Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
cdc920e
feat(service): add task and project operations with a Claude skill
LukasGold Sep 17, 2026
0b0f1ba
docs(service): correct the cause of the emptied reference lists
LukasGold Sep 18, 2026
d794302
feat(service): derive SMW property names from the category @context
LukasGold Sep 18, 2026
219a76e
feat(service): add generic schema, search and validation operations
LukasGold Sep 18, 2026
bff6dc8
fix(service): reconcile validate_entity with the write path
LukasGold Sep 18, 2026
50998e9
feat(service): add schema field-usage operation
LukasGold Sep 21, 2026
f38c400
refactor(service): drop the task read and render operations
LukasGold Sep 21, 2026
cc5c13c
fix(service): report created, updated and skipped pages separately
LukasGold Sep 21, 2026
8b8dabd
docs(skill): drive osl-tasks from the generic operations
LukasGold Sep 21, 2026
1dcba96
refactor(service): remove the task service module
LukasGold Sep 21, 2026
a97e423
fix(skill): validate before the first write, and cover the task settings
LukasGold Sep 21, 2026
e427047
docs(service): name the two terms that could desync the skipped check
LukasGold Sep 21, 2026
9309dc3
build(release): stamp the osw version into the osl-tasks skill
LukasGold Sep 22, 2026
3ee045b
test(release): check the version bump touches only the skill frontmatter
LukasGold Sep 22, 2026
e349225
build(release): stamp the osw version into the plugin manifest too
LukasGold Sep 22, 2026
af2ef57
test(release): cover CITATION.cff in the version stamping guards
LukasGold Sep 22, 2026
7be7c22
test(release): drop the version equality check
LukasGold Sep 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"name": "osw-python",
"description": "Skills for working with OpenSemanticLab wikis through the osw CLI and MCP server.",
"owner": {
"name": "OpenSemanticLab",
"url": "https://github.com/OpenSemanticLab"
},
"plugins": [
{
"name": "osl-tasks",
"source": "./",
"description": "Task and project management for OpenSemanticLab, driven by the osw CLI and MCP server."
}
]
}
14 changes: 14 additions & 0 deletions .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "osl-tasks",
"displayName": "OSL task management",
"description": "Task and project management for OpenSemanticLab, driven by the osw CLI and MCP server.",
"version": "2.7.1",
"author": {
"name": "OpenSemanticLab"
},
"homepage": "https://github.com/OpenSemanticLab/osw-python",
"repository": "https://github.com/OpenSemanticLab/osw-python",
"license": "Apache-2.0",
"keywords": ["opensemanticlab", "osl", "tasks", "wiki", "semantic-mediawiki"],
"skills": ["./src/osw/skills/"]
}
9 changes: 5 additions & 4 deletions .github/workflows/on-release-main.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
# Automated release: python-semantic-release analyzes the conventional
# commits on main, and when a release is due it bumps the static version in
# pyproject.toml and CITATION.cff, updates CHANGELOG.md, relocks uv.lock,
# commits, tags vX.Y.Z, pushes and creates the GitHub release. Publishing
# happens in this same run: pushes made with GITHUB_TOKEN do not retrigger
# workflows, so a tag-triggered publish would never fire.
# pyproject.toml, CITATION.cff, the osl-tasks skill and the Claude Code
# plugin manifest, updates CHANGELOG.md, relocks uv.lock, commits, tags
# vX.Y.Z, pushes and creates the GitHub release. Publishing happens in this
# same run: pushes made with GITHUB_TOKEN do not retrigger workflows, so a
# tag-triggered publish would never fire.
#
# Filename and environment name are load-bearing: the PyPI trusted
# publisher (OIDC) references workflow "on-release-main.yml" and
Expand Down
40 changes: 37 additions & 3 deletions docs/tools/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,12 @@ Commands are grouped by subject:

| Group | Commands |
| --- | --- |
| `entity` | `get`, `put`, `export`, `delete` |
| `entity` | `get`, `put`, `export`, `delete`, `validate` |
| `file` | `info`, `cat`, `write`, `download`, `upload` |
| `search` | `ask`, `titles`, `content`, `entities`, `sparql` |
| `search` | `ask`, `titles`, `content`, `entities`, `sparql`, `label` |
| `slot` | `list`, `get`, `set` |
| `schema` | `get` |
| `schema` | `get`, `props`, `usage` |
| `skill` | `install` |
| `instances` | `list`, `status` |
| `ledger` | `path` |
| top level | `status` |
Expand Down Expand Up @@ -79,3 +80,36 @@ form.
help.

Failures exit non-zero with a short message on stderr and no traceback.

## Tasks and projects

There is no dedicated command group for tasks. Reading local todos into an
OSL wiki as Task entities, and reading tasks, projects and persons back out,
is done with the generic `entity`, `schema` and `search` commands against the
three OSL core categories (Task, Person, Project).

**Configuration.** Four environment variables affect this, and all are
optional: `OSW_PERSON_IRI`, `OSW_TASK_CATEGORY`, `OSW_PERSON_CATEGORY` and
`OSW_PROJECT_CATEGORY`. Reading always queries the shared OSL core category,
since MediaWiki category membership includes the whole subclass tree, so a
task kept in a local subclass is found without any configuration. The three
category overrides only change where a newly created task, person or project
is written.

**Vocabularies.** `status` and `prio` store the page name of a wiki item, not
a word. Read the allowed values from the category schema with `osw schema get
<task category> --resolve`: `status` carries them in `enum`, and `prio` names
the category that holds them in `range`. A stock instance offers To do, In
work and Done for `status`, and High, Medium and Low for `prio`. A due date is
written to `end_date_time`, since the Task category has no due-date property.

### The Claude Code skill

The skill that drives this ships at `src/osw/skills/osl-tasks/SKILL.md`.
Install it one of two ways:

1. `osw skill install`, which copies it to `~/.claude/skills/osl-tasks/`.
2. `/plugin marketplace add OpenSemanticLab/osw-python` then
`/plugin install osl-tasks`.

A new Claude Code session picks it up with no further action.
4 changes: 4 additions & 0 deletions docs/tools/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,10 @@ set wins:
| `OSW_MAX_RESULTS` | `OSW_MCP_MAX_RESULTS` | Default result cap (100) |
| `OSW_MAX_CHARS` | `OSW_MCP_MAX_CHARS` | Result size cap in characters (100000) |
| `OSW_VERBOSE` | `OSW_MCP_VERBOSE` | `true` prints the configuration source report |
| `OSW_PERSON_IRI` | | Page name of the operator's own Person entity, read by the osl-tasks skill to filter tasks by actionee |
| `OSW_TASK_CATEGORY` | | Category a newly created task is written to, read by the osl-tasks skill |
| `OSW_PERSON_CATEGORY` | | Category a newly created person is written to, read by the osl-tasks skill |
| `OSW_PROJECT_CATEGORY` | | Category a newly created project is written to, read by the osl-tasks skill |

## Windows paths in a `.env` file

Expand Down
15 changes: 13 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ dependencies = [
"PyLD",
"SPARQLWrapper",
"jsonpath-ng",
# validates jsondata against a resolved JSON Schema in
# osw.service.ops.entities.validate_entity
"jsonschema>=4.0",
"numpy",
"pyyaml",
"typing_extensions",
Expand Down Expand Up @@ -300,7 +303,8 @@ preview = true

[tool.semantic_release]
# python-semantic-release owns the version: it reads the conventional commits
# on main, bumps the static version below and in CITATION.cff, updates the
# on main, bumps the static version below, in CITATION.cff, in the osl-tasks
# skill's frontmatter and in the Claude Code plugin manifest, updates the
# changelog, relocks uv.lock, commits, tags (vX.Y.Z) and creates the GitHub
# release. Publishing to PyPI happens in the same workflow run
# (on-release-main.yml).
Expand All @@ -312,7 +316,14 @@ commit_message = "chore(release): v{version} [skip ci]"
allow_zero_version = true
exclude_commit_patterns = ['''chore\(release\):.*''']
version_toml = ["pyproject.toml:project.version"]
version_variables = ["CITATION.cff:version"]
version_variables = [
"CITATION.cff:version",
"src/osw/skills/osl-tasks/SKILL.md:version",
# Claude Code caches an installed plugin under its version string, so a
# version that never changes keeps plugin users on the skill they first
# installed.
".claude-plugin/plugin.json:version",
]
# relock so uv.lock's own pin of osw's version stays consistent, and include
# the relocked file in the release commit
build_command = "uv lock"
Expand Down
1 change: 1 addition & 0 deletions src/osw/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@ def command(**kwargs: Any) -> None:
"schema": "Category JSON Schemas.",
"search": "Find pages. OSW pages are titled by OSW-ID, so use 'ask' "
"to search by name.",
"skill": "Install the Claude Code skills that ship with this package.",
"slot": "Read and write individual page slots.",
}

Expand Down
39 changes: 39 additions & 0 deletions src/osw/cli/ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,45 @@ def ledger_path(ctx: Context) -> dict:
return {"path": str(ctx.ledger.path)}


@operation(
group="skill",
cli_name="install",
surfaces=frozenset({"cli"}),
idempotent_hint=True,
)
def install_skill(
ctx: Context,
name: str = "osl-tasks",
target_dir: Optional[str] = None,
force: bool = False,
) -> dict:
"""Install a Claude Code skill that ships with this package.

Copies the packaged skill directory ``src/osw/skills/<name>`` to
``~/.claude/skills/<name>`` (or under ``target_dir`` when given). A new
Claude Code session picks the installed skill up automatically, with no
further action needed. This is the alternative to installing the
osw-python plugin from its marketplace.
"""
src = Path(__file__).resolve().parent.parent / "skills" / name
if not src.is_dir():
available = sorted(p.name for p in src.parent.iterdir() if p.is_dir())
raise errors.NotFound(
f"No packaged skill named '{name}'. Available: {', '.join(available)}."
)

base = Path(target_dir) if target_dir else Path.home() / ".claude" / "skills"
dest = base / name
if dest.exists() and not force:
raise errors.OpError(f"'{dest}' already exists. Pass --force to overwrite it.")

shutil.copytree(src, dest, dirs_exist_ok=True)
files = sorted(
p.relative_to(dest).as_posix() for p in dest.rglob("*") if p.is_file()
)
return {"name": name, "source": str(src), "target": str(dest), "files": files}


@operation(
group="instances",
cli_name="list",
Expand Down
7 changes: 5 additions & 2 deletions src/osw/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,11 @@
Entity and page titles are full MediaWiki page names, e.g. "Item:OSW1234...",
never a bare id or label.

Before creating or updating an entity, fetch its category's JSON Schema
(get_category_schema) so the written jsondata validates against it.
Before creating or updating an entity, get its category's JSON Schema with
get_category_schema(resolve=True) - the unresolved schema alone is usually
missing inherited properties - then check the payload with validate_entity
before writing it. A new entity does not need "uuid" or "type" in its
payload, since create_or_update_entity generates both itself.

This server has no filesystem access: file content moves inline as text, not
as a path. For anything path-based (uploading/downloading a local file, the
Expand Down
35 changes: 35 additions & 0 deletions src/osw/service/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@
ENV_MAX_CHARS = ("OSW_MAX_CHARS", "OSW_MCP_MAX_CHARS")
ENV_FILE = ("OSW_ENV_FILE", "OSW_MCP_ENV_FILE")
ENV_VERBOSE = ("OSW_VERBOSE", "OSW_MCP_VERBOSE")
ENV_TASK_CATEGORY = ("OSW_TASK_CATEGORY",)
ENV_PERSON_CATEGORY = ("OSW_PERSON_CATEGORY",)
ENV_PROJECT_CATEGORY = ("OSW_PROJECT_CATEGORY",)
ENV_PERSON_IRI = ("OSW_PERSON_IRI",)


def _first_env(names: tuple[str, ...]) -> Optional[str]:
Expand All @@ -64,6 +68,10 @@ def _first_env(names: tuple[str, ...]) -> Optional[str]:
"max_results": ENV_MAX_RESULTS,
"max_chars": ENV_MAX_CHARS,
"verbose": ENV_VERBOSE,
"task_category": ENV_TASK_CATEGORY,
"person_category": ENV_PERSON_CATEGORY,
"project_category": ENV_PROJECT_CATEGORY,
"person_iri": ENV_PERSON_IRI,
}


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

@field_validator("domain")
@classmethod
Expand Down Expand Up @@ -166,6 +186,17 @@ def _validate_state_dir(cls, value: Optional[str]) -> Optional[str]:
)
return value

@field_validator(
"task_category", "person_category", "project_category", "person_iri"
)
@classmethod
def _validate_non_blank(cls, value: Optional[str]) -> Optional[str]:
if value is None:
return value
if not value.strip():
raise ValueError("must not be empty or whitespace-only")
return value

@field_validator("cred_filepath")
@classmethod
def _validate_cred_filepath(cls, value: Optional[str]) -> Optional[str]:
Expand Down Expand Up @@ -755,6 +786,10 @@ def load(strict: bool = True) -> Settings:
cred_filepath=cred_filepath,
sparql_endpoint=_first_env(ENV_SPARQL_ENDPOINT),
state_dir=_first_env(ENV_STATE_DIR),
task_category=_first_env(ENV_TASK_CATEGORY),
person_category=_first_env(ENV_PERSON_CATEGORY),
project_category=_first_env(ENV_PROJECT_CATEGORY),
person_iri=_first_env(ENV_PERSON_IRI),
)
# An unset or blank/whitespace-only variable falls back to the model
# default; pass the raw string only when there is one to validate. Letting
Expand Down
20 changes: 20 additions & 0 deletions src/osw/service/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,26 @@ def page(self, title: str):
raise errors.NotFound(f"Page '{title}' does not exist.")
return page

def get_page_uncached(self, title: str):
"""Return the page for ``title``, bypassing the site page cache.

The page cache is off by default, but ``OSW.fetch_schema`` turns it
on and only restores the previous state itself when its own call
finishes; a caller that does not save and restore the cache state
around ``fetch_schema`` (e.g. ``create_or_update_entity``) can leave
it on for the rest of a long-running process. A later read through
the plain cached path could then return a page revision from before
a write made earlier in the same process. Does not raise for a
missing page; the caller branches on ``page.exists``.
"""
cache_state = self.osw.site.get_cache_enabled()
self.osw.site.disable_cache()
try:
return self.osw.site.get_page(WtSite.GetPageParam(titles=[title])).pages[0]
finally:
if cache_state:
self.osw.site.enable_cache()

def require_write(self, op_name: str) -> None:
"""Raise if this context's policy disallows writes."""
if not self.policy.allow_writes:
Expand Down
Loading
Loading