feat(skills): restore light skill creation from scratch + capability-regression review guard

Light could no longer CREATE a new skill: decide_payload_short_form's blanket is_dir gate (f705b37)
errored when the payload directory did not exist yet, so writing a new skill's SKILL.md manifest was
rejected. Treat a missing payload as CREATE when the write IS the skill manifest (SKILL.md/skill.json)
— the explicit authoring signal — while a non-manifest file into a missing payload still errors (typo
guard). The downstream write already mkdir-parents, so the manifest write provisions the new
external/<name>/ payload. Restore the explicit light-create contract in prompts/SYSTEM.md.

Anti-regression: a new advisory `capability_regression` item in docs/CHECKLISTS.md asks reviewers
(LLM-first, severity-driven) whether a diff silently removes/narrows a previously-supported capability
— the failure class this very regression belonged to — plus golden "from zero" capability tests
(tests/test_skill_create_golden.py) that turn red on exactly that removal: light creates a new external
skill from scratch, and acting (mutative) subagents stay available in light under the owner MASTER toggle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ouroboros 2026-06-19 08:12:33 +03:00
parent 48cbf4afd0
commit b5f492f03b
6 changed files with 136 additions and 3 deletions

View file

@ -165,6 +165,7 @@ Used by `commit_reviewed` for all changes to the Ouroboros repository.
| 18 | subagent_isolation | If the diff changes `schedule_subagent`, child-task queueing, task constraints, tool discovery/execution, data reads, or memory handoff, does it preserve the accepted live-subagent contract: strict `objective` + `expected_output` schema, inferred lineage/workspace/contract/deadline/resource inheritance, `local_readonly_subagent` schema and execute-time allowlist, subagent-scoped secret/control-file denial for data tools, nested readonly delegation only within configured depth/cap limits with depth>1 coerced to light, no local writes/commits/review/runtime/tool-expansion/skills-lifecycle/shell, enabled external tools allowed only by owner policy and inherited resources, the subagent browser boundary (external HTTP(S) + `file://` scoped to `workspace_root` + loopback EXCEPT Ouroboros control-plane ports; private/link-local/reserved/DNS-rebind still blocked; `evaluate` JS unavailable; `vlm_query`/`analyze_screenshot` available), full task-result handoff, new/changed wait/timeout paths for cognitive work using progress-aware/re-decidable waiting rather than a fixed cutoff that discards in-flight work (P5), and tests for both allowed and blocked paths? | critical |
| 19 | evolution_durability | If the diff touches `supervisor/git_ops.py`, `launcher.py`, `server.py`, `ouroboros/preflight_runner.py`, `ouroboros/tools/review_helpers.py`, `ouroboros/tools/git.py`, tests, review gates, or evolution code, does it preserve hermetic preflight, live repo/data mutation fuses, remote-optional local commit success, and transaction/rescue evidence for interrupted self-modification? | critical |
| 20 | context_budget_ssot | If the diff changes context-size budgets/constants (`ouroboros/context_budget.py`), the context layout/manifest, a section's tier/policy, or compaction thresholds: does it keep the low/max context split coherent (single SSOT + both profiles + docs + drift-guard tests in sync), preserve the tier-0 always-full core (BIBLE/SYSTEM/identity/scratchpad/knowledge-index/recent-dialogue) in EVERY mode, use a visible on-demand pointer instead of silent truncation (P1), and leave the blocking scope-reviewer >=1M floor untouched? (PASS with "Not applicable" if no context-budget/layout change.) | critical |
| 21 | capability_regression | Does the diff REMOVE or NARROW a previously-supported user-facing behavior or capability — a tool/flag/mode/path that worked before now errors or is gated tighter (e.g. a new `is_dir`/existence guard that blocks a legitimate create, a tightened allowlist that drops a real path, a removed fallback)? If so, is it INTENTIONAL and disclosed as a breaking/capability change in the commit message + changelog? Accidental capability removal is the failure class this item names. Ask whether a golden "from zero" test would have caught it. Severity follows the `Critical surface whitelist` below — silently removing a documented capability or a safety/release contract is critical; a deliberate, disclosed narrowing or an internal-only refactor is advisory. | advisory |
### Severity rules
@ -182,6 +183,10 @@ Used by `commit_reviewed` for all changes to the Ouroboros repository.
drift, off-by-one test counts, and minor descriptive inaccuracies in the
README changelog row MUST NOT be raised as critical under `self_consistency`
or `changelog_and_badge`. They surface here and do not block.
- Item 21 (`capability_regression`) is advisory by default but escalates to
critical under the `Critical surface whitelist` below: a SILENT removal/narrowing
of a documented capability or a safety/release contract is critical; a
deliberate, disclosed narrowing or an internal-only refactor stays advisory.
### Retry convergence for tests_affected

View file

@ -305,6 +305,12 @@ would pick. Supply both args together — passing only one returns a clear
`bucket and skill_name must be supplied together` error instead of silently
writing into the drive root.
To **create a new skill** the payload directory need not pre-exist: writing the
manifest at the payload root (`path="SKILL.md"` or `path="skill.json"`) is the
authoring signal and provisions the new payload (and marks it `self_authored`).
A non-manifest path into a not-yet-existing payload still errors as a typo guard
— write the manifest first, then add the rest of the files.
Equivalent ways to address `data/skills/external/weather/plugin.py` under
light:

View file

@ -445,6 +445,21 @@ def _explicit_path_kind(path_text: str, *, repo_dir: Path, drive_root: Path) ->
return ""
_SKILL_MANIFEST_BASENAMES = frozenset({"skill.md", "skill.json"})
def _is_skill_create_signal(path_text: str) -> bool:
"""A missing payload is a typo for an arbitrary file but a legitimate NEW skill when the write IS
the skill manifest at the payload ROOT (``SKILL.md``/``skill.json`` NOT ``nested/SKILL.md`` and
NOT an absolute path) the explicit authoring signal. Keying CREATE on the root manifest (not on
mere directory existence or a bare basename anywhere) restores light skill creation that the
f705b37 blanket is_dir gate regressed, while a misspelled or nested path still errors (typo guard)."""
raw = str(path_text or "").replace("\\", "/").strip()
if raw.startswith("./"):
raw = raw[2:]
return "/" not in raw and raw.lower() in _SKILL_MANIFEST_BASENAMES
def decide_payload_short_form(
*,
bucket: str,
@ -476,11 +491,15 @@ def decide_payload_short_form(
)
)
payload_root = (Path(drive_root) / synth.payload_root).resolve(strict=False)
if not payload_root.is_dir():
# CREATE-from-scratch is for AGENT-authored skills only: the `external` bucket. The marketplace
# buckets (clawhub/ouroboroshub) are installed FROM the marketplace, never authored into a
# missing payload, so a missing marketplace payload stays an error (install it, don't create).
if not payload_root.is_dir() and not (clean_bucket == "external" and _is_skill_create_signal(path_text)):
return PayloadShortFormDecision(
error=(
f"skill payload not found: {synth.payload_root}. "
"Use an existing skill_name, or omit bucket/skill_name for a repo/data edit."
"Use an existing skill_name; for a NEW skill write its manifest (SKILL.md/skill.json) "
"under bucket=external; or omit bucket/skill_name for a repo/data edit."
)
)
return PayloadShortFormDecision(constraint=synth)

View file

@ -667,11 +667,16 @@ def _data_write(
marker_path: pathlib.Path | None = None
if (
mode == "overwrite"
and not (task_constraint and task_constraint.mode == "skill_repair")
# A genuine NEW external skill is self-authored even when reached via the bucket+skill_name
# short-form (which synthesizes a skill_repair constraint, so the old `not skill_repair`
# guard wrongly suppressed provenance on create). Require BOTH the manifest AND the payload
# directory to be new (`not marker_payload[2].exists()`, evaluated before the mkdir below) so
# writing a SKILL.md into an ALREADY-EXISTING external skill is never mis-marked self-authored.
and marker_payload is not None
and marker_payload[0] == "external"
and pathlib.PurePosixPath(str(path or "")).name.lower() in {"skill.md", "skill.json"}
and not target_path.exists()
and not marker_payload[2].exists()
):
marker_path = marker_payload[2] / _SELF_AUTHORED_MARKER
should_mark_self_authored = not marker_path.exists()

View file

@ -220,6 +220,10 @@ When creating or repairing a skill:
- use skill-scoped tools/paths under the structured `task_constraint.mode=skill_repair`;
- inspect payloads with `read_file`/`list_files` using `root=skill_payload`;
- edit with `edit_text` for exact changes and `write_file` for new/full files using `root=skill_payload`;
- create a NEW skill by writing its `SKILL.md` manifest (the authoring signal) into a fresh
`external/<name>/` payload — `write_file(root="skill_payload", bucket="external", skill_name="<name>", path="SKILL.md", …)`;
the payload directory need not pre-exist, and create works in
`runtime_mode=light` (a missing payload errors only for a non-manifest path, as a typo guard);
- run `skill_preflight`, then `skill_review`;
- do not call a skill ready until review, grants, dependencies, enablement, and widget/extension visibility are checked as applicable.

View file

@ -0,0 +1,94 @@
"""Golden capability tests — product promises exercised from zero, so a silent capability
regression turns a test red. Flagship: light can CREATE a new external skill from scratch (no
pre-mkdir payload), the exact behavior the f705b37 blanket is_dir gate regressed."""
from ouroboros.contracts.skill_payload_policy import decide_payload_short_form
def test_light_creates_new_external_skill_from_scratch(tmp_path):
"""A NEW external skill is created by writing its SKILL.md/skill.json manifest even though the
payload directory does not exist yet (the authoring signal). A non-manifest file into a missing
payload still errors (typo guard); an existing payload edits normally. Red on f705b37."""
drive_root = tmp_path / "data"
repo_dir = tmp_path / "repo"
drive_root.mkdir(parents=True)
repo_dir.mkdir(parents=True)
# 1. CREATE: payload does NOT exist, the write IS the manifest -> allowed (constraint, no error).
for manifest in ("SKILL.md", "skill.json"):
decision = decide_payload_short_form(
bucket="external", skill_name="weather", path_text=manifest,
repo_dir=repo_dir, drive_root=drive_root,
)
assert decision.error == "", f"writing {manifest} into a fresh payload must create, not error"
assert decision.constraint is not None and decision.constraint.mode == "skill_repair"
# 2. TYPO GUARD: payload does NOT exist, the write is a non-manifest file -> still errors.
typo = decide_payload_short_form(
bucket="external", skill_name="weather", path_text="plugin.py",
repo_dir=repo_dir, drive_root=drive_root,
)
assert "skill payload not found" in (typo.error or "")
assert typo.constraint is None
# 3. EDIT: an EXISTING payload edits normally, any filename.
(drive_root / "skills" / "external" / "weather").mkdir(parents=True)
edit = decide_payload_short_form(
bucket="external", skill_name="weather", path_text="plugin.py",
repo_dir=repo_dir, drive_root=drive_root,
)
assert edit.error == "" and edit.constraint is not None
# 4. MARKETPLACE buckets are INSTALLED, not authored from scratch: a manifest into a missing
# clawhub/ouroboroshub payload still errors (only `external` is the agent-authoring bucket).
for marketplace in ("clawhub", "ouroboroshub"):
market = decide_payload_short_form(
bucket=marketplace, skill_name="fromhub", path_text="SKILL.md",
repo_dir=repo_dir, drive_root=drive_root,
)
assert "skill payload not found" in (market.error or ""), f"{marketplace} create must error"
assert market.constraint is None
def test_skill_create_signal_only_fires_for_root_manifest():
"""The CREATE carve-out must be tight: only the manifest at the payload ROOT is an authoring
signal a nested or absolute path ending in SKILL.md is NOT, so it cannot smuggle a write into
data/skills/<bucket>/<skill>/nested/ on a missing payload."""
from ouroboros.contracts.skill_payload_policy import _is_skill_create_signal
assert _is_skill_create_signal("SKILL.md") is True
assert _is_skill_create_signal("skill.json") is True
assert _is_skill_create_signal("./SKILL.md") is True
assert _is_skill_create_signal("nested/SKILL.md") is False
assert _is_skill_create_signal("/etc/SKILL.md") is False
assert _is_skill_create_signal("plugin.py") is False
assert _is_skill_create_signal("") is False
def test_short_form_create_marks_new_skill_self_authored(tmp_path, monkeypatch):
"""End-to-end: creating a new external skill via the bucket+skill_name short-form (SKILL.md into
a non-existent payload) succeeds AND marks it self-authored. The short-form synthesizes a
skill_repair constraint, so provenance marking must NOT be suppressed for a genuine create
(target/marker absent) only for repair of an existing skill."""
from ouroboros import config
from ouroboros.tools.registry import ToolContext, ToolRegistry
repo_dir = tmp_path / "repo"
drive_root = tmp_path / "drive"
repo_dir.mkdir()
drive_root.mkdir()
# the self-authored marker keys off the GLOBAL config DATA_DIR — align it with this drive.
monkeypatch.setattr(config, "DATA_DIR", str(drive_root))
ctx = ToolContext(repo_dir=repo_dir, drive_root=drive_root)
registry = ToolRegistry(repo_dir=repo_dir, drive_root=drive_root)
registry._ctx = ctx
result = registry.execute("write_file", {
"root": "skill_payload", "bucket": "external", "skill_name": "fresh",
"path": "SKILL.md", "content": "---\nname: fresh\n---\nA fresh skill.\n",
})
assert "OK" in result and "ERROR" not in result and "BLOCKED" not in result, result
payload = drive_root / "skills" / "external" / "fresh"
assert (payload / "SKILL.md").exists(), "the manifest must be created"
assert (payload / ".self_authored.json").exists(), "a newly created external skill must be marked self-authored"