Skip to content

Commit ddd4445

Browse files
authored
feat(service): generic schema and search operations with an osl-tasks skill (#204)
* feat(service): add task and project operations with a Claude skill - seven task operations shared by the CLI and the MCP server - fixed status and priority vocabularies; references resolved by label - OSW_PERSON_IRI plus three category overrides, all optional - osl-tasks skill, installed by `osw skill install` or as a plugin - reads the stored page uncached, and verifies a write really landed * docs(service): correct the cause of the emptied reference lists - oold's LinkedBaseModel.__init__ clears them, not _store - no wiki call is involved; cls(**jsondata) alone is enough - rename the test to match * feat(service): derive SMW property names from the category @context - read the field-to-property mapping from the category schema chain - cache the merged context per WtSite, cleared by clear_cache - keep the hardcoded names as a fallback when the schema is unreadable - alias printouts to the property name; SMW keys them by display label - raise instead of querying a property the @context does not declare * feat(service): add generic schema, search and validation operations - search ask: printouts parameter, result keys forced by an alias - search label: find an entity or category by its display title - schema get: resolve option merges the category inheritance chain - schema props: expose the Semantic MediaWiki property map - entity validate: check a payload against the resolved schema * fix(service): reconcile validate_entity with the write path - exempt uuid and type from required: the osw model supplies both - report pages the schema walk could not use as `skipped`, with a reason - treat a blank schema slot as absent, not as a parse failure - drop a skipped entry when a shorter branch read that page successfully - guard a malformed `required` so it still surfaces as SchemaError * feat(service): add schema field-usage operation - count per category field how many stored instances populate it - answers which of several same-shaped fields real data uses - guard the category against ask-query injection, as search does - read instances uncached, like the schema walk in the same module - report nothing when the sample is empty, and say why a page failed * refactor(service): drop the task read and render operations - list_tasks, list_projects and list_persons are covered by the generic search ask and search label operations - render_task_view wrote a local Markdown file, which the skill does itself - _resolve_ref now names search_by_label instead of the removed list ops - OSW_PERSON_IRI is no longer read by any operation * fix(service): report created, updated and skipped pages separately - default overwrite changes from 'keep existing' to 'true', so an update writes - created/updated split comes from the pre-write WtPage.exists flag - 'keep existing' now reports the page as skipped instead of updated - the ledger records only pages that were actually written * docs(skill): drive osl-tasks from the generic operations - replaces the task-specific commands with entity, schema and search ops - keeps the three category page names in prose; they are not discoverable - status and priority vocabularies are read from the category schema - records the update-by-uuid rule and the required-label validation - documents the printout value shapes returned by an ask query * refactor(service): remove the task service module - create_task, update_task and create_person are covered by the osl-tasks skill driving the generic entity, schema and search operations - drops the 'task' CLI group and its three MCP tools - keeps the four OSW_*_CATEGORY and OSW_PERSON_IRI variables; no operation reads them any more, the skill reads the environment directly - corrects the documented status and prio vocabularies: they store page names read from the category schema, not the words the commands took * fix(skill): validate before the first write, and cover the task settings - the skill now runs 'entity validate' once per run, which catches a wrong status page name against the schema enum before anything is stored - adds the alias mapping the deleted module held, as prose - states that the four env variables are unreachable through the MCP server - a config test now covers the four settings no operation reads * docs(service): name the two terms that could desync the skipped check * build(release): stamp the osw version into the osl-tasks skill - SKILL.md carries it as metadata.version, the Agent Skills spec field - python-semantic-release rewrites it with pyproject.toml and CITATION.cff - a test fails when the two versions differ or the entry is removed * test(release): check the version bump touches only the skill frontmatter * build(release): stamp the osw version into the plugin manifest too - register .claude-plugin/plugin.json:version with python-semantic-release - set the plugin and skill versions to the current 2.7.0 after the rebase - parametrize the three version guard tests over skill and plugin * test(release): cover CITATION.cff in the version stamping guards - add the third stamped file to both parametrized guard tests - compare against the release tool's own read, not a UTF-8 read - name all four stamped files in the release workflow comment * test(release): drop the version equality check - a PR is tested against its merge with main, so a release there leaves a branch that edits SKILL.md with an older version in that file only - the check failed on this PR for that reason, not for a real defect - registration and replacement tests still cover the rot that matters - set the skill and plugin versions to main's current 2.7.1
1 parent 480819d commit ddd4445

23 files changed

Lines changed: 3373 additions & 35 deletions

‎.claude-plugin/marketplace.json‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"name": "osw-python",
3+
"description": "Skills for working with OpenSemanticLab wikis through the osw CLI and MCP server.",
4+
"owner": {
5+
"name": "OpenSemanticLab",
6+
"url": "https://github.com/OpenSemanticLab"
7+
},
8+
"plugins": [
9+
{
10+
"name": "osl-tasks",
11+
"source": "./",
12+
"description": "Task and project management for OpenSemanticLab, driven by the osw CLI and MCP server."
13+
}
14+
]
15+
}

‎.claude-plugin/plugin.json‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"name": "osl-tasks",
3+
"displayName": "OSL task management",
4+
"description": "Task and project management for OpenSemanticLab, driven by the osw CLI and MCP server.",
5+
"version": "2.7.1",
6+
"author": {
7+
"name": "OpenSemanticLab"
8+
},
9+
"homepage": "https://github.com/OpenSemanticLab/osw-python",
10+
"repository": "https://github.com/OpenSemanticLab/osw-python",
11+
"license": "Apache-2.0",
12+
"keywords": ["opensemanticlab", "osl", "tasks", "wiki", "semantic-mediawiki"],
13+
"skills": ["./src/osw/skills/"]
14+
}

‎.github/workflows/on-release-main.yml‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
# Automated release: python-semantic-release analyzes the conventional
22
# commits on main, and when a release is due it bumps the static version in
3-
# pyproject.toml and CITATION.cff, updates CHANGELOG.md, relocks uv.lock,
4-
# commits, tags vX.Y.Z, pushes and creates the GitHub release. Publishing
5-
# happens in this same run: pushes made with GITHUB_TOKEN do not retrigger
6-
# workflows, so a tag-triggered publish would never fire.
3+
# pyproject.toml, CITATION.cff, the osl-tasks skill and the Claude Code
4+
# plugin manifest, updates CHANGELOG.md, relocks uv.lock, commits, tags
5+
# vX.Y.Z, pushes and creates the GitHub release. Publishing happens in this
6+
# same run: pushes made with GITHUB_TOKEN do not retrigger workflows, so a
7+
# tag-triggered publish would never fire.
78
#
89
# Filename and environment name are load-bearing: the PyPI trusted
910
# publisher (OIDC) references workflow "on-release-main.yml" and

‎docs/tools/cli.md‎

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,12 @@ Commands are grouped by subject:
3232

3333
| Group | Commands |
3434
| --- | --- |
35-
| `entity` | `get`, `put`, `export`, `delete` |
35+
| `entity` | `get`, `put`, `export`, `delete`, `validate` |
3636
| `file` | `info`, `cat`, `write`, `download`, `upload` |
37-
| `search` | `ask`, `titles`, `content`, `entities`, `sparql` |
37+
| `search` | `ask`, `titles`, `content`, `entities`, `sparql`, `label` |
3838
| `slot` | `list`, `get`, `set` |
39-
| `schema` | `get` |
39+
| `schema` | `get`, `props`, `usage` |
40+
| `skill` | `install` |
4041
| `instances` | `list`, `status` |
4142
| `ledger` | `path` |
4243
| top level | `status` |
@@ -79,3 +80,36 @@ form.
7980
help.
8081

8182
Failures exit non-zero with a short message on stderr and no traceback.
83+
84+
## Tasks and projects
85+
86+
There is no dedicated command group for tasks. Reading local todos into an
87+
OSL wiki as Task entities, and reading tasks, projects and persons back out,
88+
is done with the generic `entity`, `schema` and `search` commands against the
89+
three OSL core categories (Task, Person, Project).
90+
91+
**Configuration.** Four environment variables affect this, and all are
92+
optional: `OSW_PERSON_IRI`, `OSW_TASK_CATEGORY`, `OSW_PERSON_CATEGORY` and
93+
`OSW_PROJECT_CATEGORY`. Reading always queries the shared OSL core category,
94+
since MediaWiki category membership includes the whole subclass tree, so a
95+
task kept in a local subclass is found without any configuration. The three
96+
category overrides only change where a newly created task, person or project
97+
is written.
98+
99+
**Vocabularies.** `status` and `prio` store the page name of a wiki item, not
100+
a word. Read the allowed values from the category schema with `osw schema get
101+
<task category> --resolve`: `status` carries them in `enum`, and `prio` names
102+
the category that holds them in `range`. A stock instance offers To do, In
103+
work and Done for `status`, and High, Medium and Low for `prio`. A due date is
104+
written to `end_date_time`, since the Task category has no due-date property.
105+
106+
### The Claude Code skill
107+
108+
The skill that drives this ships at `src/osw/skills/osl-tasks/SKILL.md`.
109+
Install it one of two ways:
110+
111+
1. `osw skill install`, which copies it to `~/.claude/skills/osl-tasks/`.
112+
2. `/plugin marketplace add OpenSemanticLab/osw-python` then
113+
`/plugin install osl-tasks`.
114+
115+
A new Claude Code session picks it up with no further action.

‎docs/tools/configuration.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,10 @@ set wins:
111111
| `OSW_MAX_RESULTS` | `OSW_MCP_MAX_RESULTS` | Default result cap (100) |
112112
| `OSW_MAX_CHARS` | `OSW_MCP_MAX_CHARS` | Result size cap in characters (100000) |
113113
| `OSW_VERBOSE` | `OSW_MCP_VERBOSE` | `true` prints the configuration source report |
114+
| `OSW_PERSON_IRI` | | Page name of the operator's own Person entity, read by the osl-tasks skill to filter tasks by actionee |
115+
| `OSW_TASK_CATEGORY` | | Category a newly created task is written to, read by the osl-tasks skill |
116+
| `OSW_PERSON_CATEGORY` | | Category a newly created person is written to, read by the osl-tasks skill |
117+
| `OSW_PROJECT_CATEGORY` | | Category a newly created project is written to, read by the osl-tasks skill |
114118

115119
## Windows paths in a `.env` file
116120

‎pyproject.toml‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ dependencies = [
3838
"PyLD",
3939
"SPARQLWrapper",
4040
"jsonpath-ng",
41+
# validates jsondata against a resolved JSON Schema in
42+
# osw.service.ops.entities.validate_entity
43+
"jsonschema>=4.0",
4144
"numpy",
4245
"pyyaml",
4346
"typing_extensions",
@@ -303,7 +306,8 @@ preview = true
303306

304307
[tool.semantic_release]
305308
# python-semantic-release owns the version: it reads the conventional commits
306-
# on main, bumps the static version below and in CITATION.cff, updates the
309+
# on main, bumps the static version below, in CITATION.cff, in the osl-tasks
310+
# skill's frontmatter and in the Claude Code plugin manifest, updates the
307311
# changelog, relocks uv.lock, commits, tags (vX.Y.Z) and creates the GitHub
308312
# release. Publishing to PyPI happens in the same workflow run
309313
# (on-release-main.yml).
@@ -315,7 +319,14 @@ commit_message = "chore(release): v{version} [skip ci]"
315319
allow_zero_version = true
316320
exclude_commit_patterns = ['''chore\(release\):.*''']
317321
version_toml = ["pyproject.toml:project.version"]
318-
version_variables = ["CITATION.cff:version"]
322+
version_variables = [
323+
"CITATION.cff:version",
324+
"src/osw/skills/osl-tasks/SKILL.md:version",
325+
# Claude Code caches an installed plugin under its version string, so a
326+
# version that never changes keeps plugin users on the skill they first
327+
# installed.
328+
".claude-plugin/plugin.json:version",
329+
]
319330
# relock so uv.lock's own pin of osw's version stays consistent, and include
320331
# the relocked file in the release commit
321332
build_command = "uv lock"

‎src/osw/cli/main.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,7 @@ def command(**kwargs: Any) -> None:
323323
"schema": "Category JSON Schemas.",
324324
"search": "Find pages. OSW pages are titled by OSW-ID, so use 'ask' "
325325
"to search by name.",
326+
"skill": "Install the Claude Code skills that ship with this package.",
326327
"slot": "Read and write individual page slots.",
327328
}
328329

‎src/osw/cli/ops.py‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,45 @@ def ledger_path(ctx: Context) -> dict:
148148
return {"path": str(ctx.ledger.path)}
149149

150150

151+
@operation(
152+
group="skill",
153+
cli_name="install",
154+
surfaces=frozenset({"cli"}),
155+
idempotent_hint=True,
156+
)
157+
def install_skill(
158+
ctx: Context,
159+
name: str = "osl-tasks",
160+
target_dir: Optional[str] = None,
161+
force: bool = False,
162+
) -> dict:
163+
"""Install a Claude Code skill that ships with this package.
164+
165+
Copies the packaged skill directory ``src/osw/skills/<name>`` to
166+
``~/.claude/skills/<name>`` (or under ``target_dir`` when given). A new
167+
Claude Code session picks the installed skill up automatically, with no
168+
further action needed. This is the alternative to installing the
169+
osw-python plugin from its marketplace.
170+
"""
171+
src = Path(__file__).resolve().parent.parent / "skills" / name
172+
if not src.is_dir():
173+
available = sorted(p.name for p in src.parent.iterdir() if p.is_dir())
174+
raise errors.NotFound(
175+
f"No packaged skill named '{name}'. Available: {', '.join(available)}."
176+
)
177+
178+
base = Path(target_dir) if target_dir else Path.home() / ".claude" / "skills"
179+
dest = base / name
180+
if dest.exists() and not force:
181+
raise errors.OpError(f"'{dest}' already exists. Pass --force to overwrite it.")
182+
183+
shutil.copytree(src, dest, dirs_exist_ok=True)
184+
files = sorted(
185+
p.relative_to(dest).as_posix() for p in dest.rglob("*") if p.is_file()
186+
)
187+
return {"name": name, "source": str(src), "target": str(dest), "files": files}
188+
189+
151190
@operation(
152191
group="instances",
153192
cli_name="list",

‎src/osw/mcp/server.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,11 @@
3535
Entity and page titles are full MediaWiki page names, e.g. "Item:OSW1234...",
3636
never a bare id or label.
3737
38-
Before creating or updating an entity, fetch its category's JSON Schema
39-
(get_category_schema) so the written jsondata validates against it.
38+
Before creating or updating an entity, get its category's JSON Schema with
39+
get_category_schema(resolve=True) - the unresolved schema alone is usually
40+
missing inherited properties - then check the payload with validate_entity
41+
before writing it. A new entity does not need "uuid" or "type" in its
42+
payload, since create_or_update_entity generates both itself.
4043
4144
This server has no filesystem access: file content moves inline as text, not
4245
as a path. For anything path-based (uploading/downloading a local file, the

‎src/osw/service/config.py‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@
4040
ENV_MAX_CHARS = ("OSW_MAX_CHARS", "OSW_MCP_MAX_CHARS")
4141
ENV_FILE = ("OSW_ENV_FILE", "OSW_MCP_ENV_FILE")
4242
ENV_VERBOSE = ("OSW_VERBOSE", "OSW_MCP_VERBOSE")
43+
ENV_TASK_CATEGORY = ("OSW_TASK_CATEGORY",)
44+
ENV_PERSON_CATEGORY = ("OSW_PERSON_CATEGORY",)
45+
ENV_PROJECT_CATEGORY = ("OSW_PROJECT_CATEGORY",)
46+
ENV_PERSON_IRI = ("OSW_PERSON_IRI",)
4347

4448

4549
def _first_env(names: tuple[str, ...]) -> Optional[str]:
@@ -64,6 +68,10 @@ def _first_env(names: tuple[str, ...]) -> Optional[str]:
6468
"max_results": ENV_MAX_RESULTS,
6569
"max_chars": ENV_MAX_CHARS,
6670
"verbose": ENV_VERBOSE,
71+
"task_category": ENV_TASK_CATEGORY,
72+
"person_category": ENV_PERSON_CATEGORY,
73+
"project_category": ENV_PROJECT_CATEGORY,
74+
"person_iri": ENV_PERSON_IRI,
6775
}
6876

6977

@@ -105,6 +113,18 @@ class Settings(BaseModel):
105113
# Only controls the startup configuration report. No tool or command
106114
# reads it, and Settings.redacted() deliberately does not expose it.
107115
verbose: bool = False
116+
# Category a newly created task, person or project is written to. No
117+
# operation reads these three; the osl-tasks skill reads the env variables
118+
# directly, and only when a new entity has to go into a local subclass
119+
# instead of the shared OSL core category.
120+
task_category: Optional[str] = None
121+
person_category: Optional[str] = None
122+
project_category: Optional[str] = None
123+
# Full page name of the Person entity representing the operator, e.g.
124+
# "Item:OSW...". No operation reads it; the osl-tasks skill reads the env
125+
# variable directly to filter tasks by their actionee. An actionee is never
126+
# assigned from it, so a task created without an explicit actionee has none.
127+
person_iri: Optional[str] = None
108128

109129
@field_validator("domain")
110130
@classmethod
@@ -166,6 +186,17 @@ def _validate_state_dir(cls, value: Optional[str]) -> Optional[str]:
166186
)
167187
return value
168188

189+
@field_validator(
190+
"task_category", "person_category", "project_category", "person_iri"
191+
)
192+
@classmethod
193+
def _validate_non_blank(cls, value: Optional[str]) -> Optional[str]:
194+
if value is None:
195+
return value
196+
if not value.strip():
197+
raise ValueError("must not be empty or whitespace-only")
198+
return value
199+
169200
@field_validator("cred_filepath")
170201
@classmethod
171202
def _validate_cred_filepath(cls, value: Optional[str]) -> Optional[str]:
@@ -755,6 +786,10 @@ def load(strict: bool = True) -> Settings:
755786
cred_filepath=cred_filepath,
756787
sparql_endpoint=_first_env(ENV_SPARQL_ENDPOINT),
757788
state_dir=_first_env(ENV_STATE_DIR),
789+
task_category=_first_env(ENV_TASK_CATEGORY),
790+
person_category=_first_env(ENV_PERSON_CATEGORY),
791+
project_category=_first_env(ENV_PROJECT_CATEGORY),
792+
person_iri=_first_env(ENV_PERSON_IRI),
758793
)
759794
# An unset or blank/whitespace-only variable falls back to the model
760795
# default; pass the raw string only when there is one to validate. Letting

0 commit comments

Comments
 (0)