From e657e09328b8d48ffe9f63a6833a23e35332f73c Mon Sep 17 00:00:00 2001 From: "Bogdan (Dan) Baciu" Date: Mon, 10 Aug 2026 02:32:34 +0400 Subject: [PATCH 1/5] feat(sleep): adopt reviewed skill subsets safely --- docs/sleep/README.md | 4 + docs/sleep/multi-skill-staging.md | 90 ++++++++++ skillopt_sleep/staging.py | 140 ++++++++++++++- tests/test_sleep_adopt_skill_subset.py | 239 +++++++++++++++++++++++++ 4 files changed, 472 insertions(+), 1 deletion(-) create mode 100644 docs/sleep/multi-skill-staging.md create mode 100644 tests/test_sleep_adopt_skill_subset.py diff --git a/docs/sleep/README.md b/docs/sleep/README.md index 5b29ebcf..7f5be874 100644 --- a/docs/sleep/README.md +++ b/docs/sleep/README.md @@ -299,6 +299,10 @@ gate keeps the worst case bounded; keep it **on** by default. ## Learn more +Staging a proposal for more than one skill, and adopting a reviewed subset of +them with backups and hash receipts, is documented in +[`docs/sleep/multi-skill-staging.md`](multi-skill-staging.md). + See the [SkillOpt documentation index](../index.md), the [CLI reference](../reference/cli.md), and the integration-specific READMEs under [`plugins/`](https://github.com/microsoft/SkillOpt/tree/main/plugins). diff --git a/docs/sleep/multi-skill-staging.md b/docs/sleep/multi-skill-staging.md new file mode 100644 index 00000000..2f29e2fb --- /dev/null +++ b/docs/sleep/multi-skill-staging.md @@ -0,0 +1,90 @@ +# Multi-skill staging and subset adoption + +A night can stage a proposal for more than one skill. Adoption stays explicit: +staging only ever writes into the staging directory, and `adopt_skills()` copies +a **reviewed subset** over the live files, with a backup and a hash receipt per +skill. + +Nothing here changes a single-managed-skill night. If a night stages no per-skill +proposals, the staging directory and `manifest.json` are exactly the legacy ones +and `skillopt-sleep adopt` keeps working unchanged. + +## Staging layout + +Legacy (single managed skill) — unchanged: + +```text +.skillopt-sleep/staging/20260728-013000/ +├── manifest.json # live_skill_path, live_memory_path, has_skill, has_memory, accepted +├── proposed_SKILL.md +├── proposed_CLAUDE.md +├── report.json +└── report.md +``` + +Multi-skill night — one extra file and one manifest row per skill: + +```text +.skillopt-sleep/staging/20260728-013000/ +├── manifest.json # …the legacy keys plus "skills": [ … ] +├── proposed_SKILL.alpha.md +├── proposed_SKILL.beta.md +├── report.json # report.skill_groups carries each skill's gate evidence +└── report.md +``` + +```json +{ + "live_skill_path": "/home/dev/.claude/skills/alpha/SKILL.md", + "has_skill": false, + "accepted": true, + "skills": [ + { + "skill_name": "alpha", + "proposed_file": "proposed_SKILL.alpha.md", + "live_skill_path": "/home/dev/.claude/skills/alpha/SKILL.md" + }, + { + "skill_name": "beta", + "proposed_file": "proposed_SKILL.beta.md", + "live_skill_path": "/home/dev/.claude/skills/beta/SKILL.md" + } + ] +} +``` + +A skill name must be a single safe path segment and a live path must be an +absolute, traversal-free `*.md` file; two skills may not share a name or a target +file. A refused fan-out writes no `manifest.json`, so the folder is not adoptable. + +## Adopting a reviewed subset + +```python +from skillopt_sleep.staging import adopt_skills, latest_staging, staged_skills + +staging = latest_staging("/path/to/project") +[row["skill_name"] for row in staged_skills(staging)] # ['alpha', 'beta'] + +receipts = adopt_skills(staging, ["alpha"]) # beta is left alone +receipts[0].sha256_before, receipts[0].sha256_after +``` + +- `skill_names=None` adopts every staged skill; `[]` adopts nothing. +- An unknown or repeated name, an unsafe manifest row, or a missing proposal file + raises `StagingError` **before** anything is written. +- Each live file is backed up to `backup/skills//` and written atomically. +- If any write fails, every file in the selection is restored (and files that did + not exist before are removed), so a partial adoption never survives. +- Receipts (`skill_name`, `live_skill_path`, `sha256_before`, `sha256_after`, + `backup_path`) are returned and written to `adopted_skills.json` in the staging + directory. An empty `sha256_before` means the skill had no live file yet. + +## Migrating + +- **Consumers of `manifest.json`**: treat `"skills"` as optional; when absent the + night is a legacy single-proposal one. +- **Consumers of `report.json`**: `skill_groups` is `[]` on a single-skill night, + and the flat `accepted` / `gate_action` / score fields keep their meaning. +- **Adoption tooling**: `adopt()` still adopts the legacy single proposal pair. + Use `adopt_skills()` for per-skill nights; the two are independent, and neither + runs implicitly. diff --git a/skillopt_sleep/staging.py b/skillopt_sleep/staging.py index fb6f8863..d4e4d63a 100644 --- a/skillopt_sleep/staging.py +++ b/skillopt_sleep/staging.py @@ -7,14 +7,16 @@ """ from __future__ import annotations +import hashlib import json import os import re import shutil +import stat import tempfile import time from dataclasses import dataclass -from typing import Any, Dict, Iterable, List, Optional +from typing import Any, Dict, Iterable, List, Optional, Sequence from skillopt_sleep.types import SleepReport @@ -310,12 +312,17 @@ def _write_atomic(path: str, text: str) -> None: """Write ``text`` to ``path`` atomically, so review never sees half a file.""" directory = os.path.dirname(path) or "." os.makedirs(directory, exist_ok=True) + existing_mode = ( + stat.S_IMODE(os.stat(path).st_mode) if os.path.exists(path) else None + ) fd, tmp = tempfile.mkstemp(dir=directory, prefix=".tmp-", suffix=".md") try: with os.fdopen(fd, "w", encoding="utf-8") as f: f.write(text) f.flush() os.fsync(f.fileno()) + if existing_mode is not None: + os.chmod(tmp, existing_mode) os.replace(tmp, path) except BaseException: if os.path.exists(tmp): @@ -495,6 +502,137 @@ def write_staging( return out +@dataclass +class AdoptedSkill: + """Receipt for one adopted skill: where it landed and what changed.""" + + skill_name: str + live_skill_path: str + sha256_before: str # "" when no live file existed yet + sha256_after: str + backup_path: str = "" # "" when there was nothing to back up + + +def _sha256_text(text: str) -> str: + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def staged_skills(staging_dir: str) -> List[Dict[str, Any]]: + """Manifest rows for the per-skill proposals staged in ``staging_dir``.""" + with open(os.path.join(staging_dir, "manifest.json"), encoding="utf-8") as f: + manifest = json.load(f) + if not isinstance(manifest, dict): + raise StagingError("staging manifest must be a JSON object") + if "skills" not in manifest: + return [] + rows = manifest["skills"] + if not isinstance(rows, list): + raise StagingError("staging manifest 'skills' must be a list") + if any(not isinstance(row, dict) for row in rows): + raise StagingError("every staging manifest 'skills' row must be an object") + return rows + + +def _selected_rows( + rows: Sequence[Dict[str, Any]], skill_names: Optional[Sequence[str]] +) -> List[Dict[str, Any]]: + """Rows for the reviewed subset, in manifest order, or every row.""" + if skill_names is None: + return list(rows) + wanted = [str(n).strip() for n in skill_names] + if not wanted: + return [] + known = {str(row.get("skill_name", "")) for row in rows} + unknown = [n for n in wanted if n not in known] + if unknown: + raise StagingError(f"no staged proposal for: {', '.join(sorted(unknown))}") + duplicates = {n for n in wanted if wanted.count(n) > 1} + if duplicates: + raise StagingError(f"skill selected twice: {', '.join(sorted(duplicates))}") + chosen = set(wanted) + return [row for row in rows if str(row.get("skill_name", "")) in chosen] + + +def adopt_skills( + staging_dir: str, skill_names: Optional[Sequence[str]] = None +) -> List[AdoptedSkill]: + """Adopt an explicitly reviewed subset of staged per-skill proposals. + + ``skill_names`` selects which staged skills to adopt; ``None`` means every + staged skill. Nothing is adopted implicitly and skills outside the selection + are never touched. + + Every selected proposal is validated first, each live file is backed up, and + the writes are rolled back as a set if any one of them fails, so a partial + adoption never survives. Returns a before/after sha256 receipt per skill and + also writes them to ``adopted_skills.json`` in the staging directory. + """ + rows = _selected_rows(staged_skills(staging_dir), skill_names) + if not rows: + return [] + + plan: List[tuple] = [] + for row in rows: + name = _safe_skill_name(row.get("skill_name")) + if not name: + raise StagingError(f"unsafe staged skill name: {row.get('skill_name')!r}") + live = _safe_live_path(row.get("live_skill_path")) + if not live: + raise StagingError( + f"unsafe live skill path for {name!r}: {row.get('live_skill_path')!r}" + ) + proposed_file = row.get("proposed_file") + expected_file = proposal_filename(name) + if proposed_file != expected_file: + raise StagingError( + f"unsafe staged proposal filename for {name!r}: {proposed_file!r}; " + f"expected {expected_file!r}" + ) + staged = os.path.join(staging_dir, expected_file) + if not os.path.isfile(staged): + raise StagingError(f"staged proposal missing for {name!r}: {staged}") + plan.append((name, live, staged)) + + backup_dir = os.path.join(staging_dir, "backup", "skills") + receipts: List[AdoptedSkill] = [] + done: List[tuple] = [] # (live, original_bytes or None) for rollback + try: + for name, live, staged in plan: + with open(staged, encoding="utf-8") as f: + proposed = f.read() + original = None + backup_path = "" + if os.path.exists(live): + with open(live, "rb") as f: + original = f.read() + skill_backup = os.path.join(backup_dir, name) + os.makedirs(skill_backup, exist_ok=True) + backup_path = os.path.join(skill_backup, os.path.basename(live)) + shutil.copy2(live, backup_path) + before = hashlib.sha256(original).hexdigest() if original is not None else "" + _write_atomic(live, proposed) + done.append((live, original)) + receipts.append(AdoptedSkill( + skill_name=name, live_skill_path=live, sha256_before=before, + sha256_after=_sha256_text(proposed), backup_path=backup_path, + )) + except BaseException: + for live, original in reversed(done): + if original is None: + if os.path.exists(live): + os.unlink(live) + else: + with open(live, "wb") as f: + f.write(original) + raise + + _write_atomic( + os.path.join(staging_dir, "adopted_skills.json"), + json.dumps([r.__dict__ for r in receipts], ensure_ascii=False, indent=2), + ) + return receipts + + def _backup(path: str, backup_dir: str) -> None: if os.path.exists(path): os.makedirs(backup_dir, exist_ok=True) diff --git a/tests/test_sleep_adopt_skill_subset.py b/tests/test_sleep_adopt_skill_subset.py new file mode 100644 index 00000000..86247386 --- /dev/null +++ b/tests/test_sleep_adopt_skill_subset.py @@ -0,0 +1,239 @@ +"""Tests for explicit multi-skill subset adoption (issue #120). + +Pure-stdlib (unittest), hermetic (tmpdir only), no API key, no network. +Run: python -m pytest tests/test_sleep_adopt_skill_subset.py +""" +from __future__ import annotations + +import hashlib +import json +import os +import stat +import tempfile +import unittest + +from skillopt_sleep.staging import ( + SkillProposal, + StagingError, + adopt_skills, + staged_skills, + write_staging, +) +from skillopt_sleep.types import SleepReport + + +def _sha(text): + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def _read(path): + with open(path, encoding="utf-8") as f: + return f.read() + + +def _write(path, text): + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + f.write(text) + + +class TwoSkillNight: + """End-to-end fixture: a staged night with two per-skill proposals.""" + + def __init__(self, tmp): + self.tmp = tmp + self.live_root = os.path.join(tmp, "live") + self.alpha_live = os.path.join(self.live_root, "alpha", "SKILL.md") + self.beta_live = os.path.join(self.live_root, "beta", "SKILL.md") + _write(self.alpha_live, "# alpha v1\n") + _write(self.beta_live, "# beta v1\n") + self.staging = write_staging( + tmp, + report=SleepReport(night=1, project=tmp, accepted=True), + proposed_skill=None, proposed_memory=None, + live_skill_path=self.alpha_live, + live_memory_path=os.path.join(self.live_root, "CLAUDE.md"), + report_md="# report\n", + skill_proposals=[ + SkillProposal("alpha", "# alpha v2\n", self.alpha_live), + SkillProposal("beta", "# beta v2\n", self.beta_live), + ], + ) + + +class TestStagedSkills(unittest.TestCase): + def test_rows_are_readable_from_the_manifest(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + rows = staged_skills(night.staging) + self.assertEqual([r["skill_name"] for r in rows], ["alpha", "beta"]) + + def test_legacy_single_proposal_night_has_no_staged_skills(self): + with tempfile.TemporaryDirectory() as tmp: + out = write_staging( + tmp, report=SleepReport(night=1, project=tmp), proposed_skill="# s\n", + proposed_memory=None, + live_skill_path=os.path.join(tmp, "live", "SKILL.md"), + live_memory_path=os.path.join(tmp, "live", "CLAUDE.md"), + report_md="# report\n", + ) + self.assertEqual(staged_skills(out), []) + self.assertEqual(adopt_skills(out), []) + + def test_malformed_skills_manifest_shape_is_refused(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + manifest_path = os.path.join(night.staging, "manifest.json") + for malformed in ({"not": "a list"}, [{"skill_name": "alpha"}, "bad"]): + with open(manifest_path, encoding="utf-8") as f: + manifest = json.load(f) + manifest["skills"] = malformed + with open(manifest_path, "w", encoding="utf-8") as f: + json.dump(manifest, f) + with self.assertRaises(StagingError, msg=repr(malformed)): + staged_skills(night.staging) + + +class TestAdoptSkillSubset(unittest.TestCase): + def test_adopting_one_skill_leaves_the_other_untouched(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + receipts = adopt_skills(night.staging, ["alpha"]) + self.assertEqual([r.skill_name for r in receipts], ["alpha"]) + self.assertEqual(_read(night.alpha_live), "# alpha v2\n") + self.assertEqual(_read(night.beta_live), "# beta v1\n") + + def test_receipts_carry_before_and_after_hashes(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + receipt = adopt_skills(night.staging, ["alpha"])[0] + self.assertEqual(receipt.sha256_before, _sha("# alpha v1\n")) + self.assertEqual(receipt.sha256_after, _sha("# alpha v2\n")) + self.assertEqual(receipt.live_skill_path, night.alpha_live) + self.assertEqual(_read(receipt.backup_path), "# alpha v1\n") + + def test_receipts_are_persisted_beside_the_report(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + adopt_skills(night.staging, ["beta"]) + with open(os.path.join(night.staging, "adopted_skills.json"), + encoding="utf-8") as f: + rows = json.load(f) + self.assertEqual([r["skill_name"] for r in rows], ["beta"]) + self.assertEqual(rows[0]["sha256_after"], _sha("# beta v2\n")) + + def test_selecting_no_skills_adopts_nothing(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + self.assertEqual(adopt_skills(night.staging, []), []) + self.assertEqual(_read(night.alpha_live), "# alpha v1\n") + self.assertEqual(_read(night.beta_live), "# beta v1\n") + self.assertFalse( + os.path.exists(os.path.join(night.staging, "adopted_skills.json"))) + + def test_selecting_every_skill_adopts_all_of_them(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + receipts = adopt_skills(night.staging) + self.assertEqual([r.skill_name for r in receipts], ["alpha", "beta"]) + self.assertEqual(_read(night.alpha_live), "# alpha v2\n") + self.assertEqual(_read(night.beta_live), "# beta v2\n") + + def test_a_new_live_file_reports_an_empty_before_hash(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + os.unlink(night.beta_live) + receipt = [r for r in adopt_skills(night.staging) if r.skill_name == "beta"][0] + self.assertEqual(receipt.sha256_before, "") + self.assertEqual(receipt.backup_path, "") + self.assertEqual(_read(night.beta_live), "# beta v2\n") + + def test_unknown_or_repeated_selection_is_refused_without_writing(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + for selection in (["gamma"], ["alpha", "gamma"], ["alpha", "alpha"]): + with self.assertRaises(StagingError, msg=str(selection)): + adopt_skills(night.staging, selection) + self.assertEqual(_read(night.alpha_live), "# alpha v1\n") + self.assertEqual(_read(night.beta_live), "# beta v1\n") + + def test_missing_staged_proposal_is_refused_without_writing(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + os.unlink(os.path.join(night.staging, "proposed_SKILL.beta.md")) + with self.assertRaises(StagingError): + adopt_skills(night.staging) + self.assertEqual(_read(night.alpha_live), "# alpha v1\n") + + def test_unsafe_manifest_row_is_refused_without_writing(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + manifest_path = os.path.join(night.staging, "manifest.json") + with open(manifest_path, encoding="utf-8") as f: + manifest = json.load(f) + manifest["skills"][1]["live_skill_path"] = "relative/SKILL.md" + with open(manifest_path, "w", encoding="utf-8") as f: + json.dump(manifest, f) + with self.assertRaises(StagingError): + adopt_skills(night.staging) + self.assertEqual(_read(night.alpha_live), "# alpha v1\n") + + def test_manifest_proposal_filename_cannot_escape_staging(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + outside = os.path.join(tmp, "outside.md") + _write(outside, "# not a staged proposal\n") + manifest_path = os.path.join(night.staging, "manifest.json") + with open(manifest_path, encoding="utf-8") as f: + manifest = json.load(f) + manifest["skills"][0]["proposed_file"] = os.path.relpath( + outside, night.staging + ) + with open(manifest_path, "w", encoding="utf-8") as f: + json.dump(manifest, f) + with self.assertRaises(StagingError): + adopt_skills(night.staging, ["alpha"]) + self.assertEqual(_read(night.alpha_live), "# alpha v1\n") + + def test_adoption_preserves_existing_live_file_mode(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + os.chmod(night.alpha_live, 0o640) + adopt_skills(night.staging, ["alpha"]) + self.assertEqual(stat.S_IMODE(os.stat(night.alpha_live).st_mode), 0o640) + + def test_a_failed_write_rolls_the_whole_selection_back(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + # beta's live path becomes un-writable: its parent is now a file. + os.unlink(night.beta_live) + os.rmdir(os.path.dirname(night.beta_live)) + _write(os.path.dirname(night.beta_live), "not a directory\n") + with self.assertRaises(OSError): + adopt_skills(night.staging) + self.assertEqual(_read(night.alpha_live), "# alpha v1\n") + self.assertFalse( + os.path.exists(os.path.join(night.staging, "adopted_skills.json"))) + + def test_rollback_removes_files_that_did_not_exist_before(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + os.unlink(night.alpha_live) + os.unlink(night.beta_live) + os.rmdir(os.path.dirname(night.beta_live)) + _write(os.path.dirname(night.beta_live), "not a directory\n") + with self.assertRaises(OSError): + adopt_skills(night.staging) + self.assertFalse(os.path.exists(night.alpha_live)) + + def test_adoption_never_happens_without_an_explicit_call(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + self.assertEqual(_read(night.alpha_live), "# alpha v1\n") + self.assertEqual(_read(night.beta_live), "# beta v1\n") + self.assertTrue(os.path.exists( + os.path.join(night.staging, "proposed_SKILL.alpha.md"))) + + +if __name__ == "__main__": + unittest.main() From e9d8c9303c89ec8d10799263cf31fbe38225e958 Mon Sep 17 00:00:00 2001 From: "Bogdan (Dan) Baciu" Date: Wed, 12 Aug 2026 20:04:05 +0200 Subject: [PATCH 2/5] fix(sleep): wire cycle staging and adopt-time review checks Address PR 212 review: run_sleep_cycle stages resolved SkillProposals, status/adopt list and select a subset, uniqueness is rechecked at adopt, and a failed adopted_skills.json write rolls live files back. Refs microsoft/SkillOpt#212 --- docs/reference/cli.md | 5 + docs/sleep/README.md | 9 +- docs/sleep/multi-skill-staging.md | 60 +++++++++-- skillopt_sleep/__main__.py | 70 +++++++++++- skillopt_sleep/cycle.py | 46 +++++++- skillopt_sleep/staging.py | 89 +++++++++++++--- tests/test_sleep_adopt_skill_subset.py | 141 +++++++++++++++++++++++++ 7 files changed, 385 insertions(+), 35 deletions(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index ae234146..1b798bff 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -146,6 +146,11 @@ Actions are `run`, `dry-run`, `status`, `adopt`, `harvest`, `schedule`, and | `--progress` / `--json` | Progress or machine-readable output | | `--auto-adopt` | Apply an accepted staged proposal automatically | +`adopt` also accepts `--skill NAME` (repeatable) and `--all-skills` for a night +that staged per-skill proposals. Bare `adopt` on that night lists the names and +exits instead of promoting every skill. See +[multi-skill staging](../sleep/multi-skill-staging.md). + The `mock` and `handoff` backends make no network calls. A real backend sends mining, replay, judging, and reflection prompts derived from harvested transcripts and tasks to its selected provider. Review that provider's diff --git a/docs/sleep/README.md b/docs/sleep/README.md index 7f5be874..3a8953d4 100644 --- a/docs/sleep/README.md +++ b/docs/sleep/README.md @@ -84,6 +84,7 @@ skillopt-sleep dry-run # harvest + mine + replay, report only; stages nothi skillopt-sleep run # a full nightly cycle; the proposal is staged for review skillopt-sleep status # show state + the latest staged proposal skillopt-sleep adopt # apply the latest staged proposal +skillopt-sleep adopt --skill NAME # adopt one staged skill (repeatable) skillopt-sleep schedule # install a nightly cron entry for this project ``` @@ -299,9 +300,11 @@ gate keeps the worst case bounded; keep it **on** by default. ## Learn more -Staging a proposal for more than one skill, and adopting a reviewed subset of -them with backups and hash receipts, is documented in -[`docs/sleep/multi-skill-staging.md`](multi-skill-staging.md). +The **low-level** API for staging one proposal per skill and adopting a reviewed +subset (`staged_skills` / `adopt_skills`, plus `status` and `adopt --skill`) is +documented in [`docs/sleep/multi-skill-staging.md`](multi-skill-staging.md). +That page also states what this slice does **not** yet do: an end-to-end +nightly workflow where each group edits its own live `SKILL.md`. See the [SkillOpt documentation index](../index.md), the [CLI reference](../reference/cli.md), and the integration-specific READMEs under diff --git a/docs/sleep/multi-skill-staging.md b/docs/sleep/multi-skill-staging.md index 2f29e2fb..2d5bfa35 100644 --- a/docs/sleep/multi-skill-staging.md +++ b/docs/sleep/multi-skill-staging.md @@ -1,14 +1,37 @@ # Multi-skill staging and subset adoption -A night can stage a proposal for more than one skill. Adoption stays explicit: -staging only ever writes into the staging directory, and `adopt_skills()` copies -a **reviewed subset** over the live files, with a backup and a hash receipt per -skill. +There are two layers here. Do not collapse them. + +1. **Low-level adoption API** — `staged_skills()` / `adopt_skills()`, plus + `skillopt-sleep status` and `skillopt-sleep adopt --skill`. This slice is + complete: a night can stage one proposal file per resolved skill, a reviewer + can list those names, and an explicit subset is copied over the live files + with a backup and a hash receipt. +2. **End-to-end multi-skill nightly workflow** — each hinted group loading and + editing *its own* live `SKILL.md`, then promoting that file without a human + picking names. That workflow is **not** this slice. `multi_skill_report` + still consolidates every group from the **managed** skill document; staging + only *targets* the resolved live path when the name is `FOUND` and unique. Nothing here changes a single-managed-skill night. If a night stages no per-skill proposals, the staging directory and `manifest.json` are exactly the legacy ones and `skillopt-sleep adopt` keeps working unchanged. +## Nightly wiring (`run_sleep_cycle`) + +When `multi_skill_report` is on and hinted groups pass the gate: + +- the managed catch-all is **not** staged as a per-skill proposal (it stays on + `proposed_SKILL.md`); +- each accepted group name is resolved with `resolve_skill` against + `skill_search_roots(cfg)`; +- only `FOUND` unique live paths become `SkillProposal` rows; +- missing, ambiguous, rejected, or colliding names are skipped rather than + aborting the night. + +Review remains explicit. `auto_adopt` still only runs the legacy `adopt()` +pair; it never silently promotes every staged skill. + ## Staging layout Legacy (single managed skill) — unchanged: @@ -59,6 +82,8 @@ file. A refused fan-out writes no `manifest.json`, so the folder is not adoptabl ## Adopting a reviewed subset +Low-level API: + ```python from skillopt_sleep.staging import adopt_skills, latest_staging, staged_skills @@ -69,12 +94,31 @@ receipts = adopt_skills(staging, ["alpha"]) # beta is left alone receipts[0].sha256_before, receipts[0].sha256_after ``` +CLI: + +```text +python -m skillopt_sleep status --project PATH +python -m skillopt_sleep adopt --project PATH --skill alpha +python -m skillopt_sleep adopt --project PATH --skill alpha --skill beta +python -m skillopt_sleep adopt --project PATH --all-skills +``` + +On a multi-skill night, bare `adopt` does **not** silently promote every staged +skill. It lists the names and asks for `--skill` or `--all-skills`. Legacy +nights (no `skills` in the manifest) still use `adopt()` unchanged. + - `skill_names=None` adopts every staged skill; `[]` adopts nothing. -- An unknown or repeated name, an unsafe manifest row, or a missing proposal file - raises `StagingError` **before** anything is written. +- An unknown or repeated name, an unsafe manifest row, a missing proposal file, + or a uniqueness / live-target collision raises `StagingError` **before** + anything is written. +- Uniqueness and live-target nonexistence are re-checked **at adoption time**, + not only at staging, so a tampered manifest that points two skills at one + file (including via casefold or realpath/symlink) is refused with no writes. + A live path that exists as something other than a file is also refused. - Each live file is backed up to `backup/skills//` and written atomically. -- If any write fails, every file in the selection is restored (and files that did - not exist before are removed), so a partial adoption never survives. +- If any write fails — including `adopted_skills.json` — every live file in the + selection is restored (and files that did not exist before are removed), so a + partial adoption never survives. - Receipts (`skill_name`, `live_skill_path`, `sha256_before`, `sha256_after`, `backup_path`) are returned and written to `adopted_skills.json` in the staging directory. An empty `sha256_before` means the skill had no live file yet. diff --git a/skillopt_sleep/__main__.py b/skillopt_sleep/__main__.py index 416922c3..370555ab 100644 --- a/skillopt_sleep/__main__.py +++ b/skillopt_sleep/__main__.py @@ -4,6 +4,7 @@ python -m skillopt_sleep dry-run # same but report only, no staging/adopt python -m skillopt_sleep status # show state + latest staged proposal python -m skillopt_sleep adopt # apply the latest staged proposal (with backup) + python -m skillopt_sleep adopt --skill NAME # adopt one staged skill (repeatable) python -m skillopt_sleep harvest # just print what would be mined (debug) Common flags: @@ -35,8 +36,8 @@ from skillopt_sleep.cycle import run_sleep_cycle from skillopt_sleep.harvest_sources import harvest_for_config from skillopt_sleep.mine import mine -from skillopt_sleep.staging import adopt as adopt_staging -from skillopt_sleep.staging import latest_staging +from skillopt_sleep.staging import StagingError, adopt as adopt_staging +from skillopt_sleep.staging import adopt_skills, latest_staging, staged_skills from skillopt_sleep.state import SleepState from skillopt_sleep.tasks_file import load_tasks_file, make_tasks_payload, write_tasks_file @@ -222,7 +223,17 @@ def _print_run_report(outcome, args, task_meta: Dict[str, Any]) -> None: if outcome.staging_dir: print(f"[sleep] staged: {outcome.staging_dir}") if not outcome.adopted: - print("[sleep] review it, then: python -m skillopt_sleep adopt") + names = [] + try: + names = [r["skill_name"] for r in staged_skills(outcome.staging_dir)] + except Exception: + names = [] + if names: + listed = " ".join(f"--skill {n}" for n in names) + print("[sleep] review it, then adopt a subset:") + print(f" python -m skillopt_sleep adopt {listed}") + else: + print("[sleep] review it, then: python -m skillopt_sleep adopt") if outcome.adopted: print(f"[sleep] auto-adopted: {', '.join(outcome.adopted_paths)}") @@ -414,6 +425,12 @@ def cmd_status(args) -> int: state = SleepState.load(cfg.state_path) project = cfg.get("invoked_project") or os.getcwd() latest = latest_staging(project) + skills = [] + if latest: + try: + skills = staged_skills(latest) + except Exception: + skills = [] info = { "night": state.night, "state_path": cfg.state_path, @@ -421,6 +438,7 @@ def cmd_status(args) -> int: "history_tail": state.data.get("history", [])[-5:], "latest_staging": latest, "slow_memory_chars": len(state.slow_memory), + "staged_skills": [r.get("skill_name", "") for r in skills], } if args.json: print(json.dumps(info, ensure_ascii=False, indent=2)) @@ -429,6 +447,10 @@ def cmd_status(args) -> int: print(f"[sleep] project: {project}") if latest: print(f"[sleep] latest staged proposal: {latest}") + if skills: + print("[sleep] staged skills:") + for row in skills: + print(f" {row.get('skill_name', '')} -> {row.get('live_skill_path', '')}") rp = os.path.join(latest, "report.md") if os.path.exists(rp): with open(rp) as f: @@ -445,6 +467,40 @@ def cmd_adopt(args) -> int: if not target or not os.path.isdir(target): print("[sleep] nothing to adopt (no staging dir).") return 1 + selected = list(getattr(args, "skills", None) or []) + adopt_all = bool(getattr(args, "all_skills", False)) + if selected and adopt_all: + print("[sleep] use --skill or --all-skills, not both.") + return 2 + try: + rows = staged_skills(target) + except Exception as exc: + print(f"[sleep] cannot read staged skills: {exc}") + return 1 + if selected or adopt_all: + if not rows: + print("[sleep] this night has no per-skill proposals; omit --skill to adopt the legacy pair.") + return 2 + names = None if adopt_all else selected + try: + receipts = adopt_skills(target, names) + except StagingError as exc: + print(f"[sleep] adopt refused: {exc}") + return 2 + except OSError as exc: + print(f"[sleep] adopt failed: {exc}") + return 1 + print(f"[sleep] adopted from {target}") + for receipt in receipts: + print(f" -> {receipt.skill_name}: {receipt.live_skill_path}") + if not receipts: + print("[sleep] (no skills in the selection)") + return 0 + if rows: + print("[sleep] this night staged per-skill proposals; pass --skill NAME or --all-skills.") + for row in rows: + print(f" {row.get('skill_name', '')} -> {row.get('live_skill_path', '')}") + return 2 updated = adopt_staging(target) print(f"[sleep] adopted from {target}") for p in updated: @@ -535,6 +591,14 @@ def main(argv=None) -> int: p_adopt = sub.add_parser("adopt", help="apply latest staged proposal") _add_common(p_adopt) p_adopt.add_argument("--staging", default="", help="specific staging dir") + p_adopt.add_argument( + "--skill", action="append", default=[], dest="skills", + help="adopt this staged skill (repeatable)", + ) + p_adopt.add_argument( + "--all-skills", action="store_true", dest="all_skills", + help="adopt every staged per-skill proposal", + ) p_harvest = sub.add_parser("harvest", help="debug: show mined tasks") _add_common(p_harvest) p_harvest.add_argument("--output", default="", help="write mined tasks JSON for review") diff --git a/skillopt_sleep/cycle.py b/skillopt_sleep/cycle.py index afa9c101..937d28f3 100644 --- a/skillopt_sleep/cycle.py +++ b/skillopt_sleep/cycle.py @@ -25,9 +25,12 @@ from skillopt_sleep.mine import group_tasks_by_skill_hint, mine from skillopt_sleep.multi_skill import ( SkillGroup, + accepted_group_skills, consolidate_groups, skill_group_reports, ) +from skillopt_sleep.skill_resolver import resolve_skill, skill_search_roots +from skillopt_sleep.staging import SkillProposal, StagingError, skill_proposal_rows from skillopt_sleep.staging import adopt as adopt_staging from skillopt_sleep.staging import redact_secrets from skillopt_sleep.staging import write_staging @@ -274,6 +277,36 @@ def _render_report_md(report: SleepReport, cfg: SleepConfig) -> str: return "\n".join(lines) +def _skill_proposals_from_groups( + cfg: SleepConfig, + group_outcomes: dict, + managed_name: str, +) -> List[SkillProposal]: + """Stage per-skill proposals for accepted groups whose names resolve uniquely. + + Groups still consolidate from the managed document; this only chooses the + live ``SKILL.md`` each accepted name would replace. Unresolved, ambiguous, + rejected, or colliding names are skipped so one bad hint cannot abort the + night. The managed catch-all is never staged here — it stays on the legacy + ``proposed_SKILL.md`` path. + """ + roots = skill_search_roots(cfg) + proposals: List[SkillProposal] = [] + for name, new_skill in accepted_group_skills(group_outcomes).items(): + if name == managed_name: + continue + resolution = resolve_skill(name, roots) + if not resolution.ok: + continue + candidate = SkillProposal(name, new_skill, resolution.path) + try: + skill_proposal_rows(proposals + [candidate]) + except StagingError: + continue + proposals.append(candidate) + return proposals + + def run_sleep_cycle( cfg: Optional[SleepConfig] = None, *, @@ -532,12 +565,13 @@ def run_sleep_cycle( # automatic; a night whose evidence produces only the catch-all group adds # no rows and no calls. # - # Each group currently starts from the same managed document. Resolving a - # hinted group to its own live SKILL.md is the resolver's job and is not - # wired here yet, so a row describes what that group's evidence did to the - # managed skill, not to a separate file. + # Each group currently starts from the same managed document. Staging + # targets the resolved live SKILL.md when the name is FOUND and unique; + # the row still describes what that group's evidence did to the managed + # skill, not a separately loaded live file. + group_outcomes = {} + managed_name = cfg.get("managed_skill_name", "skillopt-sleep-learned") if cfg.get("multi_skill_report", False): - managed_name = cfg.get("managed_skill_name", "skillopt-sleep-learned") grouped = group_tasks_by_skill_hint(tasks, managed_name) only_catch_all = len(grouped) == 1 and managed_name in grouped if grouped and not only_catch_all: @@ -574,6 +608,7 @@ def run_sleep_cycle( report_md = _render_report_md(report, cfg) proposed_skill = result.new_skill if (cfg.get("evolve_skill") and result.accepted) else None proposed_memory = result.new_memory if (cfg.get("evolve_memory") and result.accepted) else None + skill_proposals = _skill_proposals_from_groups(cfg, group_outcomes, managed_name) staging_dir = write_staging( project, report=report, @@ -583,6 +618,7 @@ def run_sleep_cycle( live_memory_path=live_memory_path, report_md=report_md, out_dir=staging_dir_pre, + skill_proposals=skill_proposals, ) if ev is not None: ev.log("stage", "staged", staging_dir=staging_dir, diff --git a/skillopt_sleep/staging.py b/skillopt_sleep/staging.py index d4e4d63a..a8bdcd77 100644 --- a/skillopt_sleep/staging.py +++ b/skillopt_sleep/staging.py @@ -553,6 +553,57 @@ def _selected_rows( return [row for row in rows if str(row.get("skill_name", "")) in chosen] +def _revalidate_selected_skill_rows(rows: Sequence[Dict[str, Any]]) -> None: + """Re-run uniqueness and live-target checks at adoption time. + + Staging already refused collisions, but the manifest can be edited between + staging and adopt. A tampered pair that shares a skill name, a staged + filename, or a live target must fail here with no writes. Live paths are + also compared by realpath so a symlink cannot hide a second claim on one + file, and a live path that exists as something other than a file is + refused rather than overwritten. + """ + skill_proposal_rows([ + SkillProposal( + str(row.get("skill_name") or ""), + "", + str(row.get("live_skill_path") or ""), + ) + for row in rows + ]) + seen_real: Dict[str, str] = {} + for row in rows: + name = _safe_skill_name(row.get("skill_name")) + live = _safe_live_path(row.get("live_skill_path")) + if not name or not live: + continue + try: + real = os.path.realpath(live) + except OSError: + real = live + key = real.casefold() + if key in seen_real: + raise StagingError( + f"skills {seen_real[key]!r} and {name!r} target the same file: {live}" + ) + seen_real[key] = name + if os.path.lexists(live) and not os.path.isfile(live): + raise StagingError( + f"live skill path for {name!r} exists and is not a file: {live}" + ) + + +def _restore_live_writes(done: Sequence[tuple]) -> None: + """Restore live files written by a failed adoption, newest first.""" + for live, original in reversed(done): + if original is None: + if os.path.exists(live): + os.unlink(live) + else: + with open(live, "wb") as f: + f.write(original) + + def adopt_skills( staging_dir: str, skill_names: Optional[Sequence[str]] = None ) -> List[AdoptedSkill]: @@ -562,14 +613,17 @@ def adopt_skills( staged skill. Nothing is adopted implicitly and skills outside the selection are never touched. - Every selected proposal is validated first, each live file is backed up, and - the writes are rolled back as a set if any one of them fails, so a partial - adoption never survives. Returns a before/after sha256 receipt per skill and - also writes them to ``adopted_skills.json`` in the staging directory. + Every selected proposal is validated first, including a second uniqueness + and live-target check against the current manifest and filesystem. Each + live file is backed up, and the writes — including ``adopted_skills.json`` + — are rolled back as a set if any one of them fails, so a partial adoption + never survives. Returns a before/after sha256 receipt per skill and also + writes them to ``adopted_skills.json`` in the staging directory. """ rows = _selected_rows(staged_skills(staging_dir), skill_names) if not rows: return [] + _revalidate_selected_skill_rows(rows) plan: List[tuple] = [] for row in rows: @@ -596,6 +650,11 @@ def adopt_skills( backup_dir = os.path.join(staging_dir, "backup", "skills") receipts: List[AdoptedSkill] = [] done: List[tuple] = [] # (live, original_bytes or None) for rollback + receipt_path = os.path.join(staging_dir, "adopted_skills.json") + receipt_original = None + if os.path.isfile(receipt_path): + with open(receipt_path, "rb") as f: + receipt_original = f.read() try: for name, live, staged in plan: with open(staged, encoding="utf-8") as f: @@ -616,20 +675,18 @@ def adopt_skills( skill_name=name, live_skill_path=live, sha256_before=before, sha256_after=_sha256_text(proposed), backup_path=backup_path, )) + _write_atomic( + receipt_path, + json.dumps([r.__dict__ for r in receipts], ensure_ascii=False, indent=2), + ) except BaseException: - for live, original in reversed(done): - if original is None: - if os.path.exists(live): - os.unlink(live) - else: - with open(live, "wb") as f: - f.write(original) + _restore_live_writes(done) + if receipt_original is not None: + with open(receipt_path, "wb") as f: + f.write(receipt_original) + elif os.path.isfile(receipt_path): + os.unlink(receipt_path) raise - - _write_atomic( - os.path.join(staging_dir, "adopted_skills.json"), - json.dumps([r.__dict__ for r in receipts], ensure_ascii=False, indent=2), - ) return receipts diff --git a/tests/test_sleep_adopt_skill_subset.py b/tests/test_sleep_adopt_skill_subset.py index 86247386..73dc6fb2 100644 --- a/tests/test_sleep_adopt_skill_subset.py +++ b/tests/test_sleep_adopt_skill_subset.py @@ -234,6 +234,147 @@ def test_adoption_never_happens_without_an_explicit_call(self): self.assertTrue(os.path.exists( os.path.join(night.staging, "proposed_SKILL.alpha.md"))) + def test_tampered_duplicate_live_paths_are_refused_at_adopt(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + manifest_path = os.path.join(night.staging, "manifest.json") + with open(manifest_path, encoding="utf-8") as f: + manifest = json.load(f) + manifest["skills"][1]["live_skill_path"] = night.alpha_live + with open(manifest_path, "w", encoding="utf-8") as f: + json.dump(manifest, f) + with self.assertRaises(StagingError): + adopt_skills(night.staging) + self.assertEqual(_read(night.alpha_live), "# alpha v1\n") + self.assertEqual(_read(night.beta_live), "# beta v1\n") + self.assertFalse( + os.path.exists(os.path.join(night.staging, "adopted_skills.json"))) + + def test_live_target_that_is_not_a_file_is_refused_without_writing(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + os.unlink(night.beta_live) + os.mkdir(night.beta_live) + with self.assertRaises(StagingError): + adopt_skills(night.staging, ["beta"]) + self.assertEqual(_read(night.alpha_live), "# alpha v1\n") + self.assertTrue(os.path.isdir(night.beta_live)) + + def test_receipt_write_failure_rolls_back_live_files(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + os.makedirs(os.path.join(night.staging, "adopted_skills.json")) + with self.assertRaises(OSError): + adopt_skills(night.staging, ["alpha"]) + self.assertEqual(_read(night.alpha_live), "# alpha v1\n") + self.assertEqual(_read(night.beta_live), "# beta v1\n") + + +class TestCycleStagesResolvedSkillSubset(unittest.TestCase): + """run_sleep_cycle stages resolved skills; adopt promotes only the subset.""" + + def _hinted_tasks(self): + from dataclasses import replace + + from skillopt_sleep.experiments.personas import programmer_persona, researcher_persona + from skillopt_sleep.mine import assign_splits + + research = assign_splits(researcher_persona(), holdout_fraction=0.34, seed=42) + programming = assign_splits(programmer_persona(), holdout_fraction=0.34, seed=1) + tagged = [replace(t, skill_hint="research-skill") for t in research] + tagged += [replace(t, id=f"prog-{t.id}", skill_hint="programming-skill") + for t in programming] + return tagged + + def test_cycle_stages_both_skills_and_subset_adopt_touches_only_one(self): + from skillopt_sleep.config import load_config + from skillopt_sleep.cycle import run_sleep_cycle + + with tempfile.TemporaryDirectory() as proj, tempfile.TemporaryDirectory() as home: + claude_home = os.path.join(home, ".claude") + research_live = os.path.join( + claude_home, "skills", "research-skill", "SKILL.md") + programming_live = os.path.join( + claude_home, "skills", "programming-skill", "SKILL.md") + _write(research_live, "# research-skill v1\n") + _write(programming_live, "# programming-skill v1\n") + cfg = load_config( + invoked_project=proj, projects="invoked", backend="mock", + claude_home=claude_home, + managed_skill_name="skillopt-sleep-learned", auto_adopt=False, + multi_skill_report=True, + ) + outcome = run_sleep_cycle(cfg, seed_tasks=self._hinted_tasks()) + rows = staged_skills(outcome.staging_dir) + names = [r["skill_name"] for r in rows] + self.assertIn("research-skill", names) + self.assertIn("programming-skill", names) + self.assertTrue(os.path.isfile(os.path.join( + outcome.staging_dir, "proposed_SKILL.research-skill.md"))) + self.assertTrue(os.path.isfile(os.path.join( + outcome.staging_dir, "proposed_SKILL.programming-skill.md"))) + self.assertEqual(_read(research_live), "# research-skill v1\n") + self.assertEqual(_read(programming_live), "# programming-skill v1\n") + + receipts = adopt_skills(outcome.staging_dir, ["research-skill"]) + self.assertEqual([r.skill_name for r in receipts], ["research-skill"]) + self.assertNotEqual(_read(research_live), "# research-skill v1\n") + self.assertEqual(_read(programming_live), "# programming-skill v1\n") + + +class TestAdoptSkillCli(unittest.TestCase): + def _cli(self, argv): + import contextlib + import io + + from skillopt_sleep.__main__ import main + + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + rc = main(argv) + return rc, stdout.getvalue() + + def test_status_lists_staged_skill_names(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + claude_home = os.path.join(tmp, ".claude") + os.makedirs(claude_home, exist_ok=True) + rc, out = self._cli([ + "status", "--project", tmp, "--claude-home", claude_home, "--json", + ]) + self.assertEqual(rc, 0) + payload = json.loads(out) + self.assertEqual(payload["staged_skills"], ["alpha", "beta"]) + self.assertEqual(payload["latest_staging"], night.staging) + + def test_bare_adopt_on_a_multi_skill_night_lists_and_refuses(self): + with tempfile.TemporaryDirectory() as tmp: + TwoSkillNight(tmp) + claude_home = os.path.join(tmp, ".claude") + os.makedirs(claude_home, exist_ok=True) + rc, out = self._cli([ + "adopt", "--project", tmp, "--claude-home", claude_home, + ]) + self.assertEqual(rc, 2) + self.assertIn("--skill", out) + self.assertIn("alpha", out) + self.assertIn("beta", out) + self.assertEqual(_read(os.path.join(tmp, "live", "alpha", "SKILL.md")), + "# alpha v1\n") + + def test_adopt_skill_flag_promotes_only_the_named_skill(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + claude_home = os.path.join(tmp, ".claude") + os.makedirs(claude_home, exist_ok=True) + rc, out = self._cli([ + "adopt", "--project", tmp, "--claude-home", claude_home, + "--skill", "alpha", + ]) + self.assertEqual(rc, 0, out) + self.assertEqual(_read(night.alpha_live), "# alpha v2\n") + self.assertEqual(_read(night.beta_live), "# beta v1\n") + if __name__ == "__main__": unittest.main() From de1e3986ea4a3a6cae20571016a9ef48b309af65 Mon Sep 17 00:00:00 2001 From: "Bogdan (Dan) Baciu" Date: Wed, 12 Aug 2026 20:15:06 +0200 Subject: [PATCH 3/5] test(sleep): mega-cover PR 212 review paths Adversarial CLI, adopt-time, cycle-staging, and auto-adopt cases for Yifan's five review items. Also tidy isort on the files this slice touches. Refs microsoft/SkillOpt#120 --- skillopt_sleep/__main__.py | 6 +- skillopt_sleep/cycle.py | 6 +- tests/test_sleep_adopt_skill_subset.py | 297 +++++++++++++++++++++++++ 3 files changed, 302 insertions(+), 7 deletions(-) diff --git a/skillopt_sleep/__main__.py b/skillopt_sleep/__main__.py index 370555ab..098f2db3 100644 --- a/skillopt_sleep/__main__.py +++ b/skillopt_sleep/__main__.py @@ -36,8 +36,8 @@ from skillopt_sleep.cycle import run_sleep_cycle from skillopt_sleep.harvest_sources import harvest_for_config from skillopt_sleep.mine import mine -from skillopt_sleep.staging import StagingError, adopt as adopt_staging -from skillopt_sleep.staging import adopt_skills, latest_staging, staged_skills +from skillopt_sleep.staging import StagingError, adopt_skills, latest_staging, staged_skills +from skillopt_sleep.staging import adopt as adopt_staging from skillopt_sleep.state import SleepState from skillopt_sleep.tasks_file import load_tasks_file, make_tasks_payload, write_tasks_file @@ -554,7 +554,7 @@ def cmd_harvest(args) -> int: def cmd_schedule(args) -> int: - from skillopt_sleep.scheduler import schedule, list_scheduled + from skillopt_sleep.scheduler import list_scheduled, schedule cfg = _cfg_from_args(args) project = cfg.get("invoked_project") or os.getcwd() ok, msg = schedule(project, backend=cfg.get("backend", "mock"), diff --git a/skillopt_sleep/cycle.py b/skillopt_sleep/cycle.py index 937d28f3..2dd00aef 100644 --- a/skillopt_sleep/cycle.py +++ b/skillopt_sleep/cycle.py @@ -17,9 +17,9 @@ from skillopt_sleep import evidence from skillopt_sleep.backend import Backend, CursorBackendError, build_backend -from skillopt_sleep.evidence import EvidenceLog from skillopt_sleep.config import SleepConfig, load_config from skillopt_sleep.dream import dream_consolidate +from skillopt_sleep.evidence import EvidenceLog from skillopt_sleep.harvest_sources import harvest_for_config from skillopt_sleep.memory import ensure_skill_scaffold from skillopt_sleep.mine import group_tasks_by_skill_hint, mine @@ -30,10 +30,8 @@ skill_group_reports, ) from skillopt_sleep.skill_resolver import resolve_skill, skill_search_roots -from skillopt_sleep.staging import SkillProposal, StagingError, skill_proposal_rows +from skillopt_sleep.staging import SkillProposal, StagingError, redact_secrets, skill_proposal_rows, write_staging from skillopt_sleep.staging import adopt as adopt_staging -from skillopt_sleep.staging import redact_secrets -from skillopt_sleep.staging import write_staging from skillopt_sleep.state import SleepState, _now_iso from skillopt_sleep.types import SessionDigest, SleepReport, TaskRecord diff --git a/tests/test_sleep_adopt_skill_subset.py b/tests/test_sleep_adopt_skill_subset.py index 73dc6fb2..492cdc75 100644 --- a/tests/test_sleep_adopt_skill_subset.py +++ b/tests/test_sleep_adopt_skill_subset.py @@ -11,6 +11,7 @@ import stat import tempfile import unittest +from unittest import mock from skillopt_sleep.staging import ( SkillProposal, @@ -375,6 +376,302 @@ def test_adopt_skill_flag_promotes_only_the_named_skill(self): self.assertEqual(_read(night.alpha_live), "# alpha v2\n") self.assertEqual(_read(night.beta_live), "# beta v1\n") + def test_all_skills_flag_promotes_every_staged_skill(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + claude_home = os.path.join(tmp, ".claude") + os.makedirs(claude_home, exist_ok=True) + rc, out = self._cli([ + "adopt", "--project", tmp, "--claude-home", claude_home, + "--all-skills", + ]) + self.assertEqual(rc, 0, out) + self.assertEqual(_read(night.alpha_live), "# alpha v2\n") + self.assertEqual(_read(night.beta_live), "# beta v2\n") + + def test_repeated_skill_flags_adopt_the_named_pair(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + claude_home = os.path.join(tmp, ".claude") + os.makedirs(claude_home, exist_ok=True) + rc, out = self._cli([ + "adopt", "--project", tmp, "--claude-home", claude_home, + "--skill", "alpha", "--skill", "beta", + ]) + self.assertEqual(rc, 0, out) + self.assertEqual(_read(night.alpha_live), "# alpha v2\n") + self.assertEqual(_read(night.beta_live), "# beta v2\n") + + def test_skill_and_all_skills_together_are_refused(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + claude_home = os.path.join(tmp, ".claude") + os.makedirs(claude_home, exist_ok=True) + rc, out = self._cli([ + "adopt", "--project", tmp, "--claude-home", claude_home, + "--skill", "alpha", "--all-skills", + ]) + self.assertEqual(rc, 2) + self.assertIn("not both", out) + self.assertEqual(_read(night.alpha_live), "# alpha v1\n") + self.assertEqual(_read(night.beta_live), "# beta v1\n") + + def test_legacy_night_bare_adopt_still_copies_the_managed_pair(self): + with tempfile.TemporaryDirectory() as tmp: + live = os.path.join(tmp, "live", "SKILL.md") + memory = os.path.join(tmp, "live", "CLAUDE.md") + _write(live, "# live v1\n") + _write(memory, "# mem v1\n") + write_staging( + tmp, report=SleepReport(night=1, project=tmp, accepted=True), + proposed_skill="# live v2\n", proposed_memory="# mem v2\n", + live_skill_path=live, live_memory_path=memory, + report_md="# report\n", + ) + claude_home = os.path.join(tmp, ".claude") + os.makedirs(claude_home, exist_ok=True) + rc, out = self._cli([ + "adopt", "--project", tmp, "--claude-home", claude_home, + ]) + self.assertEqual(rc, 0, out) + self.assertEqual(_read(live), "# live v2\n") + self.assertEqual(_read(memory), "# mem v2\n") + + def test_skill_flag_on_a_legacy_night_is_refused(self): + with tempfile.TemporaryDirectory() as tmp: + live = os.path.join(tmp, "live", "SKILL.md") + _write(live, "# live v1\n") + write_staging( + tmp, report=SleepReport(night=1, project=tmp, accepted=True), + proposed_skill="# live v2\n", proposed_memory=None, + live_skill_path=live, + live_memory_path=os.path.join(tmp, "live", "CLAUDE.md"), + report_md="# report\n", + ) + claude_home = os.path.join(tmp, ".claude") + os.makedirs(claude_home, exist_ok=True) + rc, out = self._cli([ + "adopt", "--project", tmp, "--claude-home", claude_home, + "--skill", "alpha", + ]) + self.assertEqual(rc, 2) + self.assertIn("no per-skill", out) + self.assertEqual(_read(live), "# live v1\n") + + +class TestAdoptTimeRevalidationMega(unittest.TestCase): + def test_casefold_live_path_collision_is_refused_at_adopt(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + manifest_path = os.path.join(night.staging, "manifest.json") + with open(manifest_path, encoding="utf-8") as f: + manifest = json.load(f) + live_root = os.path.dirname(os.path.dirname(night.alpha_live)) + manifest["skills"][1]["live_skill_path"] = os.path.join( + live_root, "ALPHA", "SKILL.md") + with open(manifest_path, "w", encoding="utf-8") as f: + json.dump(manifest, f) + with self.assertRaises(StagingError): + adopt_skills(night.staging) + self.assertEqual(_read(night.alpha_live), "# alpha v1\n") + self.assertEqual(_read(night.beta_live), "# beta v1\n") + + def test_symlink_realpath_collision_is_refused_at_adopt(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + alias = os.path.join(night.live_root, "alias", "SKILL.md") + os.makedirs(os.path.dirname(alias), exist_ok=True) + try: + os.symlink(night.alpha_live, alias) + except OSError: + self.skipTest("symlinks unavailable") + manifest_path = os.path.join(night.staging, "manifest.json") + with open(manifest_path, encoding="utf-8") as f: + manifest = json.load(f) + manifest["skills"][1]["live_skill_path"] = alias + with open(manifest_path, "w", encoding="utf-8") as f: + json.dump(manifest, f) + with self.assertRaises(StagingError): + adopt_skills(night.staging) + self.assertEqual(_read(night.alpha_live), "# alpha v1\n") + self.assertEqual(_read(night.beta_live), "# beta v1\n") + + def test_receipt_write_failure_restores_a_previous_receipt(self): + from skillopt_sleep import staging as staging_mod + + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + adopt_skills(night.staging, ["alpha"]) + receipt_path = os.path.join(night.staging, "adopted_skills.json") + previous = _read(receipt_path) + real_write = staging_mod._write_atomic + + def boom(path, text): + if os.path.basename(path) == "adopted_skills.json": + raise OSError("disk full") + return real_write(path, text) + + with mock.patch.object(staging_mod, "_write_atomic", side_effect=boom): + with self.assertRaises(OSError): + adopt_skills(night.staging, ["beta"]) + self.assertEqual(_read(night.beta_live), "# beta v1\n") + self.assertEqual(_read(night.alpha_live), "# alpha v2\n") + self.assertEqual(_read(receipt_path), previous) + + +def _accepted_group(name, body): + from skillopt_sleep.consolidate import ConsolidationResult + from skillopt_sleep.multi_skill import CONSOLIDATED, GroupConsolidation + + result = ConsolidationResult( + accepted=True, gate_action="accept_new_best", + baseline_score=0.1, candidate_score=0.2, + new_skill=body, new_memory="", + applied_edits=[], rejected_edits=[], + holdout_baseline=0.1, holdout_candidate=0.2, + ) + return GroupConsolidation( + skill_name=name, status=CONSOLIDATED, result=result, n_tasks=2, + ) + + +class TestSkillProposalsFromGroups(unittest.TestCase): + def test_skips_managed_catch_all_and_unresolved_names(self): + from skillopt_sleep.config import load_config + from skillopt_sleep.cycle import _skill_proposals_from_groups + + with tempfile.TemporaryDirectory() as home: + claude_home = os.path.join(home, ".claude") + live = os.path.join(claude_home, "skills", "research-skill", "SKILL.md") + _write(live, "# research v1\n") + cfg = load_config( + claude_home=claude_home, + managed_skill_name="skillopt-sleep-learned", + ) + proposals = _skill_proposals_from_groups( + cfg, + { + "skillopt-sleep-learned": _accepted_group( + "skillopt-sleep-learned", "# managed v2\n"), + "research-skill": _accepted_group( + "research-skill", "# research v2\n"), + "ghost-skill": _accepted_group( + "ghost-skill", "# ghost v2\n"), + }, + "skillopt-sleep-learned", + ) + names = [p.skill_name for p in proposals] + self.assertEqual(names, ["research-skill"]) + self.assertEqual(proposals[0].live_skill_path, os.path.realpath(live)) + self.assertEqual(proposals[0].proposed_skill, "# research v2\n") + + def test_skips_a_second_skill_that_resolves_to_the_same_live_file(self): + from skillopt_sleep.config import load_config + from skillopt_sleep.cycle import _skill_proposals_from_groups + + with tempfile.TemporaryDirectory() as home: + claude_home = os.path.join(home, ".claude") + skills = os.path.join(claude_home, "skills") + research = os.path.join(skills, "research-skill") + alias = os.path.join(skills, "alias-skill") + _write(os.path.join(research, "SKILL.md"), "# research v1\n") + try: + os.symlink(research, alias) + except OSError: + self.skipTest("symlinks unavailable") + cfg = load_config(claude_home=claude_home) + proposals = _skill_proposals_from_groups( + cfg, + { + "research-skill": _accepted_group( + "research-skill", "# research v2\n"), + "alias-skill": _accepted_group( + "alias-skill", "# alias v2\n"), + }, + "skillopt-sleep-learned", + ) + self.assertEqual([p.skill_name for p in proposals], ["research-skill"]) + + +class TestCycleStagingGaps(unittest.TestCase): + def _hinted_tasks(self): + from dataclasses import replace + + from skillopt_sleep.experiments.personas import programmer_persona, researcher_persona + from skillopt_sleep.mine import assign_splits + + research = assign_splits(researcher_persona(), holdout_fraction=0.34, seed=42) + programming = assign_splits(programmer_persona(), holdout_fraction=0.34, seed=1) + tagged = [replace(t, skill_hint="research-skill") for t in research] + tagged += [replace(t, id=f"prog-{t.id}", skill_hint="programming-skill") + for t in programming] + return tagged + + def test_missing_live_skill_is_skipped_not_aborted(self): + from skillopt_sleep.config import load_config + from skillopt_sleep.cycle import run_sleep_cycle + + with tempfile.TemporaryDirectory() as proj, tempfile.TemporaryDirectory() as home: + claude_home = os.path.join(home, ".claude") + research_live = os.path.join( + claude_home, "skills", "research-skill", "SKILL.md") + _write(research_live, "# research-skill v1\n") + cfg = load_config( + invoked_project=proj, projects="invoked", backend="mock", + claude_home=claude_home, + managed_skill_name="skillopt-sleep-learned", auto_adopt=False, + multi_skill_report=True, + ) + outcome = run_sleep_cycle(cfg, seed_tasks=self._hinted_tasks()) + names = [r["skill_name"] for r in staged_skills(outcome.staging_dir)] + self.assertEqual(names, ["research-skill"]) + self.assertFalse(os.path.isfile(os.path.join( + outcome.staging_dir, "proposed_SKILL.programming-skill.md"))) + + def test_report_off_stages_no_per_skill_proposals(self): + from skillopt_sleep.config import load_config + from skillopt_sleep.cycle import run_sleep_cycle + + with tempfile.TemporaryDirectory() as proj, tempfile.TemporaryDirectory() as home: + claude_home = os.path.join(home, ".claude") + _write(os.path.join(claude_home, "skills", "research-skill", "SKILL.md"), + "# research-skill v1\n") + _write(os.path.join(claude_home, "skills", "programming-skill", "SKILL.md"), + "# programming-skill v1\n") + cfg = load_config( + invoked_project=proj, projects="invoked", backend="mock", + claude_home=claude_home, + managed_skill_name="skillopt-sleep-learned", auto_adopt=False, + multi_skill_report=False, + ) + outcome = run_sleep_cycle(cfg, seed_tasks=self._hinted_tasks()) + self.assertEqual(staged_skills(outcome.staging_dir), []) + + def test_auto_adopt_does_not_promote_per_skill_live_files(self): + from skillopt_sleep.config import load_config + from skillopt_sleep.cycle import run_sleep_cycle + + with tempfile.TemporaryDirectory() as proj, tempfile.TemporaryDirectory() as home: + claude_home = os.path.join(home, ".claude") + research_live = os.path.join( + claude_home, "skills", "research-skill", "SKILL.md") + programming_live = os.path.join( + claude_home, "skills", "programming-skill", "SKILL.md") + _write(research_live, "# research-skill v1\n") + _write(programming_live, "# programming-skill v1\n") + cfg = load_config( + invoked_project=proj, projects="invoked", backend="mock", + claude_home=claude_home, + managed_skill_name="skillopt-sleep-learned", auto_adopt=True, + multi_skill_report=True, + ) + outcome = run_sleep_cycle(cfg, seed_tasks=self._hinted_tasks()) + self.assertEqual(_read(research_live), "# research-skill v1\n") + self.assertEqual(_read(programming_live), "# programming-skill v1\n") + names = [r["skill_name"] for r in staged_skills(outcome.staging_dir)] + self.assertIn("research-skill", names) + self.assertIn("programming-skill", names) + if __name__ == "__main__": unittest.main() From f393a7ad1a286b3cbfc81d9a41edd9bf52f26716 Mon Sep 17 00:00:00 2001 From: "Bogdan (Dan) Baciu" Date: Wed, 12 Aug 2026 20:58:37 +0200 Subject: [PATCH 4/5] fix(sleep): pin staged skill hashes and confine adopt targets Harden PR 212 adopt: sha256 pin each staged skill, revalidate the whole manifest before any live write, refuse symlink/missing-parent targets, skip notes on the cycle report, and reject empty --skill. Refs microsoft/SkillOpt#212 --- docs/sleep/multi-skill-staging.md | 32 ++-- skillopt_sleep/__main__.py | 6 +- skillopt_sleep/cycle.py | 31 +++- skillopt_sleep/staging.py | 125 +++++++++++--- tests/test_sleep_adopt_skill_subset.py | 219 +++++++++++++++++++++++-- tests/test_sleep_staging_fanout.py | 15 ++ 6 files changed, 369 insertions(+), 59 deletions(-) diff --git a/docs/sleep/multi-skill-staging.md b/docs/sleep/multi-skill-staging.md index 2d5bfa35..8ef711f2 100644 --- a/docs/sleep/multi-skill-staging.md +++ b/docs/sleep/multi-skill-staging.md @@ -26,8 +26,8 @@ When `multi_skill_report` is on and hinted groups pass the gate: - each accepted group name is resolved with `resolve_skill` against `skill_search_roots(cfg)`; - only `FOUND` unique live paths become `SkillProposal` rows; -- missing, ambiguous, rejected, or colliding names are skipped rather than - aborting the night. +- missing, ambiguous, rejected, empty, or colliding names are skipped rather + than aborting the night, and each skip is recorded on `report.notes`. Review remains explicit. `auto_adopt` still only runs the legacy `adopt()` pair; it never silently promotes every staged skill. @@ -65,12 +65,14 @@ Multi-skill night — one extra file and one manifest row per skill: { "skill_name": "alpha", "proposed_file": "proposed_SKILL.alpha.md", - "live_skill_path": "/home/dev/.claude/skills/alpha/SKILL.md" + "live_skill_path": "/home/dev/.claude/skills/alpha/SKILL.md", + "sha256": "" }, { "skill_name": "beta", "proposed_file": "proposed_SKILL.beta.md", - "live_skill_path": "/home/dev/.claude/skills/beta/SKILL.md" + "live_skill_path": "/home/dev/.claude/skills/beta/SKILL.md", + "sha256": "" } ] } @@ -108,17 +110,23 @@ skill. It lists the names and asks for `--skill` or `--all-skills`. Legacy nights (no `skills` in the manifest) still use `adopt()` unchanged. - `skill_names=None` adopts every staged skill; `[]` adopts nothing. -- An unknown or repeated name, an unsafe manifest row, a missing proposal file, - or a uniqueness / live-target collision raises `StagingError` **before** - anything is written. -- Uniqueness and live-target nonexistence are re-checked **at adoption time**, - not only at staging, so a tampered manifest that points two skills at one - file (including via casefold or realpath/symlink) is refused with no writes. +- An unknown or repeated name, an empty `--skill` token, an unsafe manifest + row, a missing proposal file, a sha256 mismatch, an empty proposal body, or a + uniqueness / live-target collision raises `StagingError` **before** anything + is written. +- Uniqueness and live-target checks run **at adoption time against every staged + row**, not only the selection, so adopting one skill cannot hide a sibling + that now points at the same file (including via casefold or realpath/symlink). A live path that exists as something other than a file is also refused. +- Each selected proposal is pinned by the manifest `sha256`. Tampering with the + staged file, or dropping the pin, is refused with no writes. +- The live target must already be `/SKILL.md`. Adopt will not create + parent directories, follow a symlink file, or write through a symlink parent. - Each live file is backed up to `backup/skills//` and written atomically. - If any write fails — including `adopted_skills.json` — every live file in the - selection is restored (and files that did not exist before are removed), so a - partial adoption never survives. + selection is restored (and files that did not exist before are removed), and + the previous receipt bytes are restored atomically, so a partial adoption + never survives. - Receipts (`skill_name`, `live_skill_path`, `sha256_before`, `sha256_after`, `backup_path`) are returned and written to `adopted_skills.json` in the staging directory. An empty `sha256_before` means the skill had no live file yet. diff --git a/skillopt_sleep/__main__.py b/skillopt_sleep/__main__.py index 098f2db3..2c2d9b6d 100644 --- a/skillopt_sleep/__main__.py +++ b/skillopt_sleep/__main__.py @@ -467,7 +467,11 @@ def cmd_adopt(args) -> int: if not target or not os.path.isdir(target): print("[sleep] nothing to adopt (no staging dir).") return 1 - selected = list(getattr(args, "skills", None) or []) + raw_selected = list(getattr(args, "skills", None) or []) + if any(not str(name).strip() for name in raw_selected): + print("[sleep] --skill names must be non-empty.") + return 2 + selected = [str(name).strip() for name in raw_selected] adopt_all = bool(getattr(args, "all_skills", False)) if selected and adopt_all: print("[sleep] use --skill or --all-skills, not both.") diff --git a/skillopt_sleep/cycle.py b/skillopt_sleep/cycle.py index 2dd00aef..9946ad11 100644 --- a/skillopt_sleep/cycle.py +++ b/skillopt_sleep/cycle.py @@ -275,34 +275,48 @@ def _render_report_md(report: SleepReport, cfg: SleepConfig) -> str: return "\n".join(lines) +def _cycle_skip_note(name: str, reason: str) -> str: + """One-line skip reason for report.notes. Names are untrusted free text.""" + label = str(name or "").strip() or "" + return redact_secrets(f"cycle skipped skill {label}: {reason}") + + def _skill_proposals_from_groups( cfg: SleepConfig, group_outcomes: dict, managed_name: str, -) -> List[SkillProposal]: +) -> tuple[List[SkillProposal], List[str]]: """Stage per-skill proposals for accepted groups whose names resolve uniquely. Groups still consolidate from the managed document; this only chooses the live ``SKILL.md`` each accepted name would replace. Unresolved, ambiguous, - rejected, or colliding names are skipped so one bad hint cannot abort the - night. The managed catch-all is never staged here — it stays on the legacy - ``proposed_SKILL.md`` path. + rejected, empty, or colliding names are skipped so one bad hint cannot abort + the night; each skip is recorded on ``report.notes``. The managed catch-all + is never staged here — it stays on the legacy ``proposed_SKILL.md`` path. """ roots = skill_search_roots(cfg) proposals: List[SkillProposal] = [] + notes: List[str] = [] for name, new_skill in accepted_group_skills(group_outcomes).items(): if name == managed_name: continue + if not str(new_skill or "").strip(): + notes.append(_cycle_skip_note(name, "empty proposed_skill")) + continue resolution = resolve_skill(name, roots) if not resolution.ok: + notes.append( + _cycle_skip_note(name, resolution.reason or resolution.status) + ) continue candidate = SkillProposal(name, new_skill, resolution.path) try: skill_proposal_rows(proposals + [candidate]) - except StagingError: + except StagingError as exc: + notes.append(_cycle_skip_note(name, str(exc))) continue proposals.append(candidate) - return proposals + return proposals, notes def run_sleep_cycle( @@ -606,7 +620,10 @@ def run_sleep_cycle( report_md = _render_report_md(report, cfg) proposed_skill = result.new_skill if (cfg.get("evolve_skill") and result.accepted) else None proposed_memory = result.new_memory if (cfg.get("evolve_memory") and result.accepted) else None - skill_proposals = _skill_proposals_from_groups(cfg, group_outcomes, managed_name) + skill_proposals, skip_notes = _skill_proposals_from_groups( + cfg, group_outcomes, managed_name + ) + report.notes.extend(skip_notes) staging_dir = write_staging( project, report=report, diff --git a/skillopt_sleep/staging.py b/skillopt_sleep/staging.py index a8bdcd77..b5dcf4c1 100644 --- a/skillopt_sleep/staging.py +++ b/skillopt_sleep/staging.py @@ -308,10 +308,15 @@ def proposal_filename(skill_name: str) -> str: return f"proposed_SKILL.{skill_name}.md" -def _write_atomic(path: str, text: str) -> None: +def _sha256_text(text: str) -> str: + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def _write_atomic(path: str, text: str, *, create_parents: bool = True) -> None: """Write ``text`` to ``path`` atomically, so review never sees half a file.""" directory = os.path.dirname(path) or "." - os.makedirs(directory, exist_ok=True) + if create_parents: + os.makedirs(directory, exist_ok=True) existing_mode = ( stat.S_IMODE(os.stat(path).st_mode) if os.path.exists(path) else None ) @@ -390,6 +395,7 @@ def skill_proposal_rows(proposals: Iterable[SkillProposal]) -> List[Dict[str, An "skill_name": name, "proposed_file": proposed_file, "live_skill_path": live, + "sha256": _sha256_text(proposal.proposed_skill), }) return rows @@ -410,6 +416,11 @@ def write_skill_proposals( rows = skill_proposal_rows(proposals) if not rows: return rows + for row, proposal in zip(rows, proposals): + if not str(proposal.proposed_skill).strip(): + raise StagingError( + f"proposed skill content for {row['skill_name']!r} is empty" + ) os.makedirs(out_dir, exist_ok=True) for row, proposal in zip(rows, proposals): _write_atomic(os.path.join(out_dir, row["proposed_file"]), proposal.proposed_skill) @@ -513,10 +524,6 @@ class AdoptedSkill: backup_path: str = "" # "" when there was nothing to back up -def _sha256_text(text: str) -> str: - return hashlib.sha256(text.encode("utf-8")).hexdigest() - - def staged_skills(staging_dir: str) -> List[Dict[str, Any]]: """Manifest rows for the per-skill proposals staged in ``staging_dir``.""" with open(os.path.join(staging_dir, "manifest.json"), encoding="utf-8") as f: @@ -553,26 +560,33 @@ def _selected_rows( return [row for row in rows if str(row.get("skill_name", "")) in chosen] -def _revalidate_selected_skill_rows(rows: Sequence[Dict[str, Any]]) -> None: +def _revalidate_selected_skill_rows( + rows: Sequence[Dict[str, Any]], + *, + all_rows: Optional[Sequence[Dict[str, Any]]] = None, +) -> None: """Re-run uniqueness and live-target checks at adoption time. Staging already refused collisions, but the manifest can be edited between staging and adopt. A tampered pair that shares a skill name, a staged - filename, or a live target must fail here with no writes. Live paths are + filename, or a live target must fail here with no writes. The check runs + against every staged row, not only the selection, so adopting one skill + cannot hide a sibling that now points at the same file. Live paths are also compared by realpath so a symlink cannot hide a second claim on one file, and a live path that exists as something other than a file is refused rather than overwritten. """ + universe = list(all_rows) if all_rows is not None else list(rows) skill_proposal_rows([ SkillProposal( str(row.get("skill_name") or ""), "", str(row.get("live_skill_path") or ""), ) - for row in rows + for row in universe ]) seen_real: Dict[str, str] = {} - for row in rows: + for row in universe: name = _safe_skill_name(row.get("skill_name")) live = _safe_live_path(row.get("live_skill_path")) if not name or not live: @@ -593,6 +607,35 @@ def _revalidate_selected_skill_rows(rows: Sequence[Dict[str, Any]]) -> None: ) +def _valid_sha256_pin(value: object) -> bool: + if not isinstance(value, str) or len(value) != 64: + return False + return all(ch in "0123456789abcdef" for ch in value) + + +def _adopt_live_target_ok(name: str, live: str) -> None: + """Refuse live targets that would create dirs, follow links, or leave the skill folder.""" + if os.path.islink(live): + raise StagingError(f"live skill path for {name!r} is a symlink: {live}") + parent = os.path.dirname(live) + if os.path.islink(parent): + raise StagingError( + f"live skill parent directory for {name!r} is a symlink: {parent}" + ) + if not os.path.isdir(parent): + raise StagingError( + f"live skill parent directory for {name!r} does not exist: {parent}" + ) + if os.path.basename(live) != "SKILL.md": + raise StagingError( + f"live skill path for {name!r} must be a SKILL.md file: {live}" + ) + if os.path.basename(parent) != name: + raise StagingError( + f"live skill path for {name!r} is not {name}/SKILL.md: {live}" + ) + + def _restore_live_writes(done: Sequence[tuple]) -> None: """Restore live files written by a failed adoption, newest first.""" for live, original in reversed(done): @@ -604,6 +647,26 @@ def _restore_live_writes(done: Sequence[tuple]) -> None: f.write(original) +def _restore_receipt_bytes(path: str, original: Optional[bytes]) -> None: + """Put ``adopted_skills.json`` back without leaving a half-written file.""" + if original is not None: + directory = os.path.dirname(path) or "." + fd, tmp = tempfile.mkstemp(dir=directory, prefix=".tmp-", suffix=".json") + try: + with os.fdopen(fd, "wb") as f: + f.write(original) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, path) + except BaseException: + if os.path.exists(tmp): + os.unlink(tmp) + raise + return + if os.path.isfile(path): + os.unlink(path) + + def adopt_skills( staging_dir: str, skill_names: Optional[Sequence[str]] = None ) -> List[AdoptedSkill]: @@ -614,16 +677,18 @@ def adopt_skills( are never touched. Every selected proposal is validated first, including a second uniqueness - and live-target check against the current manifest and filesystem. Each - live file is backed up, and the writes — including ``adopted_skills.json`` - — are rolled back as a set if any one of them fails, so a partial adoption - never survives. Returns a before/after sha256 receipt per skill and also - writes them to ``adopted_skills.json`` in the staging directory. + and live-target check against the **whole** current manifest, a sha256 pin + of the staged file, and a live-path layout check. Each live file is backed + up, and the writes — including ``adopted_skills.json`` — are rolled back as + a set if any one of them fails, so a partial adoption never survives. + Returns a before/after sha256 receipt per skill and also writes them to + ``adopted_skills.json`` in the staging directory. """ - rows = _selected_rows(staged_skills(staging_dir), skill_names) + all_rows = staged_skills(staging_dir) + rows = _selected_rows(all_rows, skill_names) if not rows: return [] - _revalidate_selected_skill_rows(rows) + _revalidate_selected_skill_rows(rows, all_rows=all_rows) plan: List[tuple] = [] for row in rows: @@ -635,6 +700,7 @@ def adopt_skills( raise StagingError( f"unsafe live skill path for {name!r}: {row.get('live_skill_path')!r}" ) + _adopt_live_target_ok(name, live) proposed_file = row.get("proposed_file") expected_file = proposal_filename(name) if proposed_file != expected_file: @@ -645,7 +711,18 @@ def adopt_skills( staged = os.path.join(staging_dir, expected_file) if not os.path.isfile(staged): raise StagingError(f"staged proposal missing for {name!r}: {staged}") - plan.append((name, live, staged)) + with open(staged, encoding="utf-8") as f: + proposed = f.read() + pin = row.get("sha256") + if not _valid_sha256_pin(pin): + raise StagingError(f"staged proposal for {name!r} is missing a sha256 pin") + if _sha256_text(proposed) != pin: + raise StagingError( + f"staged proposal for {name!r} does not match its manifest sha256" + ) + if not proposed.strip(): + raise StagingError(f"staged proposal for {name!r} is empty") + plan.append((name, live, proposed)) backup_dir = os.path.join(staging_dir, "backup", "skills") receipts: List[AdoptedSkill] = [] @@ -656,9 +733,7 @@ def adopt_skills( with open(receipt_path, "rb") as f: receipt_original = f.read() try: - for name, live, staged in plan: - with open(staged, encoding="utf-8") as f: - proposed = f.read() + for name, live, proposed in plan: original = None backup_path = "" if os.path.exists(live): @@ -669,7 +744,7 @@ def adopt_skills( backup_path = os.path.join(skill_backup, os.path.basename(live)) shutil.copy2(live, backup_path) before = hashlib.sha256(original).hexdigest() if original is not None else "" - _write_atomic(live, proposed) + _write_atomic(live, proposed, create_parents=False) done.append((live, original)) receipts.append(AdoptedSkill( skill_name=name, live_skill_path=live, sha256_before=before, @@ -681,11 +756,7 @@ def adopt_skills( ) except BaseException: _restore_live_writes(done) - if receipt_original is not None: - with open(receipt_path, "wb") as f: - f.write(receipt_original) - elif os.path.isfile(receipt_path): - os.unlink(receipt_path) + _restore_receipt_bytes(receipt_path, receipt_original) raise return receipts diff --git a/tests/test_sleep_adopt_skill_subset.py b/tests/test_sleep_adopt_skill_subset.py index 492cdc75..dbf19077 100644 --- a/tests/test_sleep_adopt_skill_subset.py +++ b/tests/test_sleep_adopt_skill_subset.py @@ -204,28 +204,56 @@ def test_adoption_preserves_existing_live_file_mode(self): self.assertEqual(stat.S_IMODE(os.stat(night.alpha_live).st_mode), 0o640) def test_a_failed_write_rolls_the_whole_selection_back(self): + from skillopt_sleep import staging as staging_mod + with tempfile.TemporaryDirectory() as tmp: night = TwoSkillNight(tmp) - # beta's live path becomes un-writable: its parent is now a file. - os.unlink(night.beta_live) - os.rmdir(os.path.dirname(night.beta_live)) - _write(os.path.dirname(night.beta_live), "not a directory\n") - with self.assertRaises(OSError): - adopt_skills(night.staging) + real_write = staging_mod._write_atomic + + def boom(path, text, *, create_parents=True): + if path == night.beta_live: + raise OSError("disk full") + return real_write(path, text, create_parents=create_parents) + + with mock.patch.object(staging_mod, "_write_atomic", side_effect=boom): + with self.assertRaises(OSError): + adopt_skills(night.staging) self.assertEqual(_read(night.alpha_live), "# alpha v1\n") + self.assertEqual(_read(night.beta_live), "# beta v1\n") self.assertFalse( os.path.exists(os.path.join(night.staging, "adopted_skills.json"))) def test_rollback_removes_files_that_did_not_exist_before(self): + from skillopt_sleep import staging as staging_mod + with tempfile.TemporaryDirectory() as tmp: night = TwoSkillNight(tmp) os.unlink(night.alpha_live) os.unlink(night.beta_live) + real_write = staging_mod._write_atomic + + def boom(path, text, *, create_parents=True): + if path == night.beta_live: + raise OSError("disk full") + return real_write(path, text, create_parents=create_parents) + + with mock.patch.object(staging_mod, "_write_atomic", side_effect=boom): + with self.assertRaises(OSError): + adopt_skills(night.staging) + self.assertFalse(os.path.exists(night.alpha_live)) + self.assertFalse(os.path.exists(night.beta_live)) + + def test_missing_live_parent_is_refused_before_any_write(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + os.unlink(night.beta_live) os.rmdir(os.path.dirname(night.beta_live)) _write(os.path.dirname(night.beta_live), "not a directory\n") - with self.assertRaises(OSError): + with self.assertRaises(StagingError): adopt_skills(night.staging) - self.assertFalse(os.path.exists(night.alpha_live)) + self.assertEqual(_read(night.alpha_live), "# alpha v1\n") + self.assertFalse( + os.path.exists(os.path.join(night.staging, "adopted_skills.json"))) def test_adoption_never_happens_without_an_explicit_call(self): with tempfile.TemporaryDirectory() as tmp: @@ -458,6 +486,33 @@ def test_skill_flag_on_a_legacy_night_is_refused(self): self.assertIn("no per-skill", out) self.assertEqual(_read(live), "# live v1\n") + def test_empty_skill_flag_is_refused(self): + with tempfile.TemporaryDirectory() as tmp: + TwoSkillNight(tmp) + claude_home = os.path.join(tmp, ".claude") + os.makedirs(claude_home, exist_ok=True) + rc, out = self._cli([ + "adopt", "--project", tmp, "--claude-home", claude_home, + "--skill", " ", + ]) + self.assertEqual(rc, 2) + self.assertIn("non-empty", out) + self.assertEqual(_read(os.path.join(tmp, "live", "alpha", "SKILL.md")), + "# alpha v1\n") + + def test_skill_flag_strips_surrounding_whitespace(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + claude_home = os.path.join(tmp, ".claude") + os.makedirs(claude_home, exist_ok=True) + rc, out = self._cli([ + "adopt", "--project", tmp, "--claude-home", claude_home, + "--skill", " alpha ", + ]) + self.assertEqual(rc, 0, out) + self.assertEqual(_read(night.alpha_live), "# alpha v2\n") + self.assertEqual(_read(night.beta_live), "# beta v1\n") + class TestAdoptTimeRevalidationMega(unittest.TestCase): def test_casefold_live_path_collision_is_refused_at_adopt(self): @@ -506,10 +561,10 @@ def test_receipt_write_failure_restores_a_previous_receipt(self): previous = _read(receipt_path) real_write = staging_mod._write_atomic - def boom(path, text): + def boom(path, text, *, create_parents=True): if os.path.basename(path) == "adopted_skills.json": raise OSError("disk full") - return real_write(path, text) + return real_write(path, text, create_parents=create_parents) with mock.patch.object(staging_mod, "_write_atomic", side_effect=boom): with self.assertRaises(OSError): @@ -548,7 +603,7 @@ def test_skips_managed_catch_all_and_unresolved_names(self): claude_home=claude_home, managed_skill_name="skillopt-sleep-learned", ) - proposals = _skill_proposals_from_groups( + proposals, notes = _skill_proposals_from_groups( cfg, { "skillopt-sleep-learned": _accepted_group( @@ -564,6 +619,8 @@ def test_skips_managed_catch_all_and_unresolved_names(self): self.assertEqual(names, ["research-skill"]) self.assertEqual(proposals[0].live_skill_path, os.path.realpath(live)) self.assertEqual(proposals[0].proposed_skill, "# research v2\n") + self.assertTrue(any("ghost-skill" in note for note in notes)) + self.assertFalse(any("skillopt-sleep-learned" in note for note in notes)) def test_skips_a_second_skill_that_resolves_to_the_same_live_file(self): from skillopt_sleep.config import load_config @@ -580,7 +637,7 @@ def test_skips_a_second_skill_that_resolves_to_the_same_live_file(self): except OSError: self.skipTest("symlinks unavailable") cfg = load_config(claude_home=claude_home) - proposals = _skill_proposals_from_groups( + proposals, notes = _skill_proposals_from_groups( cfg, { "research-skill": _accepted_group( @@ -591,6 +648,7 @@ def test_skips_a_second_skill_that_resolves_to_the_same_live_file(self): "skillopt-sleep-learned", ) self.assertEqual([p.skill_name for p in proposals], ["research-skill"]) + self.assertTrue(any("alias-skill" in note for note in notes)) class TestCycleStagingGaps(unittest.TestCase): @@ -627,6 +685,9 @@ def test_missing_live_skill_is_skipped_not_aborted(self): self.assertEqual(names, ["research-skill"]) self.assertFalse(os.path.isfile(os.path.join( outcome.staging_dir, "proposed_SKILL.programming-skill.md"))) + self.assertTrue(any( + "programming-skill" in note for note in outcome.report.notes + )) def test_report_off_stages_no_per_skill_proposals(self): from skillopt_sleep.config import load_config @@ -673,5 +734,139 @@ def test_auto_adopt_does_not_promote_per_skill_live_files(self): self.assertIn("programming-skill", names) +class TestAdoptHardeningPinsAndLayout(unittest.TestCase): + def test_tampered_proposal_file_is_refused_by_sha256_pin(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + staged = os.path.join(night.staging, "proposed_SKILL.alpha.md") + _write(staged, "# alpha tampered\n") + with self.assertRaisesRegex(StagingError, "does not match its manifest sha256"): + adopt_skills(night.staging, ["alpha"]) + self.assertEqual(_read(night.alpha_live), "# alpha v1\n") + self.assertFalse( + os.path.exists(os.path.join(night.staging, "adopted_skills.json"))) + + def test_missing_sha256_pin_is_refused_without_writing(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + manifest_path = os.path.join(night.staging, "manifest.json") + with open(manifest_path, encoding="utf-8") as f: + manifest = json.load(f) + del manifest["skills"][0]["sha256"] + with open(manifest_path, "w", encoding="utf-8") as f: + json.dump(manifest, f) + with self.assertRaisesRegex(StagingError, "missing a sha256 pin"): + adopt_skills(night.staging, ["alpha"]) + self.assertEqual(_read(night.alpha_live), "# alpha v1\n") + + def test_empty_staged_proposal_is_refused_even_when_hash_matches(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + staged = os.path.join(night.staging, "proposed_SKILL.alpha.md") + _write(staged, " \n") + manifest_path = os.path.join(night.staging, "manifest.json") + with open(manifest_path, encoding="utf-8") as f: + manifest = json.load(f) + manifest["skills"][0]["sha256"] = _sha(" \n") + with open(manifest_path, "w", encoding="utf-8") as f: + json.dump(manifest, f) + with self.assertRaisesRegex(StagingError, "is empty"): + adopt_skills(night.staging, ["alpha"]) + self.assertEqual(_read(night.alpha_live), "# alpha v1\n") + + def test_symlink_live_file_is_refused_without_writing(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + os.unlink(night.alpha_live) + elsewhere = os.path.join(tmp, "elsewhere.md") + _write(elsewhere, "# elsewhere\n") + try: + os.symlink(elsewhere, night.alpha_live) + except OSError: + self.skipTest("symlinks unavailable") + with self.assertRaisesRegex(StagingError, "is a symlink"): + adopt_skills(night.staging, ["alpha"]) + self.assertEqual(_read(elsewhere), "# elsewhere\n") + self.assertTrue(os.path.islink(night.alpha_live)) + + def test_symlink_parent_directory_is_refused_without_writing(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + real_parent = os.path.dirname(night.alpha_live) + alias_parent = os.path.join(night.live_root, "alias-alpha") + try: + os.symlink(real_parent, alias_parent) + except OSError: + self.skipTest("symlinks unavailable") + alias_live = os.path.join(alias_parent, "SKILL.md") + manifest_path = os.path.join(night.staging, "manifest.json") + with open(manifest_path, encoding="utf-8") as f: + manifest = json.load(f) + manifest["skills"][0]["skill_name"] = "alias-alpha" + manifest["skills"][0]["proposed_file"] = "proposed_SKILL.alias-alpha.md" + manifest["skills"][0]["live_skill_path"] = alias_live + with open(manifest_path, "w", encoding="utf-8") as f: + json.dump(manifest, f) + os.rename( + os.path.join(night.staging, "proposed_SKILL.alpha.md"), + os.path.join(night.staging, "proposed_SKILL.alias-alpha.md"), + ) + with self.assertRaisesRegex(StagingError, "is a symlink"): + adopt_skills(night.staging, ["alias-alpha"]) + self.assertEqual(_read(night.alpha_live), "# alpha v1\n") + + def test_subset_adopt_refuses_unselected_sibling_realpath_collision(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + alias = os.path.join(night.live_root, "alias", "SKILL.md") + os.makedirs(os.path.dirname(alias), exist_ok=True) + try: + os.symlink(night.alpha_live, alias) + except OSError: + self.skipTest("symlinks unavailable") + manifest_path = os.path.join(night.staging, "manifest.json") + with open(manifest_path, encoding="utf-8") as f: + manifest = json.load(f) + manifest["skills"][1]["live_skill_path"] = alias + with open(manifest_path, "w", encoding="utf-8") as f: + json.dump(manifest, f) + with self.assertRaises(StagingError): + adopt_skills(night.staging, ["alpha"]) + self.assertEqual(_read(night.alpha_live), "# alpha v1\n") + self.assertEqual(_read(night.beta_live), "# beta v1\n") + + def test_live_path_not_named_skill_md_is_refused(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + wrong = os.path.join(night.live_root, "alpha", "NOTES.md") + _write(wrong, "# notes\n") + manifest_path = os.path.join(night.staging, "manifest.json") + with open(manifest_path, encoding="utf-8") as f: + manifest = json.load(f) + manifest["skills"][0]["live_skill_path"] = wrong + with open(manifest_path, "w", encoding="utf-8") as f: + json.dump(manifest, f) + with self.assertRaisesRegex(StagingError, "must be a SKILL.md file"): + adopt_skills(night.staging, ["alpha"]) + self.assertEqual(_read(night.alpha_live), "# alpha v1\n") + + def test_cycle_skips_empty_proposed_skill_with_a_note(self): + from skillopt_sleep.config import load_config + from skillopt_sleep.cycle import _skill_proposals_from_groups + + with tempfile.TemporaryDirectory() as home: + claude_home = os.path.join(home, ".claude") + live = os.path.join(claude_home, "skills", "research-skill", "SKILL.md") + _write(live, "# research v1\n") + cfg = load_config(claude_home=claude_home) + proposals, notes = _skill_proposals_from_groups( + cfg, + {"research-skill": _accepted_group("research-skill", " \n")}, + "skillopt-sleep-learned", + ) + self.assertEqual(proposals, []) + self.assertTrue(any("empty proposed_skill" in note for note in notes)) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_sleep_staging_fanout.py b/tests/test_sleep_staging_fanout.py index cd3f29f8..89519d1e 100644 --- a/tests/test_sleep_staging_fanout.py +++ b/tests/test_sleep_staging_fanout.py @@ -5,6 +5,7 @@ """ from __future__ import annotations +import hashlib import json import os import tempfile @@ -40,6 +41,10 @@ def test_one_row_per_skill_in_order(self): ["proposed_SKILL.alpha.md", "proposed_SKILL.beta.md"]) self.assertEqual(rows[0]["live_skill_path"], os.path.normpath("/tmp/live/alpha/SKILL.md")) + self.assertEqual( + rows[0]["sha256"], + hashlib.sha256(b"# example\n").hexdigest(), + ) def test_filenames_are_unique_per_skill(self): self.assertNotEqual(proposal_filename("alpha"), proposal_filename("beta")) @@ -147,6 +152,12 @@ def test_writes_one_file_per_skill(self): with open(os.path.join(tmp, rows[0]["proposed_file"]), encoding="utf-8") as f: self.assertEqual(f.read(), "# alpha\n") + def test_empty_proposal_body_is_refused_without_writing(self): + with tempfile.TemporaryDirectory() as tmp: + with self.assertRaisesRegex(StagingError, "is empty"): + write_skill_proposals(tmp, [_proposal("alpha", " \n")]) + self.assertEqual(os.listdir(tmp), []) + def test_no_proposals_writes_nothing(self): with tempfile.TemporaryDirectory() as tmp: self.assertEqual(write_skill_proposals(tmp, []), []) @@ -227,6 +238,10 @@ def test_fan_out_adds_files_and_manifest_rows(self): self.assertEqual([r["skill_name"] for r in rows], ["alpha", "beta"]) self.assertEqual(rows[1]["live_skill_path"], os.path.join(live_root, "beta", "SKILL.md")) + self.assertEqual( + rows[0]["sha256"], + hashlib.sha256(b"# alpha\n").hexdigest(), + ) def test_unsafe_fan_out_writes_no_manifest(self): with tempfile.TemporaryDirectory() as tmp: From a233a081530522381e43293c286b9f4fc7b462cd Mon Sep 17 00:00:00 2001 From: "Bogdan (Dan) Baciu" Date: Sat, 15 Aug 2026 19:17:44 +0400 Subject: [PATCH 5/5] fix(sleep): harden multi-skill fan-out adoption end to end --- CHANGELOG.md | 15 +- docs/guide/installation.md | 5 +- docs/reference/cli.md | 18 +- docs/sleep/README.md | 42 +- docs/sleep/multi-skill-staging.md | 317 +- mkdocs.yml | 1 + plugins/README.md | 3 +- plugins/claude-code/README.md | 7 +- .../commands/skillopt-sleep-handoff.md | 5 +- .../claude-code/commands/skillopt-sleep.md | 7 +- plugins/codex/README.md | 3 +- plugins/codex/skills/skillopt-sleep/SKILL.md | 6 +- plugins/copilot/README.md | 28 +- .../copilot/copilot-instructions.snippet.md | 16 +- plugins/copilot/mcp_server.py | 252 +- plugins/cursor/README.md | 8 +- plugins/cursor/skills/skillopt-sleep/SKILL.md | 8 +- plugins/devin/README.md | 26 +- plugins/devin/devin-rules.snippet.md | 17 +- plugins/devin/mcp_server.py | 274 +- skillopt_sleep/__main__.py | 390 ++- skillopt_sleep/backend.py | 7 +- skillopt_sleep/config.py | 6 +- skillopt_sleep/cycle.py | 246 +- skillopt_sleep/multi_skill.py | 21 +- skillopt_sleep/scheduler.py | 89 +- skillopt_sleep/skill_resolver.py | 84 +- skillopt_sleep/staging.py | 2628 +++++++++++++++-- skillopt_sleep/state.py | 12 +- tests/test_devin_plugin.py | 286 +- tests/test_handoff_backend.py | 91 + tests/test_mcp_schema.py | 272 +- tests/test_plugin_sync.py | 17 + tests/test_scheduler_windows.py | 19 +- tests/test_sleep_adopt_skill_subset.py | 1270 +++++++- tests/test_sleep_engine.py | 524 +++- tests/test_sleep_multi_skill.py | 95 + tests/test_sleep_scheduler_safety.py | 82 + tests/test_sleep_skill_resolver.py | 36 + tests/test_sleep_staging_fanout.py | 365 ++- tests/test_sleep_state.py | 16 + 41 files changed, 6989 insertions(+), 625 deletions(-) create mode 100644 tests/test_sleep_scheduler_safety.py create mode 100644 tests/test_sleep_state.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 29c8fa30..d6bbfc79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ All notable changes to SkillOpt are documented here. This project adheres to ## [Unreleased] ### Added +- **SkillOpt-Sleep multi-skill fan-out and reviewed subset adoption**: each + hinted skill is consolidated from its own pinned live baseline, staged as an + independent proposal with per-skill gate evidence, and promoted only through + an explicit `--skill`, `--all-skills`, or managed `--legacy` choice. Adoption + uses a versioned fail-closed manifest, provenance hashes, canonical target + pins, immutable backups/receipts, cross-night locking, durable publication, + and restart-recoverable transactions. Fan-out discovers native project skill + roots, supports repeatable `--skill-root` overrides, and is enabled by + `multi_skill_fanout` (`multi_skill_report` remains an alias). MCP adapters + enforce typed arguments and preserve engine failures without copying outside + the transaction (thanks @bogdanbaciu21, #212). - **OpenCode transcript source** (`--source opencode`) for SkillOpt-Sleep. It reads visible user/assistant text and tool names from OpenCode's local SQLite history without requiring its CLI, login, or a provider connection. @@ -110,8 +121,8 @@ All notable changes to SkillOpt are documented here. This project adheres to Thank you to the contributors behind this unreleased work: @AKhozya, @Alphaxalchemy, @Phoenix0531-sudo, @SparshGarg999, @Tanmay9223, @chirag127, @codeL1985, @dimitarvdenev, -@ichoosetoaccept, @jcforever1, @nankingjing, @wilyan09007, @xs229, and -@zixuanguo786-ctrl. +@bogdanbaciu21, @ichoosetoaccept, @jcforever1, @nankingjing, @wilyan09007, +@xs229, and @zixuanguo786-ctrl. ## [0.2.0] — 2026-07-02 diff --git a/docs/guide/installation.md b/docs/guide/installation.md index d0a493d5..ebc68577 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -28,8 +28,9 @@ checkout for those files. The generic research `openai_compatible` backend, SkillOpt-Sleep handoff, Sleep support for non-Azure OpenAI-compatible endpoints, the Sleep `--preferences` flag, Cursor source/backend/plugin support, and Pi - source/backend support landed after that release and require a source - install from `main` until the next release. + source/backend support, multi-skill fan-out, and reviewed subset adoption + landed after that release and require a source install from `main` until + the next release. ### Source checkout diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 7a779434..f40ff200 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -5,8 +5,9 @@ > Sleep support for non-Azure OpenAI-compatible endpoints, the Sleep > `--preferences` flag, the research `cursor_exec` target harness, or Cursor > source/backend/plugin support, Pi source/backend support, OpenCode Sleep -> source/backend support, or VS Code Copilot transcript harvesting; use a source -> install from `main` for those features until the next release. +> source/backend support, VS Code Copilot transcript harvesting, or multi-skill +> fan-out and subset adoption; use a source install from `main` for those +> features until the next release. ## Training @@ -143,16 +144,25 @@ Actions are `run`, `dry-run`, `status`, `adopt`, `harvest`, `schedule`, and | `--lookback-hours N` | Initial transcript lookback; `0` scans all history | | `--max-sessions N` / `--max-tasks N` | Bound the harvested workload | | `--target-skill-path PATH` | Explicit skill document to stage/adopt | +| `--skill-root PATH` | Add a skill-resolution root; repeatable, with relative paths resolved below `--project` | | `--tasks-file PATH` | Replay a reviewed task JSON file instead of harvesting | | `--edit-budget N` | Maximum bounded edits for the night | | `--progress` / `--json` | Progress or machine-readable output | | `--auto-adopt` | Apply an accepted staged proposal automatically | `adopt` also accepts `--skill NAME` (repeatable) and `--all-skills` for a night -that staged per-skill proposals. Bare `adopt` on that night lists the names and -exits instead of promoting every skill. See +that staged per-skill proposals. Use `--legacy` to adopt only a co-staged +managed `SKILL.md` / `CLAUDE.md` proposal. Bare `adopt` on a fan-out night lists +the names and exits instead of promoting anything. A leading-dash name must use +the unambiguous `--skill=--name` form. See [multi-skill staging](../sleep/multi-skill-staging.md). +Fan-out resolves existing project-native `.agents/skills`, `.claude/skills`, +`.cursor/skills`, and `.devin/skills` directories plus the established Claude +roots. Use `--skill-root` for another integration-specific location. Configure +the canonical `multi_skill_fanout` key to enable proposal fan-out; +`multi_skill_report` remains a compatibility alias. + The `mock` and `handoff` backends make no network calls. A real backend sends mining, replay, judging, and reflection prompts derived from harvested transcripts and tasks to its selected provider. Review that provider's diff --git a/docs/sleep/README.md b/docs/sleep/README.md index 945367ac..6c653899 100644 --- a/docs/sleep/README.md +++ b/docs/sleep/README.md @@ -92,8 +92,9 @@ pip install skillopt # installs the engine + the `skillopt-sleep` command skillopt-sleep dry-run # harvest + mine + replay, report only; stages nothing skillopt-sleep run # a full nightly cycle; the proposal is staged for review skillopt-sleep status # show state + the latest staged proposal -skillopt-sleep adopt # apply the latest staged proposal +skillopt-sleep adopt --legacy # apply a reviewed managed proposal skillopt-sleep adopt --skill NAME # adopt one staged skill (repeatable) +skillopt-sleep adopt --all-skills # adopt every still-pending fan-out skill skillopt-sleep schedule # install a nightly cron entry for this project ``` @@ -101,8 +102,8 @@ skillopt-sleep schedule # install a nightly cron entry for this project > commands above. Cursor source/backend/plugin support, VS Code Copilot > transcript harvesting, Pi source/backend support, Sleep handoff, non-Azure > OpenAI-compatible endpoints, OpenCode Sleep source/backend support, and -> `--preferences` landed later and require a source install from `main` until -> the next release. +> `--preferences`, multi-skill fan-out, and reviewed subset adoption landed +> later and require a source install from `main` until the next release. The per-agent integrations below still come from the repo; the CLI above is the standalone, pip-only way to run a cycle. Claude Code, Codex, Cursor, Copilot, and @@ -279,25 +280,38 @@ documents the separate HTTPS-only boundary for Azure managed-identity credential Deterministic proof (no API key): `python -m skillopt_sleep.experiments.run_experiment --persona researcher --assert-improves`. -### Opt-in: per-skill group reporting +### Opt-in: per-skill fan-out -Set `"multi_skill_report": true` in `~/.skillopt-sleep/config.json` to add an -independent gate result and report row for every explicit skill hint mined that -night: +Set `"multi_skill_fanout": true` in `~/.skillopt-sleep/config.json` to add an +independent gate result and reviewable proposal for every explicit skill hint +mined that night. `multi_skill_report` remains a compatibility alias: ```json -{"multi_skill_report": true} +{"multi_skill_fanout": true} ``` This runs one additional consolidation per group (including a catch-all group when -hinted and unhinted evidence are mixed), so it increases backend calls and token use. +hinted and unhinted evidence are mixed), so it multiplies backend calls and token +use; configured dream rollouts and synthetic variants multiply the per-group work +too. Each group inherits the configured edit budget, gate mode/metric, +`gate_no_regression`, `dream_rollouts`, `dream_factor`, `recall_k`, and +`evolve_skill`. Recalled archive tasks are restricted to that same skill hint; +shared memory is read-only in fan-out runs. Setting `evolve_skill` to `false` +therefore disables per-skill proposals as well as the managed skill proposal. + Each explicitly hinted group resolves and reads its own live `SKILL.md` before consolidation, so its staged proposal preserves that skill's baseline. Missing, -ambiguous, or unreadable skills are skipped and reported instead of falling back to -the managed document. Adoption remains review-driven: choose proposals with -`adopt --skill NAME` or `--all-skills`; `auto_adopt` never promotes the per-skill -fan-out. Nights containing only the managed catch-all group keep the existing -single-consolidation behavior. +ambiguous, unreadable, aliased, or colliding skills are skipped and reported +instead of falling back to the managed document. Adoption remains review-driven: +choose fan-out proposals with `adopt --skill NAME` or `--all-skills`, and use +`adopt --legacy` for a co-staged managed skill/memory pair. `auto_adopt` never +promotes the per-skill fan-out. Nights containing only the managed catch-all group +keep the existing single-consolidation behavior. + +Resolution searches existing project-native `.agents/skills`, `.claude/skills`, +`.cursor/skills`, and `.devin/skills` directories, then the established Claude +home and plugin-cache roots. Add repeatable `--skill-root PATH` values when an +integration stores skills elsewhere. Relative roots resolve below `--project`. ### Opt-in: experience replay & dream rollouts diff --git a/docs/sleep/multi-skill-staging.md b/docs/sleep/multi-skill-staging.md index 1f0a9037..bb86b816 100644 --- a/docs/sleep/multi-skill-staging.md +++ b/docs/sleep/multi-skill-staging.md @@ -1,143 +1,260 @@ -# Multi-skill staging and subset adoption +# Multi-skill staging and reviewed adoption -There are two layers here. Do not collapse them. +Multi-skill nights separate learning from promotion. The cycle can consolidate +several hinted skills from their own live documents, but it never treats that +fan-out as permission to update every live file. -1. **Low-level adoption API** — `staged_skills()` / `adopt_skills()`, plus - `skillopt-sleep status` and `skillopt-sleep adopt --skill`. This slice is - complete: a night can stage one proposal file per resolved skill, a reviewer - can list those names, and an explicit subset is copied over the live files - with a backup and a hash receipt. -2. **Opt-in nightly fan-out** — with `multi_skill_report`, each hinted group - resolves and reads *its own* live `SKILL.md`, consolidates from that baseline, - and stages an independent proposal. Promotion remains a separate human - decision; `auto_adopt` never applies the fan-out implicitly. +There are two independent proposal modes, and one night can contain both: -Nothing here changes a single-managed-skill night. If a night stages no per-skill -proposals, the staging directory and `manifest.json` are exactly the legacy ones -and `skillopt-sleep adopt` keeps working unchanged. +1. **Managed (legacy) proposal** — the aggregate cycle may stage + `proposed_SKILL.md` and `proposed_CLAUDE.md` for the configured managed skill + and project memory. +2. **Per-skill fan-out** — accepted hinted groups may stage one + `proposed_SKILL..md` each. A reviewer chooses an explicit subset. -## Nightly wiring (`run_sleep_cycle`) +`auto_adopt` applies only an accepted managed proposal. It never promotes the +per-skill fan-out. Pending per-skill names remain visible after a managed +auto-adoption. -When `multi_skill_report` is on and hinted groups pass the gate: +This feature landed after PyPI 0.2.0. Install from `main` until the next release. -- the managed catch-all is **not** staged as a per-skill proposal (it stays on - `proposed_SKILL.md`); -- each hinted group name is resolved with `resolve_skill` against - `skill_search_roots(cfg)` and its live document is read before consolidation; -- each proposal targets the same resolved path that supplied its baseline; -- only `FOUND` unique live paths become `SkillProposal` rows; -- missing, ambiguous, rejected, unreadable, empty, or colliding names are skipped - rather than aborting the night, and each skip is recorded in both report - formats. +## How the nightly fan-out works -Review remains explicit. `auto_adopt` still only runs the legacy `adopt()` -pair; it never silently promotes every staged skill. +Set the canonical `multi_skill_fanout` option to `true`. The earlier +`multi_skill_report` name remains a compatibility alias. When mined evidence +contains explicit skill hints, the cycle: + +1. groups tasks by normalized hint; +2. resolves each name inside existing project-native `.agents/skills`, + `.claude/skills`, `.cursor/skills`, and `.devin/skills` directories, the + established Claude roots, and any repeatable `--skill-root PATH` overrides; +3. reads that skill's exact live `SKILL.md` bytes and canonical path; +4. runs the configured dream/consolidation pipeline independently for the + group; and +5. stages a row only when that group's own gate accepted a non-empty update. + +Missing, ambiguous, unreadable, aliased, unsafe, or colliding skills are skipped +with a note in both report formats. They never fall back to the managed skill's +document. The managed catch-all remains on `proposed_SKILL.md` and is not +duplicated as a per-skill row. + +Each group inherits `recall_k`, `dream_rollouts`, `dream_factor`, `edit_budget`, +`gate_mode`, `gate_metric`, `gate_mixed_weight`, `gate_no_regression`, and +`evolve_skill`. Recalled archive tasks are restricted to the same skill hint, +and shared memory is read-only during group runs. Consequently, +`evolve_skill=false` disables managed and fan-out skill proposals. + +The aggregate consolidation still runs once. Each usable hinted group adds one +independent dream/consolidation run, so provider calls and token use scale with +the number of groups, tasks, and configured rollouts. ## Staging layout -Legacy (single managed skill) — unchanged: +A mixed night can contain managed and per-skill artifacts together: ```text -.skillopt-sleep/staging/20260728-013000/ -├── manifest.json # live_skill_path, live_memory_path, has_skill, has_memory, accepted +.skillopt-sleep/staging/20260815-013000/ +├── manifest.json ├── proposed_SKILL.md ├── proposed_CLAUDE.md -├── report.json -└── report.md -``` - -Multi-skill night — one extra file and one manifest row per skill: - -```text -.skillopt-sleep/staging/20260728-013000/ -├── manifest.json # …the legacy keys plus "skills": [ … ] ├── proposed_SKILL.alpha.md ├── proposed_SKILL.beta.md -├── report.json # report.skill_groups carries each skill's gate evidence -└── report.md +├── report.json +├── report.md +└── evidence.jsonl ``` +`manifest.json` is a versioned, fail-closed format. It retains the old top-level +field names only as safe compatibility sentinels and adds authoritative pinned +proposal rows: + ```json { - "live_skill_path": "/home/dev/.claude/skills/alpha/SKILL.md", + "schema": "skillopt-sleep-staging", + "schema_version": 2, + "live_skill_path": "/repo/.agents/skills/managed/SKILL.md", + "live_memory_path": "/repo/CLAUDE.md", "has_skill": false, + "has_memory": false, + "has_managed_skill": true, + "has_managed_memory": true, "accepted": true, + "legacy": { + "skill": { + "proposed_file": "proposed_SKILL.md", + "live_path": "/repo/.agents/skills/managed/SKILL.md", + "sha256": "", + "live_sha256": "", + "live_realpath": "/repo/.agents/skills/managed/SKILL.md" + }, + "memory": { + "proposed_file": "proposed_CLAUDE.md", + "live_path": "/repo/CLAUDE.md", + "sha256": "", + "live_sha256": "", + "live_realpath": "/repo/CLAUDE.md" + } + }, "skills": [ { "skill_name": "alpha", "proposed_file": "proposed_SKILL.alpha.md", "live_skill_path": "/home/dev/.claude/skills/alpha/SKILL.md", - "sha256": "" - }, - { - "skill_name": "beta", - "proposed_file": "proposed_SKILL.beta.md", - "live_skill_path": "/home/dev/.claude/skills/beta/SKILL.md", - "sha256": "" + "sha256": "", + "live_sha256": "", + "live_realpath": "/home/dev/.claude/skills/alpha/SKILL.md" } ] } ``` -A skill name must be a single safe path segment and a live path must be an -absolute, traversal-free `*.md` file; two skills may not share a name or a target -file. A refused fan-out writes no `manifest.json`, so the folder is not adoptable. +The top-level `has_skill` and `has_memory` compatibility fields are deliberately +always `false`. This makes the pre-feature PyPI 0.2.0 adopter treat a new night +as a no-op instead of bypassing the new validation and transaction engine. +`has_managed_skill` and `has_managed_memory` describe managed proposal presence; +the pinned `legacy` rows are authoritative for adoption. Top-level `accepted` +describes only the aggregate managed gate and does not summarize `skills`. An +aggregate gate may reject while an independently accepted group remains +reviewable in `skills`. -## Adopting a reviewed subset +`sha256` pins the exact staged proposal bytes. `live_sha256` pins the raw live +bytes used as the consolidation baseline; an empty string means the file did +not exist. `live_realpath` pins the canonical destination identity. Staging +refuses publication if either live bytes or canonical identity changed after +the baseline read. -Low-level API: +The writer reserves each staging directory atomically, writes a complete +artifact batch, and publishes its basename through a private mode-`0600` +`.latest` pointer. Invalid or symlinked pointers fall back only to contained, +reserved nights; adoption cannot reorder nights by changing a directory mtime. +Symlinked staging directories and manifests are ignored or refused. -```python -from skillopt_sleep.staging import adopt_skills, latest_staging, staged_skills +## Reviewing and adopting + +```text +python -m skillopt_sleep status --project PATH +python -m skillopt_sleep adopt --project PATH --staging NIGHT --skill alpha +python -m skillopt_sleep adopt --project PATH --staging NIGHT --skill alpha --skill beta +python -m skillopt_sleep adopt --project PATH --staging NIGHT --all-skills +python -m skillopt_sleep adopt --project PATH --staging NIGHT --legacy +``` -staging = latest_staging("/path/to/project") -[row["skill_name"] for row in staged_skills(staging)] # ['alpha', 'beta'] +Selection modes are mutually exclusive: -receipts = adopt_skills(staging, ["alpha"]) # beta is left alone -receipts[0].sha256_before, receipts[0].sha256_after +- `--skill NAME` is repeatable and promotes only those per-skill rows; +- `--all-skills` promotes every pending per-skill row; +- `--legacy` promotes only the co-staged managed skill/memory pair; and +- bare `adopt` remains convenient for a legacy-only night, but refuses a night + with per-skill rows so it cannot imply “adopt everything.” + +Use `--skill=--leading-dash` for a name beginning with `-`. Quote names with +spaces or shell metacharacters according to the active shell. Human guidance +lists names as data and never interpolates them into a copy/paste command. + +The Python API uses the same transaction engine: + +```python +from skillopt_sleep.staging import ( + adopt_skills, + latest_staging, + pending_staged_skills, +) + +night = latest_staging("/path/to/project") +names = [row["skill_name"] for row in pending_staged_skills(night)] +receipts = adopt_skills(night, ["alpha"]) ``` -CLI: +`skill_names=None` adopts all per-skill rows. An empty sequence adopts nothing. + +## Integrity and recovery contract + +Before any live mutation, adoption validates the entire relevant manifest and +selected proposal set, including: + +- safe single-segment names and expected proposal filenames; +- unique names, staged files, case-folded paths, canonical paths, and live file + identities, including hard-link aliases; +- regular, non-symlink proposal, manifest, receipt, backup, and journal files; +- proposal SHA-256 pins and valid UTF-8; +- live raw-byte hashes, file existence, canonical targets, file identities, and + modes; and +- any prior receipt row against its derived immutable backup and hashes. + +Old fan-out or managed manifests without live baseline pins are intentionally +refused. Discard and rerun the night; adoption does not guess a baseline for an +old proposal. + +Adoption takes an exclusive staging lock plus stable per-target locks shared +across separate nights. A stale lock fails closed instead of being guessed away. +The locks cover manifest reload, full preflight, backup creation, final live +revalidation, all live replacements, and receipt publication. + +Before the first mutation, the engine fsyncs a private mode-`0600` +`.adopt-transaction.json` version-2 write-ahead journal containing the recovery +state, including the identities of directories created by this transaction. +Backups are created without replacement: ```text -python -m skillopt_sleep status --project PATH -python -m skillopt_sleep adopt --project PATH --skill alpha -python -m skillopt_sleep adopt --project PATH --skill alpha --skill beta -python -m skillopt_sleep adopt --project PATH --all-skills +backup/skills//SKILL.md # per-skill original +backup/SKILL.md # managed skill original +backup/CLAUDE.md # managed memory original ``` -On a multi-skill night, bare `adopt` does **not** silently promote every staged -skill. It lists the names and asks for `--skill` or `--all-skills`. Legacy -nights (no `skills` in the manifest) still use `adopt()` unchanged. - -- `skill_names=None` adopts every staged skill; `[]` adopts nothing. -- An unknown or repeated name, an empty `--skill` token, an unsafe manifest - row, a missing proposal file, a sha256 mismatch, an empty proposal body, or a - uniqueness / live-target collision raises `StagingError` **before** anything - is written. -- Uniqueness and live-target checks run **at adoption time against every staged - row**, not only the selection, so adopting one skill cannot hide a sibling - that now points at the same file (including via casefold or realpath/symlink). - A live path that exists as something other than a file is also refused. -- Each selected proposal is pinned by the manifest `sha256`. Tampering with the - staged file, or dropping the pin, is refused with no writes. -- The live target must already be `/SKILL.md`. Adopt will not create - parent directories, follow a symlink file, or write through a symlink parent. -- Each live file is backed up to `backup/skills//` and written atomically. -- If any write fails — including `adopted_skills.json` — every live file in the - selection is restored (and files that did not exist before are removed), and - the previous receipt bytes are restored atomically, so a partial adoption - never survives. -- Receipts (`skill_name`, `live_skill_path`, `sha256_before`, `sha256_after`, - `backup_path`) are returned and written to `adopted_skills.json` in the staging - directory. An empty `sha256_before` means the skill had no live file yet. - -## Migrating - -- **Consumers of `manifest.json`**: treat `"skills"` as optional; when absent the - night is a legacy single-proposal one. -- **Consumers of `report.json`**: `skill_groups` is `[]` on a single-skill night, - and the flat `accepted` / `gate_action` / score fields keep their meaning. -- **Adoption tooling**: `adopt()` still adopts the legacy single proposal pair. - Use `adopt_skills()` for per-skill nights; the two are independent, and neither - runs implicitly. +Per-skill receipts accumulate in `adopted_skills.json`; managed receipts live in +`adopted_legacy.json`. A skill or managed target cannot be re-adopted from the +same night, and existing receipt/backup history must validate before another +subset can be appended. + +The engine revalidates the complete target set before and after receipt +publication. The journal is removed only after every selected target and the +receipt are durably published; that removal is the commit point. A caught +failure triggers immediate rollback. If the process stops first, the next +adoption recovers the journal before it trusts the manifest. Recovery restores +only content still equal to this transaction's proposal and removes only empty +created directories whose identities still match the journal. If an external +editor changed content or replaced a directory, recovery preserves it, retains +the journal/backups, and raises `StagingRecoveryError` for manual resolution. + +On POSIX, file and parent-directory changes are fsynced. Python's standard +library does not expose an equivalent portable directory flush on Windows, so +the journal and file contents are flushed there but power-loss durability of +directory entries remains filesystem/OS dependent. + +The final byte/identity/mode check occurs immediately before atomic replacement, +and all SkillOpt adoption processes share target locks. Portable Python does not +provide a filesystem compare-and-swap against an unrelated process that ignores +those locks; such a process can still race in the final check/replace micro-gap. +Keep live skill editing and adoption coordinated when stronger OS-specific +locking is required. + +## Machine interfaces and integrations + +`run --json` includes additive `skill_groups` and `staged_skills` fields. +`status --json` always includes `staged_skills` (an empty array on a malformed +manifest) and adds `staging_error` when inspection failed. Adoption success and +failure are single JSON documents; selection-required failures return +`available_skills` as objects with `skill_name` and `live_skill_path`. + +The Copilot and Devin MCP `sleep_adopt` tools expose `staging`, `skills`, +`all_skills`, and `legacy`. They forward names as subprocess argument-vector +elements, never shell text. The adapters validate actual JSON-RPC argument types +before launching a subprocess, preserve nonzero engine status, and do not copy +adopted content to a second unpinned destination. Native project skill roots are +adopted directly through the core transaction. + +## Migration checklist + +- Require `schema="skillopt-sleep-staging"` and `schema_version=2`; unknown or + missing new-format versions fail closed. +- Treat `legacy` and `skills` as the authoritative managed and fan-out rows. +- Expect legacy `has_skill` / `has_memory` compatibility sentinels to be false; + use `has_managed_skill` / `has_managed_memory` for managed presence. +- Interpret top-level `accepted` as aggregate-only. +- Require all proposal and live pins; restage older unpinned nights. +- Preserve unknown additive JSON fields when building external tooling. +- Use `--staging` when automating promotion so “latest” cannot change between + review and adoption. +- Expect append-only receipts and fail-closed locks/recovery conflicts. +- Do not infer that a successful managed auto-adoption also promoted fan-out + rows. diff --git a/mkdocs.yml b/mkdocs.yml index 43097a6f..26c9a4c9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -50,6 +50,7 @@ nav: - Deep Learning Analogy: guide/dl-analogy.md - SkillOpt-Sleep: - Overview: sleep/README.md + - Multi-skill Staging: sleep/multi-skill-staging.md - OpenAI-compatible Endpoints: sleep/openai-compatible-endpoints.md - Results: sleep/RESULTS.md - Extension Guides: diff --git a/plugins/README.md b/plugins/README.md index 0a999c8c..9a46c98f 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -49,7 +49,8 @@ an importable `skillopt_sleep` module. Install with `uv tool install skillopt` o > supports the base Sleep CLI, while Cursor source/backend/plugin support, > Pi source/backend support, handoff, Sleep support for non-Azure > OpenAI-compatible endpoints, OpenCode Sleep source/backend support, and -> `--preferences` require a source checkout from `main` until the next release. +> `--preferences`, multi-skill fan-out, and reviewed subset adoption require a +> source checkout from `main` until the next release. ## One sleep cycle diff --git a/plugins/claude-code/README.md b/plugins/claude-code/README.md index bbfb7044..da1c08b1 100644 --- a/plugins/claude-code/README.md +++ b/plugins/claude-code/README.md @@ -56,8 +56,8 @@ the shared runner falls back first to a `skillopt-sleep` executable on `PATH` `uv tool install skillopt` or `pip install skillopt` for that fallback. > **Version note.** This page tracks `main`. PyPI 0.2.0 provides the base Sleep -> CLI, but handoff mode and `--preferences` require a source checkout from -> `main` until the next release. +> CLI, but handoff mode, `--preferences`, multi-skill fan-out, and reviewed +> subset adoption require a source checkout from `main` until the next release. ## Quick start @@ -66,7 +66,8 @@ the shared runner falls back first to a `skillopt-sleep` executable on `PATH` /skillopt-sleep dry-run # preview what it would learn; no changes staged /skillopt-sleep run # full cycle: stages a reviewed proposal (still no live edits) /skillopt-sleep status # see history + the latest staged proposal -/skillopt-sleep adopt # apply the staged proposal to CLAUDE.md / SKILL.md (with backup) +/skillopt-sleep adopt --legacy # apply the reviewed managed proposal +# Fan-out nights instead use: adopt --skill NAME (repeatable) or --all-skills /skillopt-sleep-handoff run # same cycle, but THIS session answers the model calls # (no claude -p subprocess, no API key — subscription-friendly) diff --git a/plugins/claude-code/commands/skillopt-sleep-handoff.md b/plugins/claude-code/commands/skillopt-sleep-handoff.md index 8f164604..44673bbe 100644 --- a/plugins/claude-code/commands/skillopt-sleep-handoff.md +++ b/plugins/claude-code/commands/skillopt-sleep-handoff.md @@ -53,8 +53,9 @@ Repeat until the engine exits 0 (done) — at most 8 rounds: - For `run`, if the engine prints a staging directory, `Read` its `report.md` and show the user: held-out baseline → candidate score, the gate decision, the proposed edits, and where the proposal is staged. If an accepted proposal - was staged, tell the user nothing live changed and offer - `/skillopt-sleep adopt`. + was staged, tell the user nothing live changed, inspect `status`, and offer + the exact reviewed selection: `adopt --legacy`, repeatable + `adopt --skill NAME`, or `adopt --all-skills`. - For `dry-run`, no staging directory or `report.md` is created; summarize the final stdout instead. - The engine archives `.skillopt-sleep-handoff/` on a completed real run; diff --git a/plugins/claude-code/commands/skillopt-sleep.md b/plugins/claude-code/commands/skillopt-sleep.md index 4c63466d..48ec4b6d 100644 --- a/plugins/claude-code/commands/skillopt-sleep.md +++ b/plugins/claude-code/commands/skillopt-sleep.md @@ -60,8 +60,11 @@ what the optimizer writes, add `--preferences ""`. the score, gate decision, and edits from stdout (or request `--json` when machine-readable output is useful). 4. **For `run` that produced an accepted proposal:** inspect whether stdout says - it was auto-adopted. If not, tell the user nothing live changed and offer - `/skillopt-sleep adopt`; if it was, report the updated paths explicitly. + it was auto-adopted. If not, tell the user nothing live changed, run or cite + `status`, and offer the exact reviewed mode: `adopt --legacy`, repeatable + `adopt --skill NAME`, or `adopt --all-skills`. Never imply that bare adopt + means “adopt everything.” If it was auto-adopted, report the updated paths + and any still-pending fan-out names explicitly. 5. **For `adopt`:** confirm which live files were updated and that backups were written under the staging dir's `backup/`. 6. **Never** edit `CLAUDE.md` or `SKILL.md` yourself — let the engine's explicit diff --git a/plugins/codex/README.md b/plugins/codex/README.md index 517bcf49..7d35e8af 100644 --- a/plugins/codex/README.md +++ b/plugins/codex/README.md @@ -52,7 +52,8 @@ Codex in natural language: Use the skillopt-sleep skill to run status for this project. Use the skillopt-sleep skill to run a dry-run for this project. Use the skillopt-sleep skill to run the full cycle for this project with the Codex backend. -Use the skillopt-sleep skill to adopt the latest staged proposal. +Use the skillopt-sleep skill to inspect status, then adopt the reviewed managed +proposal or an explicit pending skill subset. ``` Or call the engine directly: diff --git a/plugins/codex/skills/skillopt-sleep/SKILL.md b/plugins/codex/skills/skillopt-sleep/SKILL.md index 688ae377..ac4a2bcc 100644 --- a/plugins/codex/skills/skillopt-sleep/SKILL.md +++ b/plugins/codex/skills/skillopt-sleep/SKILL.md @@ -62,9 +62,13 @@ bash "$SKILLOPT_SLEEP_REPO/plugins/run-sleep.sh" dry-run --project "$(pwd)" \ bash "$SKILLOPT_SLEEP_REPO/plugins/run-sleep.sh" run --project "$(pwd)" \ --source codex --target-skill-path "$TARGET_SKILL" --backend codex \ --max-sessions 5 --max-tasks 3 --progress -bash "$SKILLOPT_SLEEP_REPO/plugins/run-sleep.sh" adopt --project "$(pwd)" +bash "$SKILLOPT_SLEEP_REPO/plugins/run-sleep.sh" status --project "$(pwd)" +bash "$SKILLOPT_SLEEP_REPO/plugins/run-sleep.sh" adopt --project "$(pwd)" --legacy ``` +For a fan-out night, select reviewed proposals with repeatable +`--skill NAME` or `--all-skills`; do not treat bare adopt as “adopt everything.” + On Windows (CMD / PowerShell): ```cmd :: CMD diff --git a/plugins/copilot/README.md b/plugins/copilot/README.md index dd907c76..8424ae3e 100644 --- a/plugins/copilot/README.md +++ b/plugins/copilot/README.md @@ -46,10 +46,30 @@ propose?"*, *"adopt the staged sleep proposal"*. The server exposes seven MCP tools: `sleep_status`, `sleep_dry_run`, `sleep_run`, `sleep_adopt`, `sleep_harvest`, `sleep_schedule`, and `sleep_unschedule`. -Each tool takes optional `project`, `backend` (`mock`/`claude`/`codex`/`copilot`), and -`scope` arguments. Default backend is `mock` (no API spend). The `copilot` -backend drives the GitHub Copilot CLI (`copilot -p ... --output-format json`) -and requires the `copilot` CLI to be installed and authenticated. +Each tool takes optional `project`, `backend` +(`mock`/`claude`/`codex`/`copilot`/`handoff`), and `scope` arguments. For +`sleep_adopt`, first inspect `sleep_status`, then use the adoption controls that +match the reviewed staging manifest: + +- `staging` — exact staging directory to adopt instead of the latest night +- `skills` — array of skill names to adopt; each is passed as one repeated + `--skill` argument without shell interpolation +- `all_skills` — adopt every staged per-skill proposal +- `legacy` — adopt only the legacy managed `SKILL.md`/`CLAUDE.md` pair + +Choose one selection mode (`skills`, `all_skills`, or `legacy`) and do not +combine them. A bare `sleep_adopt` remains compatible with legacy-only staging; +fan-out staging requires an explicit selection. + +Tool results preserve the engine's `exit_code` in `structuredContent`. +Ordinary nonzero exits set `isError: true`; exit 3 is the expected +`handoff_pending` state and is not an MCP tool error. With `json: true`, text +content is the engine's parseable JSON stdout, while diagnostics remain +separate in `structuredContent`. + +Default backend is `mock` (no API spend). The `copilot` backend drives the +GitHub Copilot CLI (`copilot -p ... --output-format json`) and requires the +`copilot` CLI to be installed and authenticated. Harvesting is local and read-only, and the default `mock` backend makes no provider calls. A real backend sends truncated transcript excerpts and derived diff --git a/plugins/copilot/copilot-instructions.snippet.md b/plugins/copilot/copilot-instructions.snippet.md index e22a20bf..3abc5a0d 100644 --- a/plugins/copilot/copilot-instructions.snippet.md +++ b/plugins/copilot/copilot-instructions.snippet.md @@ -18,19 +18,25 @@ my preferences", or "make the agent improve from past usage", use the MCP tools: - `sleep_dry_run` — no-staging preview; a real backend still makes provider calls - `sleep_run` — full cycle, stages a validation-gated proposal by default; explicit `auto_adopt` may update live files -- `sleep_adopt` — apply the staged proposal (backs up an existing live file first) +- `sleep_adopt` — apply a reviewed legacy or per-skill staged proposal (backs + up an existing live file first) - `sleep_harvest` — list mined recurring tasks - `sleep_schedule` — install a nightly cron entry (set `hour`/`minute`) - `sleep_unschedule` — remove the nightly cron entry ### Key parameters (pass as MCP tool arguments) -- `backend` — `mock` (default, no provider calls), `claude`, `codex`, or `copilot` +- `backend` — `mock` (default, no provider calls), `claude`, `codex`, `copilot`, + or `handoff` (write prompts for completion in fresh sessions) - `source` — `claude`, `codex`, or `auto` (where to read transcripts) - `target_skill_path` — explicit SKILL.md to evolve; use this for a skill that the current agent actually loads - `tasks_file` — reviewed TaskRecord JSON (skip harvest); real backends require its metadata to contain `"reviewed": true` +- `staging` — for `sleep_adopt`, the exact reviewed staging directory +- `skills` — for `sleep_adopt`, an array of reviewed skill names to adopt +- `all_skills` — for `sleep_adopt`, adopt every staged per-skill proposal +- `legacy` — for `sleep_adopt`, adopt only the legacy managed pair - `max_tasks` / `max_sessions` — cap workload - `auto_adopt` — auto-adopt if the gate passes - `json` — machine-readable output for programmatic use @@ -49,6 +55,12 @@ edits before suggesting `sleep_adopt`. Never hand-edit the user's memory/skill files; use `sleep_adopt` (or an explicitly requested `auto_adopt`) so the engine applies its staging manifest and backup behavior. +Before calling `sleep_adopt`, inspect `sleep_status` and the staging manifest, +then pass `staging` plus exactly one selection mode: `skills`, `all_skills`, or +`legacy`. Do not combine selection modes. A bare call is only for legacy-only +staging compatibility; fan-out staging requires an explicit selection. Pass +skill names as MCP array values, never as an invented shell command. + Harvesting is local and read-only, and `backend: "mock"` makes no provider calls. A real backend sends truncated transcript excerpts and derived tasks to the selected provider; outbound prompts are not guaranteed to be secret-free. diff --git a/plugins/copilot/mcp_server.py b/plugins/copilot/mcp_server.py index fe505424..f2d7b23b 100755 --- a/plugins/copilot/mcp_server.py +++ b/plugins/copilot/mcp_server.py @@ -9,7 +9,7 @@ - sleep_status : how many nights have run + the latest staged proposal - sleep_dry_run : harvest+mine+replay, report only (no staging) - sleep_run : full cycle, stages a proposal (nothing live changes) - - sleep_adopt : apply the latest staged proposal (with backup) + - sleep_adopt : apply a reviewed legacy or per-skill proposal (with backup) - sleep_harvest : debug — list mined recurring tasks Each tool shells out to `python -m skillopt_sleep ...` and returns its @@ -21,6 +21,7 @@ import os import subprocess import sys +from typing import NamedTuple REPO_ROOT = os.environ.get("SKILLOPT_SLEEP_REPO") or os.path.abspath( os.path.join(os.path.dirname(__file__), "..", "..") @@ -35,7 +36,7 @@ {"name": "sleep_run", "action": "run", "description": "Run a full sleep cycle; stages a reviewed proposal. Nothing live changes until adopt."}, {"name": "sleep_adopt", "action": "adopt", - "description": "Apply the latest staged proposal to CLAUDE.md/SKILL.md (backs up first)."}, + "description": "Apply a reviewed legacy or per-skill staged proposal (backs up first)."}, {"name": "sleep_harvest", "action": "harvest", "description": "Debug: list the recurring tasks mined from recent sessions."}, {"name": "sleep_schedule", "action": "schedule", @@ -50,8 +51,11 @@ "properties": { "project": {"type": "string", "description": "Project dir to evolve (default: cwd)."}, - "backend": {"type": "string", "enum": ["mock", "claude", "codex", "copilot"], - "description": "mock = no API spend (default); claude/codex/copilot = real."}, + "backend": { + "type": "string", + "enum": ["mock", "claude", "codex", "copilot", "handoff"], + "description": "mock = local/default; claude/codex/copilot = real; handoff = no API subprocess.", + }, "scope": {"type": "string", "enum": ["invoked", "all"], "description": "Harvest scope (default: invoked project only)."}, "source": {"type": "string", "enum": ["claude", "codex", "auto"], @@ -62,30 +66,155 @@ "description": "Path to reviewed TaskRecord JSON (skips harvest)."}, "target_skill_path": {"type": "string", "description": "Explicit SKILL.md path to evolve/stage/adopt."}, + "staging": { + "type": "string", + "description": "For sleep_adopt, use this exact staging directory instead of the latest night.", + }, + "skills": { + "type": "array", + "items": {"type": "string", "minLength": 1}, + "uniqueItems": True, + "description": "For sleep_adopt, adopt only these staged per-skill proposals.", + }, + "all_skills": { + "type": "boolean", + "description": "For sleep_adopt, adopt every staged per-skill proposal.", + }, + "legacy": { + "type": "boolean", + "description": "For sleep_adopt, adopt only the legacy managed SKILL.md/CLAUDE.md pair.", + }, "progress": {"type": "boolean", "description": "Print phase progress to stderr."}, - "max_sessions": {"type": "integer", + "max_sessions": {"type": "integer", "minimum": 0, "maximum": 1_000_000, "description": "Cap harvested sessions per run."}, - "max_tasks": {"type": "integer", - "description": "Cap mined tasks per run."}, - "lookback_hours": {"type": "integer", + "max_tasks": {"type": "integer", "minimum": 0, "maximum": 1_000_000, + "description": "Cap mined tasks per run."}, + "lookback_hours": {"type": "integer", "minimum": 0, "maximum": 1_000_000, "description": "Harvest window in hours (default: 72)."}, "auto_adopt": {"type": "boolean", "description": "Auto-adopt if gate passes (default: false)."}, "json": {"type": "boolean", "description": "Return machine-readable JSON output."}, - "edit_budget": {"type": "integer", + "edit_budget": {"type": "integer", "minimum": 0, "maximum": 1_000_000, "description": "Max bounded edits per night (default: 4)."}, - "hour": {"type": "integer", + "hour": {"type": "integer", "minimum": 0, "maximum": 23, "description": "Hour for schedule (0-23, default: 3)."}, - "minute": {"type": "integer", + "minute": {"type": "integer", "minimum": 0, "maximum": 59, "description": "Minute for schedule (0-59, default: 17)."}, }, "additionalProperties": False, } +_STRING_ARGS = { + "project", "backend", "scope", "source", "model", "tasks_file", + "target_skill_path", "staging", +} +_BOOLEAN_ARGS = {"all_skills", "legacy", "progress", "auto_adopt", "json"} +_INTEGER_BOUNDS = { + "max_sessions": (0, 1_000_000), + "max_tasks": (0, 1_000_000), + "lookback_hours": (0, 1_000_000), + "edit_budget": (0, 1_000_000), + "hour": (0, 23), + "minute": (0, 59), +} +_ADOPT_ONLY_ARGS = {"staging", "skills", "all_skills", "legacy"} +_SCHEDULE_ONLY_ARGS = {"hour", "minute"} + + +class EngineResult(NamedTuple): + """One engine invocation, including status hidden by the old text-only API.""" + + text: str + returncode: int + diagnostics: str = "" + + +def _validate_text(key: str, value: object) -> None: + if type(value) is not str: + raise ValueError(f"{key} must be a string") + if any(ord(ch) < 32 or ord(ch) == 127 for ch in value): + raise ValueError(f"{key} must not contain control characters") + + +def _validate_tool_arguments(action: str, args: object) -> dict: + """Validate MCP input at runtime; clients are not trusted to enforce schema.""" + if action not in {tool["action"] for tool in TOOLS}: + raise ValueError(f"unknown action: {action}") + if type(args) is not dict: + raise ValueError("arguments must be an object") + unknown = sorted(set(args) - set(_TOOL_SCHEMA["properties"])) + if unknown: + raise ValueError(f"unknown argument(s): {', '.join(unknown)}") + if action != "adopt" and set(args) & _ADOPT_ONLY_ARGS: + raise ValueError("staging/skills/all_skills/legacy are valid only for sleep_adopt") + if action != "schedule" and set(args) & _SCHEDULE_ONLY_ARGS: + raise ValueError("hour/minute are valid only for sleep_schedule") + + for key in _STRING_ARGS & set(args): + _validate_text(key, args[key]) + for key in _BOOLEAN_ARGS & set(args): + if type(args[key]) is not bool: + raise ValueError(f"{key} must be a boolean") + for key, (minimum, maximum) in _INTEGER_BOUNDS.items(): + if key not in args: + continue + value = args[key] + if type(value) is not int: + raise ValueError(f"{key} must be an integer") + if not minimum <= value <= maximum: + raise ValueError(f"{key} must be between {minimum} and {maximum}") + + for key in ("backend", "scope", "source"): + if key in args and args[key] not in _TOOL_SCHEMA["properties"][key]["enum"]: + raise ValueError(f"unsupported {key}: {args[key]!r}") + + skills = args.get("skills", []) + if type(skills) is not list: + raise ValueError("skills must be an array of strings") + normalized = [] + for skill in skills: + _validate_text("every skills entry", skill) + name = skill.strip() + if not name: + raise ValueError("every skills entry must be non-empty") + normalized.append(name) + if len(set(normalized)) != len(normalized): + raise ValueError("skills entries must be unique") -def _run_engine(action: str, args: dict) -> str: + modes = sum((bool(normalized), args.get("all_skills") is True, args.get("legacy") is True)) + if modes > 1: + raise ValueError("choose at most one of skills, all_skills, or legacy") + validated = dict(args) + if "skills" in validated: + validated["skills"] = normalized + return validated + + +def _append_adopt_args(cmd: list[str], args: dict) -> None: + """Append selection flags as argv tokens; never interpolate skill names.""" + staging = args.get("staging") + if staging: + cmd += ["--staging", str(staging)] + + skills = args.get("skills") or [] + for skill in skills: + # argparse treats a following value beginning with '-' as another + # option. The --flag=value form keeps such a skill name as data. All + # other names stay separate argv tokens; no shell parses either form. + if skill.startswith("-"): + cmd.append(f"--skill={skill}") + else: + cmd += ["--skill", skill] + if args.get("all_skills"): + cmd.append("--all-skills") + if args.get("legacy"): + cmd.append("--legacy") + + +def _run_engine(action: str, args: object) -> EngineResult: + args = _validate_tool_arguments(action, args) py = sys.executable or "python3" cmd = [py, "-m", "skillopt_sleep", action] # String-valued flags @@ -114,13 +243,19 @@ def _run_engine(action: str, args: dict) -> str: ]: if args.get(key): cmd.append(flag) + if action == "adopt": + _append_adopt_args(cmd, args) try: proc = subprocess.run(cmd, cwd=REPO_ROOT, capture_output=True, text=True, timeout=3600) except Exception as e: - return f"[error] failed to run engine: {e}" + return EngineResult(f"[error] failed to run engine: {e}", 1) out = (proc.stdout or "").strip() err = (proc.stderr or "").strip() - return out + (("\n[stderr]\n" + err) if err else "") + if args.get("json"): + text = out if out or proc.returncode in {0, 3} else err + return EngineResult(text, proc.returncode, err) + text = out + (("\n[stderr]\n" + err) if err else "") + return EngineResult(text, proc.returncode) def _result(id_, result): @@ -131,9 +266,60 @@ def _error(id_, code, message): return {"jsonrpc": "2.0", "id": id_, "error": {"code": code, "message": message}} -def handle(req: dict): +def _validate_request(req: object) -> tuple[str, object, dict]: + if type(req) is not dict: + raise ValueError("request must be a JSON object") + unknown = sorted(set(req) - {"jsonrpc", "id", "method", "params"}) + if unknown: + raise ValueError(f"unknown request member(s): {', '.join(unknown)}") + if req.get("jsonrpc") != "2.0": + raise ValueError("jsonrpc must be '2.0'") method = req.get("method") - id_ = req.get("id") + if type(method) is not str or not method: + raise ValueError("method must be a non-empty string") + params = req.get("params", {}) + if type(params) is not dict: + raise ValueError("params must be an object") + request_id = req.get("id") + if "id" in req and request_id is not None and type(request_id) not in {str, int}: + raise ValueError("id must be a string, integer, or null") + return method, request_id, params + + +def _validate_method_params(method: str, params: dict) -> None: + allowed_by_method = { + "initialize": {"protocolVersion", "capabilities", "clientInfo", "_meta"}, + "notifications/initialized": {"_meta"}, + "initialized": {"_meta"}, + "tools/list": {"cursor", "_meta"}, + "tools/call": {"name", "arguments", "_meta"}, + "ping": {"_meta"}, + } + allowed = allowed_by_method.get(method) + if allowed is None: + return + unknown = sorted(set(params) - allowed) + if unknown: + raise ValueError(f"unknown params member(s): {', '.join(unknown)}") + for key in ("capabilities", "clientInfo", "_meta"): + if key in params and type(params[key]) is not dict: + raise ValueError(f"{key} must be an object") + for key in ("protocolVersion", "cursor"): + if key in params and type(params[key]) is not str: + raise ValueError(f"{key} must be a string") + + +def handle(req: object): + try: + method, id_, params = _validate_request(req) + except ValueError as exc: + candidate = req.get("id") if type(req) is dict else None + request_id = candidate if candidate is None or type(candidate) in {str, int} else None + return _error(request_id, -32600, f"invalid request: {exc}") + try: + _validate_method_params(method, params) + except ValueError as exc: + return _error(id_, -32602, f"invalid params: {exc}") if method == "initialize": return _result(id_, { "protocolVersion": PROTOCOL_VERSION, @@ -148,13 +334,34 @@ def handle(req: dict): for t in TOOLS ]}) if method == "tools/call": - params = req.get("params") or {} name = params.get("name") + if type(name) is not str: + return _error(id_, -32602, "tool name must be a string") tool = _BY_NAME.get(name) if not tool: return _error(id_, -32602, f"unknown tool: {name}") - text = _run_engine(tool["action"], params.get("arguments") or {}) - return _result(id_, {"content": [{"type": "text", "text": text}]}) + arguments = params.get("arguments", {}) + try: + run = _run_engine(tool["action"], arguments) + except ValueError as exc: + return _error(id_, -32602, f"invalid {name} arguments: {exc}") + status = "handoff_pending" if run.returncode == 3 else ( + "ok" if run.returncode == 0 else "error" + ) + structured = {"status": status, "exit_code": run.returncode} + if run.diagnostics: + structured["diagnostics"] = run.diagnostics + if type(arguments) is dict and arguments.get("json") is True and run.text: + try: + structured["output"] = json.loads(run.text) + except json.JSONDecodeError: + pass + result = { + "content": [{"type": "text", "text": run.text}], + "structuredContent": structured, + "isError": run.returncode not in {0, 3}, + } + return _result(id_, result) if method == "ping": return _result(id_, {}) return _error(id_, -32601, f"method not found: {method}") @@ -167,9 +374,10 @@ def main() -> int: continue try: req = json.loads(line) - except Exception: - continue - resp = handle(req) + except json.JSONDecodeError: + resp = _error(None, -32700, "parse error") + else: + resp = handle(req) if resp is not None: sys.stdout.write(json.dumps(resp) + "\n") sys.stdout.flush() diff --git a/plugins/cursor/README.md b/plugins/cursor/README.md index 3310a63b..26ce74ae 100644 --- a/plugins/cursor/README.md +++ b/plugins/cursor/README.md @@ -71,9 +71,12 @@ Run the native command, for example: /skillopt-sleep status /skillopt-sleep dry-run --backend mock --max-sessions 5 --max-tasks 3 /skillopt-sleep run --backend cursor --max-sessions 5 --max-tasks 3 --progress -/skillopt-sleep adopt +/skillopt-sleep adopt --legacy ``` +For fan-out proposals, inspect `status` and use repeatable `--skill NAME` or +`--all-skills`. Bare adopt deliberately refuses a night containing fan-out rows. + The `skillopt-sleep` agent skill remains independently available if a Cursor version does not surface plugin commands. @@ -124,7 +127,8 @@ skillopt-sleep dry-run --project "$(pwd)" --source cursor --backend mock \ skillopt-sleep run --project "$(pwd)" --source cursor --backend cursor \ --target-skill-path .cursor/skills/skillopt-sleep-learned/SKILL.md \ --max-sessions 5 --max-tasks 3 --progress -skillopt-sleep adopt --project "$(pwd)" +skillopt-sleep status --project "$(pwd)" +skillopt-sleep adopt --project "$(pwd)" --legacy ``` `--source cursor` reads local JSONL transcripts below diff --git a/plugins/cursor/skills/skillopt-sleep/SKILL.md b/plugins/cursor/skills/skillopt-sleep/SKILL.md index fc66d4db..71cc664d 100644 --- a/plugins/cursor/skills/skillopt-sleep/SKILL.md +++ b/plugins/cursor/skills/skillopt-sleep/SKILL.md @@ -81,10 +81,14 @@ skillopt-sleep run --project "$(pwd)" --source cursor --backend cursor \ --target-skill-path "$TARGET_SKILL" \ --max-sessions 5 --max-tasks 3 --progress -# Apply the latest accepted staged proposal after review. -skillopt-sleep adopt --project "$(pwd)" +# Inspect selections, then apply the reviewed managed proposal. +skillopt-sleep status --project "$(pwd)" +skillopt-sleep adopt --project "$(pwd)" --legacy ``` +For fan-out proposals, use repeatable `--skill NAME` or `--all-skills` after +review. Bare adopt deliberately refuses a night containing fan-out rows. + Actions are `status`, `harvest`, `dry-run`, `run`, `adopt`, `schedule`, and `unschedule`. diff --git a/plugins/devin/README.md b/plugins/devin/README.md index 53abe6e8..9bf606a0 100644 --- a/plugins/devin/README.md +++ b/plugins/devin/README.md @@ -30,7 +30,8 @@ source into the Claude Code-compatible JSONL the engine reads. | Skill files | `.devin/skills/*/SKILL.md` | Workspaces are auto-detected from `~/.config/Devin/User/workspaceStorage/*/workspace.json`. -After `sleep_adopt`, the evolved skill is synced to `.devin/skills/skillopt-sleep-learned/SKILL.md`. +The adapter performs no post-adoption copy. The core engine applies a reviewed +proposal directly to its selected target and owns backup/rollback behavior. ## Install @@ -68,11 +69,32 @@ Requires Python ≥ 3.10. No third-party packages — the server is pure stdlib. | `sleep_status` | nights run so far + latest staged proposal | | `sleep_dry_run` | preview cycle — no staging; a real backend still makes provider calls | | `sleep_run` | full cycle; stages a proposal for review | -| `sleep_adopt` | apply the staged proposal; syncs skill to the workspace | +| `sleep_adopt` | apply a reviewed legacy or per-skill proposal (with backup) | | `sleep_harvest` | debug: list the recurring tasks mined | | `sleep_schedule` | install a nightly cron entry (`--hour` / `--minute`) | | `sleep_unschedule` | remove the nightly cron entry | +Before `sleep_adopt`, inspect `sleep_status` and use the controls that match the +reviewed staging manifest: + +- `staging` — exact staging directory to adopt instead of the latest night +- `skills` — array of skill names to adopt; each is forwarded as one repeated + `--skill` argument without shell interpolation +- `all_skills` — adopt every staged per-skill proposal +- `legacy` — adopt only the legacy managed `SKILL.md`/`CLAUDE.md` pair + +Choose one selection mode (`skills`, `all_skills`, or `legacy`) and do not +combine them. A bare call remains compatible with legacy-only staging; fan-out +staging requires an explicit selection. To operate on a specific Devin skill, +pass its `SKILL.md` as `target_skill_path`; the adapter never performs a second +copy after the core engine returns. + +Tool results preserve the engine's `exit_code` in `structuredContent`. +Ordinary nonzero exits set `isError: true`; exit 3 is the expected +`handoff_pending` state and is not an MCP tool error. With `json: true`, text +content is the engine's parseable JSON stdout, while harvest and engine +diagnostics remain separate in `structuredContent`. + Default backend is `mock` (no API spend); the `claude`, `codex`, and `copilot` backends use the corresponding authenticated CLI and budget. The `handoff` backend runs the cycle with no model subprocess or API key — the engine writes diff --git a/plugins/devin/devin-rules.snippet.md b/plugins/devin/devin-rules.snippet.md index 4bd1ad8c..d98207e9 100644 --- a/plugins/devin/devin-rules.snippet.md +++ b/plugins/devin/devin-rules.snippet.md @@ -9,9 +9,8 @@ server. Use these tools to improve your long-term skills over time: without engine staging/adoption; a real backend still makes provider calls - **`sleep_run`** — run a full cycle; stages a proposal by default, while an explicit `auto_adopt` may also update live files -- **`sleep_adopt`** — apply the staged proposal, then sync the managed skill to - `.devin/skills/skillopt-sleep-learned/SKILL.md` when `project` is the Devin - workspace and that workspace already contains a `.devin/` directory +- **`sleep_adopt`** — apply a reviewed legacy or per-skill staged proposal; + the core engine applies the selected target and creates its backup - **`sleep_harvest`** — debug: list the recurring tasks mined from recent sessions - **`sleep_schedule`** / **`sleep_unschedule`** — low-level shared-engine cron controls; the current scheduled command does not run Devin's conversion step, @@ -38,4 +37,16 @@ before selecting a real backend. For a reviewed task file, pass `tasks_file`; before using it with a real backend, inspect/redact it and ensure its metadata contains `"reviewed": true`. +Before `sleep_adopt`, inspect `sleep_status` and the staging manifest. Pass +`staging` for the exact reviewed staging directory, plus exactly one selection +mode: `skills` (an array of reviewed skill names), `all_skills` (every staged +per-skill proposal), or `legacy` (the managed `SKILL.md`/`CLAUDE.md` pair). Do +not combine selection modes. A bare call is only for legacy-only staging +compatibility; fan-out staging requires explicit selection. Pass names as MCP +array values, not as an invented shell command. + +The adapter performs no post-adoption copy. To operate on a specific Devin +skill, pass its `SKILL.md` as `target_skill_path`; the core engine is solely +responsible for applying the reviewed proposal and maintaining its backup. + Place this file at `.devin/rules/skillopt-sleep.md` in your workspace. diff --git a/plugins/devin/mcp_server.py b/plugins/devin/mcp_server.py index 5ce58bc6..57b3df68 100644 --- a/plugins/devin/mcp_server.py +++ b/plugins/devin/mcp_server.py @@ -10,8 +10,8 @@ locally available Devin data (ATIF-v1.7 transcripts, agentmemory memories, and .devin skill files) into the Claude Code-compatible JSONL the engine consumes, writing it under SKILLOPT_DEVIN_CLAUDE_HOME and pointing the engine there with -`--claude-home`. After `sleep_adopt` the evolved skill is synced back into the -workspace's `.devin/skills/`. +`--claude-home`. Adoption is performed only by the core engine against the +requested target; this adapter never performs a second copy or sync afterward. Tools: sleep_status, sleep_dry_run, sleep_run, sleep_adopt, sleep_harvest, sleep_schedule, sleep_unschedule. Each shells out to @@ -22,9 +22,9 @@ import json import os -import shutil import subprocess import sys +from typing import NamedTuple # expanduser wraps the whole value so a "~/..." env var is expanded too (not # just a default) — otherwise a literal ~ dir gets created. @@ -36,7 +36,6 @@ CLAUDE_HOME = os.path.expanduser( os.environ.get("SKILLOPT_DEVIN_CLAUDE_HOME", "~/.skillopt-sleep-devin") ) -MANAGED_SKILL_NAME = os.environ.get("SKILLOPT_MANAGED_SKILL", "skillopt-sleep-learned") PROTOCOL_VERSION = "2024-11-05" TOOLS = [ @@ -47,7 +46,7 @@ {"name": "sleep_run", "action": "run", "description": "Run a full sleep cycle; stages a reviewed proposal. Nothing live changes until adopt."}, {"name": "sleep_adopt", "action": "adopt", - "description": "Apply the latest staged proposal to the managed SKILL.md and sync it into .devin/skills/."}, + "description": "Apply a reviewed legacy or per-skill staged proposal (backs up first)."}, {"name": "sleep_harvest", "action": "harvest", "description": "Debug: list the recurring tasks mined from recent Devin sessions."}, {"name": "sleep_schedule", "action": "schedule", @@ -74,23 +73,41 @@ "description": "Path to reviewed TaskRecord JSON (skips harvest)."}, "target_skill_path": {"type": "string", "description": "Explicit SKILL.md path to evolve/stage/adopt."}, + "staging": { + "type": "string", + "description": "For sleep_adopt, use this exact staging directory instead of the latest night.", + }, + "skills": { + "type": "array", + "items": {"type": "string", "minLength": 1}, + "uniqueItems": True, + "description": "For sleep_adopt, adopt only these staged per-skill proposals.", + }, + "all_skills": { + "type": "boolean", + "description": "For sleep_adopt, adopt every staged per-skill proposal.", + }, + "legacy": { + "type": "boolean", + "description": "For sleep_adopt, adopt only the legacy managed SKILL.md/CLAUDE.md pair.", + }, "progress": {"type": "boolean", "description": "Print phase progress to stderr."}, - "max_sessions": {"type": "integer", + "max_sessions": {"type": "integer", "minimum": 0, "maximum": 1_000_000, "description": "Cap harvested sessions per run."}, - "max_tasks": {"type": "integer", + "max_tasks": {"type": "integer", "minimum": 0, "maximum": 1_000_000, "description": "Cap mined tasks per run."}, - "lookback_hours": {"type": "integer", + "lookback_hours": {"type": "integer", "minimum": 0, "maximum": 1_000_000, "description": "Harvest window in hours (default: 72)."}, "auto_adopt": {"type": "boolean", "description": "Auto-adopt if gate passes (default: false)."}, "json": {"type": "boolean", "description": "Return machine-readable JSON output."}, - "edit_budget": {"type": "integer", + "edit_budget": {"type": "integer", "minimum": 0, "maximum": 1_000_000, "description": "Max bounded edits per night (default: 4)."}, - "hour": {"type": "integer", + "hour": {"type": "integer", "minimum": 0, "maximum": 23, "description": "Hour for schedule (0-23, default: 3)."}, - "minute": {"type": "integer", + "minute": {"type": "integer", "minimum": 0, "maximum": 59, "description": "Minute for schedule (0-59, default: 17)."}, }, "additionalProperties": False, @@ -99,8 +116,93 @@ # actions that read harvested Devin data (schedule/unschedule/adopt don't) _HARVEST_ACTIONS = {"status", "dry-run", "run", "harvest"} +_STRING_ARGS = { + "project", "backend", "scope", "source", "model", "tasks_file", + "target_skill_path", "staging", +} +_BOOLEAN_ARGS = {"all_skills", "legacy", "progress", "auto_adopt", "json"} +_INTEGER_BOUNDS = { + "max_sessions": (0, 1_000_000), + "max_tasks": (0, 1_000_000), + "lookback_hours": (0, 1_000_000), + "edit_budget": (0, 1_000_000), + "hour": (0, 23), + "minute": (0, 59), +} +_ADOPT_ONLY_ARGS = {"staging", "skills", "all_skills", "legacy"} +_SCHEDULE_ONLY_ARGS = {"hour", "minute"} + + +class EngineResult(NamedTuple): + """One engine invocation, including status hidden by the old text-only API.""" + + text: str + returncode: int + diagnostics: str = "" + + +def _validate_text(key: str, value: object) -> None: + if type(value) is not str: + raise ValueError(f"{key} must be a string") + if any(ord(ch) < 32 or ord(ch) == 127 for ch in value): + raise ValueError(f"{key} must not contain control characters") + + +def _validate_tool_arguments(action: str, args: object) -> dict: + """Validate MCP input at runtime; clients are not trusted to enforce schema.""" + if action not in {tool["action"] for tool in TOOLS}: + raise ValueError(f"unknown action: {action}") + if type(args) is not dict: + raise ValueError("arguments must be an object") + unknown = sorted(set(args) - set(_TOOL_SCHEMA["properties"])) + if unknown: + raise ValueError(f"unknown argument(s): {', '.join(unknown)}") + if action != "adopt" and set(args) & _ADOPT_ONLY_ARGS: + raise ValueError("staging/skills/all_skills/legacy are valid only for sleep_adopt") + if action != "schedule" and set(args) & _SCHEDULE_ONLY_ARGS: + raise ValueError("hour/minute are valid only for sleep_schedule") + + for key in _STRING_ARGS & set(args): + _validate_text(key, args[key]) + for key in _BOOLEAN_ARGS & set(args): + if type(args[key]) is not bool: + raise ValueError(f"{key} must be a boolean") + for key, (minimum, maximum) in _INTEGER_BOUNDS.items(): + if key not in args: + continue + value = args[key] + if type(value) is not int: + raise ValueError(f"{key} must be an integer") + if not minimum <= value <= maximum: + raise ValueError(f"{key} must be between {minimum} and {maximum}") + + for key in ("backend", "scope", "source"): + if key in args and args[key] not in _TOOL_SCHEMA["properties"][key]["enum"]: + raise ValueError(f"unsupported {key}: {args[key]!r}") + + skills = args.get("skills", []) + if type(skills) is not list: + raise ValueError("skills must be an array of strings") + normalized = [] + for skill in skills: + _validate_text("every skills entry", skill) + name = skill.strip() + if not name: + raise ValueError("every skills entry must be non-empty") + normalized.append(name) + if len(set(normalized)) != len(normalized): + raise ValueError("skills entries must be unique") + + modes = sum((bool(normalized), args.get("all_skills") is True, args.get("legacy") is True)) + if modes > 1: + raise ValueError("choose at most one of skills, all_skills, or legacy") + validated = dict(args) + if "skills" in validated: + validated["skills"] = normalized + return validated -def _run_harvest() -> str: + +def _run_harvest() -> EngineResult: """Convert local Devin data into the JSONL the engine reads, under CLAUDE_HOME.""" harvester = os.path.join(PLUGIN_DIR, "harvest_devin.py") env = dict(os.environ) @@ -112,28 +214,37 @@ def _run_harvest() -> str: ) out = (proc.stdout or "").strip() err = (proc.stderr or "").strip() - return out + (("\n[harvest stderr]\n" + err) if err else "") + return EngineResult(out, proc.returncode, err) except Exception as exc: - return f"[harvest_devin] warning: {exc}" + return EngineResult(f"[error] failed to run Devin harvest: {exc}", 1) + +def _append_adopt_args(cmd: list[str], args: dict) -> None: + """Append selection flags as argv tokens; never interpolate skill names.""" + staging = args.get("staging") + if staging: + cmd += ["--staging", str(staging)] -def _sync_skill(project: str) -> str: - """After adopt, copy the evolved skill into the workspace's .devin/skills/.""" - src = os.path.join(CLAUDE_HOME, "skills", MANAGED_SKILL_NAME, "SKILL.md") - if not (os.path.isfile(src) and project and os.path.isdir(project)): - return "" - dot_root = os.path.join(project, ".devin") - if not os.path.isdir(dot_root): - return "" - dst_dir = os.path.join(dot_root, "skills", MANAGED_SKILL_NAME) - os.makedirs(dst_dir, exist_ok=True) - dst = os.path.join(dst_dir, "SKILL.md") - shutil.copy2(src, dst) - return f"\n[sleep] synced evolved skill → {dst}" + skills = args.get("skills") or [] + for skill in skills: + if skill.startswith("-"): + cmd.append(f"--skill={skill}") + else: + cmd += ["--skill", skill] + if args.get("all_skills"): + cmd.append("--all-skills") + if args.get("legacy"): + cmd.append("--legacy") -def _run_engine(action: str, args: dict) -> str: - harvest_out = _run_harvest() if action in _HARVEST_ACTIONS else "" +def _run_engine(action: str, args: object) -> EngineResult: + args = _validate_tool_arguments(action, args) + harvest = _run_harvest() if action in _HARVEST_ACTIONS else EngineResult("", 0) + if harvest.returncode != 0: + text = harvest.text + if harvest.diagnostics: + text += ("\n[harvest stderr]\n" if text else "") + harvest.diagnostics + return EngineResult(text, harvest.returncode) py = sys.executable or "python3" cmd = [py, "-m", "skillopt_sleep", action, "--claude-home", CLAUDE_HOME] @@ -165,6 +276,8 @@ def _run_engine(action: str, args: dict) -> str: ]: if args.get(key): cmd.append(flag) + if action == "adopt": + _append_adopt_args(cmd, args) env = dict(os.environ) env["PYTHONPATH"] = REPO_ROOT + os.pathsep + env.get("PYTHONPATH", "") @@ -172,15 +285,19 @@ def _run_engine(action: str, args: dict) -> str: proc = subprocess.run(cmd, cwd=REPO_ROOT, capture_output=True, text=True, timeout=3600, env=env) except Exception as e: - return f"[harvest]\n{harvest_out}\n[error] failed to run engine: {e}" + return EngineResult(f"[error] failed to run engine: {e}", 1, harvest.text) out = (proc.stdout or "").strip() err = (proc.stderr or "").strip() - result = (f"[harvest]\n{harvest_out}\n\n" if harvest_out else "") + f"[engine]\n{out}" + diagnostics = "\n".join(part for part in (harvest.text, harvest.diagnostics, err) if part) + if args.get("json"): + text = out if out or proc.returncode in {0, 3} else err + return EngineResult(text, proc.returncode, diagnostics) + result = (f"[harvest]\n{harvest.text}\n\n" if harvest.text else "") + f"[engine]\n{out}" + if harvest.diagnostics: + result += f"\n[harvest stderr]\n{harvest.diagnostics}" if err: result += f"\n[stderr]\n{err}" - if action == "adopt": - result += _sync_skill(args.get("project") or os.getcwd()) - return result + return EngineResult(result, proc.returncode) def _result(id_, result): @@ -191,9 +308,60 @@ def _error(id_, code, message): return {"jsonrpc": "2.0", "id": id_, "error": {"code": code, "message": message}} -def handle(req: dict): +def _validate_request(req: object) -> tuple[str, object, dict]: + if type(req) is not dict: + raise ValueError("request must be a JSON object") + unknown = sorted(set(req) - {"jsonrpc", "id", "method", "params"}) + if unknown: + raise ValueError(f"unknown request member(s): {', '.join(unknown)}") + if req.get("jsonrpc") != "2.0": + raise ValueError("jsonrpc must be '2.0'") method = req.get("method") - id_ = req.get("id") + if type(method) is not str or not method: + raise ValueError("method must be a non-empty string") + params = req.get("params", {}) + if type(params) is not dict: + raise ValueError("params must be an object") + request_id = req.get("id") + if "id" in req and request_id is not None and type(request_id) not in {str, int}: + raise ValueError("id must be a string, integer, or null") + return method, request_id, params + + +def _validate_method_params(method: str, params: dict) -> None: + allowed_by_method = { + "initialize": {"protocolVersion", "capabilities", "clientInfo", "_meta"}, + "notifications/initialized": {"_meta"}, + "initialized": {"_meta"}, + "tools/list": {"cursor", "_meta"}, + "tools/call": {"name", "arguments", "_meta"}, + "ping": {"_meta"}, + } + allowed = allowed_by_method.get(method) + if allowed is None: + return + unknown = sorted(set(params) - allowed) + if unknown: + raise ValueError(f"unknown params member(s): {', '.join(unknown)}") + for key in ("capabilities", "clientInfo", "_meta"): + if key in params and type(params[key]) is not dict: + raise ValueError(f"{key} must be an object") + for key in ("protocolVersion", "cursor"): + if key in params and type(params[key]) is not str: + raise ValueError(f"{key} must be a string") + + +def handle(req: object): + try: + method, id_, params = _validate_request(req) + except ValueError as exc: + candidate = req.get("id") if type(req) is dict else None + request_id = candidate if candidate is None or type(candidate) in {str, int} else None + return _error(request_id, -32600, f"invalid request: {exc}") + try: + _validate_method_params(method, params) + except ValueError as exc: + return _error(id_, -32602, f"invalid params: {exc}") if method == "initialize": return _result(id_, { "protocolVersion": PROTOCOL_VERSION, @@ -208,13 +376,34 @@ def handle(req: dict): for t in TOOLS ]}) if method == "tools/call": - params = req.get("params") or {} name = params.get("name") + if type(name) is not str: + return _error(id_, -32602, "tool name must be a string") tool = _BY_NAME.get(name) if not tool: return _error(id_, -32602, f"unknown tool: {name}") - text = _run_engine(tool["action"], params.get("arguments") or {}) - return _result(id_, {"content": [{"type": "text", "text": text}]}) + arguments = params.get("arguments", {}) + try: + run = _run_engine(tool["action"], arguments) + except ValueError as exc: + return _error(id_, -32602, f"invalid {name} arguments: {exc}") + status = "handoff_pending" if run.returncode == 3 else ( + "ok" if run.returncode == 0 else "error" + ) + structured = {"status": status, "exit_code": run.returncode} + if run.diagnostics: + structured["diagnostics"] = run.diagnostics + if type(arguments) is dict and arguments.get("json") is True and run.text: + try: + structured["output"] = json.loads(run.text) + except json.JSONDecodeError: + pass + result = { + "content": [{"type": "text", "text": run.text}], + "structuredContent": structured, + "isError": run.returncode not in {0, 3}, + } + return _result(id_, result) if method == "ping": return _result(id_, {}) return _error(id_, -32601, f"method not found: {method}") @@ -227,9 +416,10 @@ def main() -> int: continue try: req = json.loads(line) - except Exception: - continue - resp = handle(req) + except json.JSONDecodeError: + resp = _error(None, -32700, "parse error") + else: + resp = handle(req) if resp is not None: sys.stdout.write(json.dumps(resp) + "\n") sys.stdout.flush() diff --git a/skillopt_sleep/__main__.py b/skillopt_sleep/__main__.py index 237e5601..bd853486 100644 --- a/skillopt_sleep/__main__.py +++ b/skillopt_sleep/__main__.py @@ -34,10 +34,18 @@ from skillopt_sleep.backend import CursorBackendError from skillopt_sleep.config import load_config -from skillopt_sleep.cycle import run_sleep_cycle +from skillopt_sleep.cycle import _one_line_display_text, run_sleep_cycle from skillopt_sleep.harvest_sources import harvest_for_config from skillopt_sleep.mine import mine -from skillopt_sleep.staging import StagingError, adopt_skills, json_safe, latest_staging, staged_skills +from skillopt_sleep.staging import ( + StagingError, + adopt_skills, + has_pending_staged_managed, + json_safe, + latest_staging, + pending_staged_skills, + staged_skills, +) from skillopt_sleep.staging import adopt as adopt_staging from skillopt_sleep.state import SleepState from skillopt_sleep.tasks_file import load_tasks_file, make_tasks_payload, write_tasks_file @@ -52,6 +60,15 @@ def _read_text(path: str) -> str: def _report_payload(rep, outcome) -> Dict[str, Any]: + staged_names = [] + if outcome.staging_dir: + try: + staged_names = [ + row.get("skill_name", "") + for row in pending_staged_skills(outcome.staging_dir) + ] + except Exception: + staged_names = [] return json_safe({ "night": rep.night, "accepted": rep.accepted, @@ -67,8 +84,12 @@ def _report_payload(rep, outcome) -> Dict[str, Any]: "rejected_edits": [e.__dict__ for e in rep.rejected_edits], "gate_no_regression": bool(getattr(rep, "gate_no_regression", False)), "gate_trials": _redact_deep(getattr(rep, "gate_trials", [])), + "skill_groups": [ + group.to_dict() for group in getattr(rep, "skill_groups", []) + ], "notes": rep.notes, "staging_dir": outcome.staging_dir, + "staged_skills": staged_names, "adopted": outcome.adopted, }) @@ -106,6 +127,13 @@ def _add_common(p: argparse.ArgumentParser) -> None: help="cap mined tasks for this run") p.add_argument("--target-skill-path", default="", help="explicit live SKILL.md path to evolve/stage/adopt") + p.add_argument( + "--skill-root", + dest="skill_roots", + action="append", + default=[], + help="additional root containing /SKILL.md (repeatable)", + ) p.add_argument("--tasks-file", default="", help="reviewed TaskRecord JSON file to replay instead of harvesting") p.add_argument("--progress", action="store_true", @@ -178,6 +206,16 @@ def _cfg_from_args(args, task_meta: Dict[str, Any] | None = None) -> Any: if args.project and not os.path.isabs(path): path = os.path.join(os.path.abspath(args.project), path) overrides["target_skill_path"] = os.path.abspath(path) + if getattr(args, "skill_roots", None): + project = os.path.abspath(args.project) if args.project else os.getcwd() + overrides["skill_roots"] = [ + os.path.abspath( + os.path.join(project, os.path.expanduser(root)) + if not os.path.isabs(os.path.expanduser(root)) + else os.path.expanduser(root) + ) + for root in args.skill_roots + ] if getattr(args, "progress", False): overrides["progress"] = True if getattr(args, "auto_adopt", False): @@ -205,12 +243,21 @@ def cmd_run(args, dry: bool = False) -> int: file=sys.stderr, ) return 2 - if cfg.get("backend", "mock") == "handoff": - return _run_handoff(cfg, args, seed_tasks=tasks, task_meta=task_meta, dry=dry) try: + if cfg.get("backend", "mock") == "handoff": + return _run_handoff( + cfg, + args, + seed_tasks=tasks, + task_meta=task_meta, + dry=dry, + ) outcome = run_sleep_cycle(cfg, seed_tasks=tasks, dry_run=dry) except CursorBackendError as exc: - print(f"[sleep] Cursor backend failed: {_redact_deep(str(exc))}", file=sys.stderr) + _print_run_failure(args, "backend_failed", exc) + return 1 + except StagingError as exc: + _print_run_failure(args, "staging_refused", exc) return 1 _print_run_report(outcome, args, task_meta) return 0 @@ -229,35 +276,46 @@ def _print_run_report(outcome, args, task_meta: Dict[str, Any]) -> None: print(f"[sleep] held-out {rep.baseline_score:.3f} -> {rep.candidate_score:.3f} " f"=> {rep.gate_action} (accepted={rep.accepted})") for e in rep.edits: - print(f" + [{e.target}/{e.op}] {e.content}") + print( + f" + [{_display_value(e.target)}/{_display_value(e.op)}] " + f"{_display_value(e.content)}" + ) if rep.rejected_edits: print("[sleep] rejected by gate:") for e in rep.rejected_edits: - print(f" - [{e.target}/{e.op}] {e.content}") + print( + f" - [{_display_value(e.target)}/{_display_value(e.op)}] " + f"{_display_value(e.content)}" + ) if outcome.staging_dir: - print(f"[sleep] staged: {outcome.staging_dir}") - if not outcome.adopted: + print(f"[sleep] staged: {_display_value(outcome.staging_dir)}") + names = [] + try: + names = [ + r["skill_name"] + for r in pending_staged_skills(outcome.staging_dir) + ] + except Exception: names = [] - try: - names = [r["skill_name"] for r in staged_skills(outcome.staging_dir)] - except Exception: - names = [] - if names: - print("[sleep] review it, then adopt a subset:") - print("[sleep] staged skills:") - for name in names: - print(f" - {name!r}") - # Names are safe path segments but may still contain spaces - # or shell metacharacters. Keep untrusted names out of a - # copy/paste command instead of pretending one quoting - # convention works in every supported shell. - print(" python -m skillopt_sleep adopt --skill NAME") - print(" (repeat --skill NAME to adopt more than one)") - print(" python -m skillopt_sleep adopt --all-skills") - else: - print("[sleep] review it, then: python -m skillopt_sleep adopt") + if names: + print("[sleep] review the pending per-skill proposals:") + print("[sleep] staged skills:") + for name in names: + print(f" - {name!r}") + # Names are safe path segments but may still contain spaces + # or shell metacharacters. Keep untrusted names out of a + # copy/paste command instead of pretending one quoting + # convention works in every supported shell. + print(" python -m skillopt_sleep adopt --skill NAME") + print(" (repeat --skill NAME to adopt more than one)") + print(" python -m skillopt_sleep adopt --all-skills") + if has_pending_staged_managed(outcome.staging_dir): + print(" python -m skillopt_sleep adopt --legacy") + elif not outcome.adopted: + print("[sleep] review it, then: python -m skillopt_sleep adopt") if outcome.adopted: - print(f"[sleep] auto-adopted: {', '.join(outcome.adopted_paths)}") + adopted = ", ".join(_display_value(path) for path in outcome.adopted_paths) + print(f"[sleep] auto-adopted: {adopted}") def _handoff_dir_for(cfg) -> str: @@ -279,6 +337,29 @@ def _redact_deep(obj): return obj +def _display_error(exc: object) -> str: + """Render an exception without leaking secrets or terminal controls.""" + return _display_value(exc) + + +def _display_value(value: object) -> str: + """Render arbitrary untrusted text safely for a human terminal.""" + return _one_line_display_text(_redact_deep(str(value))) + + +def _print_run_failure(args, kind: str, exc: object) -> None: + """Keep run failures machine-readable under ``--json``.""" + message = _display_error(exc) + if args.json: + print(json.dumps({ + "ok": False, + "error": kind, + "message": message, + }, ensure_ascii=False, indent=2)) + else: + print(f"[sleep] {kind.replace('_', ' ')}: {message}", file=sys.stderr) + + def _flush_handoff(backend, args) -> int: prompts_path = backend.flush_pending() if args.json: @@ -375,7 +456,10 @@ def _handoff_mine_and_pin(cfg, args, backend, snapshot: str, dry: bool): # LLM mining needs answers before the task set can be pinned. return _flush_handoff(backend, args), None if not tasks: - print("[sleep] handoff: no tasks mined — nothing to consolidate") + print( + "[sleep] handoff: no tasks mined — nothing to consolidate", + file=sys.stderr if args.json else sys.stdout, + ) if not dry: # Advance the harvest window like run_sleep_cycle's no-tasks # branch, or every later run re-scans the same stale window. @@ -393,7 +477,10 @@ def _handoff_mine_and_pin(cfg, args, backend, snapshot: str, dry: bool): # with a real backend must still hit the human-review gate above. The # driver itself loads it directly, with the same trust as in-cycle mining. write_tasks_file(snapshot, _redact_deep(payload)) - print(f"[sleep] handoff: pinned {len(tasks)} tasks -> {snapshot}") + print( + f"[sleep] handoff: pinned {len(tasks)} tasks -> {snapshot}", + file=sys.stderr if args.json else sys.stdout, + ) return 0, tasks @@ -429,7 +516,6 @@ def _run_handoff(cfg, args, *, seed_tasks, task_meta: Dict[str, Any], dry: bool) pass if backend.pending: return _flush_handoff(backend, args) - _print_run_report(outcome, args, task_meta) # A completed real run ends the night: archive the handoff dir so the # next night re-harvests instead of replaying the pinned snapshot. if not dry and outcome.staging_dir and os.path.isdir(hdir): @@ -437,8 +523,18 @@ def _run_handoff(cfg, args, *, seed_tasks, task_meta: Dict[str, Any], dry: bool) done = f"{hdir}.night{outcome.report.night}.done" if os.path.exists(done): done = f"{done}.{int(time.time())}" - os.rename(hdir, done) - print(f"[sleep] handoff: archived round data -> {done}") + try: + os.rename(hdir, done) + except OSError as exc: + raise StagingError( + f"handoff completed but its round directory could not be archived; " + f"preserved at {hdir!r}: {type(exc).__name__}: {exc}" + ) from exc + print( + f"[sleep] handoff: archived round data -> {done}", + file=sys.stderr if args.json else sys.stdout, + ) + _print_run_report(outcome, args, task_meta) return 0 @@ -448,11 +544,19 @@ def cmd_status(args) -> int: project = cfg.get("invoked_project") or os.getcwd() latest = latest_staging(project) skills = [] + all_skills = [] + has_managed = False + staging_error = "" if latest: try: - skills = staged_skills(latest) - except Exception: + all_skills = staged_skills(latest) + skills = pending_staged_skills(latest) + has_managed = has_pending_staged_managed(latest) + except Exception as exc: + staging_error = _display_error(exc) + all_skills = [] skills = [] + has_managed = False info = { "night": state.night, "state_path": cfg.state_path, @@ -461,78 +565,210 @@ def cmd_status(args) -> int: "latest_staging": latest, "slow_memory_chars": len(state.slow_memory), "staged_skills": [r.get("skill_name", "") for r in skills], + "adopted_skills": [ + row.get("skill_name", "") + for row in all_skills + if row not in skills + ], + "has_managed_proposal": has_managed, } + if staging_error: + info["staging_error"] = staging_error if args.json: print(json.dumps(info, ensure_ascii=False, indent=2)) else: print(f"[sleep] nights so far: {state.night}") print(f"[sleep] project: {project}") if latest: - print(f"[sleep] latest staged proposal: {latest}") - if skills: - print("[sleep] staged skills:") + print(f"[sleep] latest staged proposal: {_display_value(latest)}") + if staging_error: + print(f"[sleep] cannot read latest staging manifest: {staging_error}") + elif skills: + print("[sleep] pending staged skills:") for row in skills: - print(f" {row.get('skill_name', '')} -> {row.get('live_skill_path', '')}") + print( + f" {_display_value(row.get('skill_name', ''))!r} -> " + f"{_display_value(row.get('live_skill_path', ''))!r}" + ) + adopted_names = [ + row.get("skill_name", "") + for row in all_skills + if row not in skills + ] + if adopted_names: + print("[sleep] already adopted from this night:") + for name in adopted_names: + print(f" {_display_value(name)!r}") + if has_managed: + print("[sleep] managed proposal available via --legacy") rp = os.path.join(latest, "report.md") - if os.path.exists(rp): - with open(rp) as f: - print("\n" + f.read()) + if ( + not staging_error + and os.path.isfile(rp) + and not os.path.islink(rp) + ): + with open(rp, encoding="utf-8") as f: + print( + "\n" + "\n".join( + _display_value(line) for line in f.read().splitlines() + ) + ) else: print("[sleep] no staged proposals yet.") - return 0 + return 1 if staging_error else 0 def cmd_adopt(args) -> int: cfg = _cfg_from_args(args) project = cfg.get("invoked_project") or os.getcwd() target = args.staging or latest_staging(project) + + def fail(code: int, kind: str, message: str, **extra: Any) -> int: + safe_message = _display_value(message) + if args.json: + payload = { + "ok": False, + "error": kind, + "message": safe_message, + "staging_dir": _display_value(target or ""), + } + payload.update(_redact_deep(extra)) + print(json.dumps(payload, ensure_ascii=False, indent=2)) + else: + print(safe_message) + return code + if not target or not os.path.isdir(target): - print("[sleep] nothing to adopt (no staging dir).") - return 1 + return fail(1, "no_staging", "[sleep] nothing to adopt (no staging dir).") raw_selected = list(getattr(args, "skills", None) or []) if any(not str(name).strip() for name in raw_selected): - print("[sleep] --skill names must be non-empty.") - return 2 + return fail( + 2, + "invalid_selection", + "[sleep] --skill names must be non-empty.", + ) selected = [str(name).strip() for name in raw_selected] adopt_all = bool(getattr(args, "all_skills", False)) - if selected and adopt_all: - print("[sleep] use --skill or --all-skills, not both.") - return 2 + adopt_legacy = bool(getattr(args, "legacy", False)) + if sum((bool(selected), adopt_all, adopt_legacy)) > 1: + return fail( + 2, + "invalid_selection", + "[sleep] use exactly one of --skill, --all-skills, or --legacy.", + ) try: rows = staged_skills(target) + pending_rows = pending_staged_skills(target) except Exception as exc: - print(f"[sleep] cannot read staged skills: {exc}") - return 1 + return fail( + 1, + "invalid_staging", + f"[sleep] cannot read staged skills: {exc}", + ) + if adopt_legacy: + try: + updated = adopt_staging(target) + except StagingError as exc: + return fail(2, "adoption_refused", f"[sleep] adopt refused: {exc}") + except (OSError, ValueError, KeyError, json.JSONDecodeError) as exc: + return fail(1, "adoption_failed", f"[sleep] adopt failed: {exc}") + if args.json: + print(json.dumps({ + "ok": True, + "staging_dir": target, + "mode": "legacy", + "adopted_skills": [], + "updated_paths": updated, + }, ensure_ascii=False, indent=2)) + else: + print(f"[sleep] adopted managed proposal from {_display_value(target)}") + for path in updated: + print(f" -> {_display_value(path)}") + if not updated: + print("[sleep] (proposal contained no accepted managed changes)") + return 0 if selected or adopt_all: if not rows: - print("[sleep] this night has no per-skill proposals; omit --skill to adopt the legacy pair.") - return 2 - names = None if adopt_all else selected + return fail( + 2, + "no_staged_skills", + "[sleep] this night has no per-skill proposals; omit --skill " + "to adopt the legacy pair.", + ) + names = ( + [str(row.get("skill_name", "")) for row in pending_rows] + if adopt_all + else selected + ) try: receipts = adopt_skills(target, names) except StagingError as exc: - print(f"[sleep] adopt refused: {exc}") - return 2 + return fail(2, "adoption_refused", f"[sleep] adopt refused: {exc}") except OSError as exc: - print(f"[sleep] adopt failed: {exc}") - return 1 - print(f"[sleep] adopted from {target}") - for receipt in receipts: - print(f" -> {receipt.skill_name}: {receipt.live_skill_path}") - if not receipts: - print("[sleep] (no skills in the selection)") + return fail(1, "adoption_failed", f"[sleep] adopt failed: {exc}") + if args.json: + print(json.dumps(json_safe({ + "ok": True, + "staging_dir": target, + "mode": "skills", + "adopted_skills": [receipt.__dict__ for receipt in receipts], + "updated_paths": [receipt.live_skill_path for receipt in receipts], + }), ensure_ascii=False, indent=2)) + else: + print(f"[sleep] adopted from {_display_value(target)}") + for receipt in receipts: + print( + f" -> {_display_value(receipt.skill_name)}: " + f"{_display_value(receipt.live_skill_path)}" + ) + if not receipts: + print("[sleep] (no skills in the selection)") return 0 if rows: - print("[sleep] this night staged per-skill proposals; pass --skill NAME or --all-skills.") + message = ( + "[sleep] this night staged per-skill proposals; pass --skill NAME " + "or --all-skills, or use --legacy for its managed proposal." + ) + if args.json: + return fail( + 2, + "selection_required", + message, + available_skills=[ + { + "skill_name": row.get("skill_name", ""), + "live_skill_path": row.get("live_skill_path", ""), + } + for row in rows + ], + ) + print(_display_value(message)) for row in rows: - print(f" {row.get('skill_name', '')} -> {row.get('live_skill_path', '')}") + print( + f" {_display_value(row.get('skill_name', ''))!r} -> " + f"{_display_value(row.get('live_skill_path', ''))!r}" + ) return 2 - updated = adopt_staging(target) - print(f"[sleep] adopted from {target}") - for p in updated: - print(f" -> {p}") - if not updated: - print("[sleep] (proposal contained no accepted changes)") + try: + updated = adopt_staging(target) + except StagingError as exc: + return fail(2, "adoption_refused", f"[sleep] adopt refused: {exc}") + except (OSError, ValueError, KeyError, json.JSONDecodeError) as exc: + return fail(1, "adoption_failed", f"[sleep] adopt failed: {exc}") + if args.json: + print(json.dumps({ + "ok": True, + "staging_dir": target, + "mode": "legacy", + "adopted_skills": [], + "updated_paths": updated, + }, ensure_ascii=False, indent=2)) + else: + print(f"[sleep] adopted from {_display_value(target)}") + for path in updated: + print(f" -> {_display_value(path)}") + if not updated: + print("[sleep] (proposal contained no accepted changes)") return 0 @@ -586,12 +822,12 @@ def cmd_schedule(args) -> int: ok, msg = schedule(project, backend=cfg.get("backend", "mock"), hour=args.hour, minute=args.minute, extra=("--auto-adopt" if getattr(args, "auto_adopt", False) else "")) - print("[sleep] " + msg) + print("[sleep] " + _display_value(msg)) cur = list_scheduled() if cur: print("[sleep] currently scheduled:") for ln in cur: - print(" " + ln[:140]) + print(" " + _display_value(ln[:140])) return 0 if ok else 1 @@ -600,7 +836,7 @@ def cmd_unschedule(args) -> int: cfg = _cfg_from_args(args) project = cfg.get("invoked_project") or os.getcwd() ok, msg = unschedule(project, all_projects=getattr(args, "all", False)) - print("[sleep] " + msg) + print("[sleep] " + _display_value(msg)) return 0 if ok else 1 @@ -625,6 +861,10 @@ def main(argv=None) -> int: "--all-skills", action="store_true", dest="all_skills", help="adopt every staged per-skill proposal", ) + p_adopt.add_argument( + "--legacy", action="store_true", + help="adopt only the staged managed skill/memory proposal", + ) p_harvest = sub.add_parser("harvest", help="debug: show mined tasks") _add_common(p_harvest) p_harvest.add_argument("--output", default="", help="write mined tasks JSON for review") diff --git a/skillopt_sleep/backend.py b/skillopt_sleep/backend.py index f0894663..44ecec71 100644 --- a/skillopt_sleep/backend.py +++ b/skillopt_sleep/backend.py @@ -1577,8 +1577,13 @@ def _parse_jsonl_response(raw: str) -> str: obj = json.loads(line) except (ValueError, RecursionError, TypeError): continue + if not isinstance(obj, dict): + continue if obj.get("type") == "assistant.message": - content = (obj.get("data") or {}).get("content") + data = obj.get("data") + if not isinstance(data, dict): + continue + content = data.get("content") if isinstance(content, str) and content: parts.append(content) return "\n".join(parts).strip() diff --git a/skillopt_sleep/config.py b/skillopt_sleep/config.py index 6aa5a716..eba5855c 100644 --- a/skillopt_sleep/config.py +++ b/skillopt_sleep/config.py @@ -74,12 +74,16 @@ "evolve_skill": True, # consolidate the managed SKILL.md "llm_mine": True, # use the backend to mine checkable tasks (real backends) "target_skill_path": "", # explicit SKILL.md target for repo-scoped agents + "skill_roots": [], # extra explicit roots containing /SKILL.md "target_task_filter": True, # prefer mined tasks matching target_skill_path/text "progress": False, # print phase progress to stderr # ── observability ────────────────────────────────────────────────────── "evidence_log": True, # write per-night evidence.jsonl (full evidentiary chain) "evidence_max_chars": 4000, # per-field truncation cap for evidence events - "multi_skill_report": False, # extra consolidation/report row per routed skill group + # ``multi_skill_report`` is the compatibility alias used before fan-out + # began staging independently adoptable proposals. + "multi_skill_fanout": None, + "multi_skill_report": False, # ── adoption / safety ────────────────────────────────────────────────── "auto_adopt": False, # default: stage + require explicit `adopt` "managed_skill_name": "skillopt-sleep-learned", diff --git a/skillopt_sleep/cycle.py b/skillopt_sleep/cycle.py index ac8aff8e..67f82fa6 100644 --- a/skillopt_sleep/cycle.py +++ b/skillopt_sleep/cycle.py @@ -9,12 +9,15 @@ """ from __future__ import annotations +import hashlib import math import os +import re import shutil import sys +import unicodedata from dataclasses import dataclass -from typing import List, Optional +from typing import List, Optional, Sequence from skillopt_sleep import evidence from skillopt_sleep.backend import Backend, CursorBackendError, build_backend @@ -45,6 +48,10 @@ from skillopt_sleep.state import SleepState, _now_iso from skillopt_sleep.types import SessionDigest, SleepReport, TaskRecord +_ANSI_ESCAPE_RE = re.compile( + r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)?|[@-_])" +) + # ── Model-swap detection (F16) ─────────────────────────────── def _make_model_key(cfg: SleepConfig) -> str: @@ -158,6 +165,33 @@ def _read(path: str) -> str: return "" +def _read_live_baseline(path: str, label: str) -> tuple[str, str, str]: + """Read one live document once and pin the exact bytes and target identity. + + A missing file is a valid empty baseline. Other I/O failures and invalid + UTF-8 are not: silently treating either as an empty document could derive a + proposal from a scaffold and later overwrite data the cycle never read. + """ + realpath = os.path.realpath(os.path.abspath(path)) + try: + with open(path, "rb") as handle: + raw = handle.read() + except FileNotFoundError: + return "", "", realpath + except OSError as exc: + raise StagingError( + f"could not read live {label} baseline {path!r}: " + f"{type(exc).__name__}: {exc}" + ) from exc + try: + text = raw.decode("utf-8") + except UnicodeError as exc: + raise StagingError( + f"live {label} baseline is not valid UTF-8: {path!r}" + ) from exc + return text, hashlib.sha256(raw).hexdigest(), realpath + + def _progress(cfg: SleepConfig, message: str) -> None: if cfg.get("progress", False): print(f"[sleep] {message}", file=sys.stderr, flush=True) @@ -177,9 +211,27 @@ def _discard_unstaged_evidence(path: str) -> None: break +def _multi_skill_fanout_enabled(cfg: SleepConfig) -> bool: + """Prefer the behavior-named flag while preserving the original alias.""" + explicit = cfg.get("multi_skill_fanout") + if explicit is not None: + return bool(explicit) + return bool(cfg.get("multi_skill_report", False)) + + +def _one_line_display_text(value: object) -> str: + """Remove terminal controls and fold untrusted text onto one line.""" + without_ansi = _ANSI_ESCAPE_RE.sub("", str(value)) + without_controls = "".join( + " " if unicodedata.category(ch) in {"Cc", "Cf"} else ch + for ch in without_ansi + ) + return " ".join(without_controls.split()) + + def _markdown_table_text(value: object) -> str: """Keep untrusted evidence text inside one readable Markdown table cell.""" - text = " ".join(str(value).splitlines()) + text = _one_line_display_text(value) return ( text.replace("&", "&") .replace("<", "<") @@ -189,6 +241,14 @@ def _markdown_table_text(value: object) -> str: ) +def _markdown_text(value: object) -> str: + """Render untrusted text literally in ordinary Markdown prose.""" + text = _markdown_table_text(value) + for token in ("\\", "*", "_", "[", "]", "(", ")", "#", "+", "!", "{"): + text = text.replace(token, "\\" + token) + return text.replace("}", "\\}") + + def _report_score(value: object) -> str: """Render an optional numeric score without breaking the report.""" if value is None: @@ -201,16 +261,20 @@ def _report_score(value: object) -> str: def _render_report_md(report: SleepReport, cfg: SleepConfig) -> str: + project = _markdown_text(report.project) + backend = _markdown_text(cfg.get("backend")) + replay = _markdown_text(cfg.get("replay_mode")) + gate_action = _markdown_text(report.gate_action) lines = [ f"# SkillOpt-Sleep — night {report.night} report", "", - f"- project: `{report.project}`", - f"- backend: `{cfg.get('backend')}` replay: `{cfg.get('replay_mode')}`", + f"- project: `{project}`", + f"- backend: `{backend}` replay: `{replay}`", f"- sessions harvested: {report.n_sessions}", f"- tasks mined: {report.n_tasks} (replayed: {report.n_replayed})", f"- held-out score: {_report_score(report.baseline_score)} " f"-> {_report_score(report.candidate_score)}", - f"- gate: **{report.gate_action}** (accepted={report.accepted})", + f"- gate: **{gate_action}** (accepted={bool(report.accepted)})", f"- no-regression gate: " f"{'enabled' if cfg.get('gate_no_regression', False) else 'disabled'}", f"- tokens used: {report.tokens_used}", @@ -268,7 +332,13 @@ def _render_report_md(report: SleepReport, cfg: SleepConfig) -> str: if report.edits: lines.append("## Accepted edits") for e in report.edits: - lines.append(f"- [{e.target}/{e.op}] {e.content} \n _why: {e.rationale}_") + target = _markdown_text(e.target) + op = _markdown_text(e.op) + content = _markdown_text(e.content) + rationale = _markdown_text(e.rationale) + lines.append( + f"- \\[{target}/{op}\\] {content} \n _why: {rationale}_" + ) lines.append("") if report.rejected_edits: # On a leaked-holdout night the gate abstained rather than rejecting, so @@ -278,7 +348,10 @@ def _render_report_md(report: SleepReport, cfg: SleepConfig) -> str: else: lines.append("## Rejected by gate (kept as negative feedback)") for e in report.rejected_edits: - lines.append(f"- [{e.target}/{e.op}] {e.content}") + target = _markdown_text(e.target) + op = _markdown_text(e.op) + content = _markdown_text(e.content) + lines.append(f"- \\[{target}/{op}\\] {content}") lines.append("") if report.unmatched_edits: lines.append("## Proposed but changed nothing (never reached the gate)") @@ -287,8 +360,15 @@ def _render_report_md(report: SleepReport, cfg: SleepConfig) -> str: "add, or an unknown op. " "These were never scored — check the anchor text if a rule you expected is missing._") for e in report.unmatched_edits: - anchor = f" \n _anchor: `{e.anchor}`_" if e.anchor else "" - lines.append(f"- [{e.target}/{e.op}] {e.content}{anchor}") + target = _markdown_text(e.target) + op = _markdown_text(e.op) + content = _markdown_text(e.content) + anchor = ( + f" \n _anchor: `{_markdown_text(e.anchor)}`_" + if e.anchor + else "" + ) + lines.append(f"- \\[{target}/{op}\\] {content}{anchor}") lines.append("") if report.skill_groups: # The reviewer decides per skill, so the per-skill verdicts belong on @@ -323,23 +403,29 @@ def _render_report_md(report: SleepReport, cfg: SleepConfig) -> str: scores = "—" edits = "—" reason = f" — {_markdown_table_text(g.reason)}" if g.reason else "" + group_gate = _markdown_table_text(g.gate_action or "—") lines.append( - f"| `{name}` | **{decision}**{reason} | {g.gate_action or '—'} " + f"| `{name}` | **{decision}**{reason} | {group_gate} " f"| {g.n_tasks} | {scores} | {edits} |") lines.append("") if report.notes: lines.append("## Notes") for n in report.notes: - lines.append(f"- {n}") + lines.append(f"- {_markdown_text(n)}") lines.append("") - lines.append("_Review, then run `/sleep adopt` to apply, or discard this folder._") + lines.append( + "_Review the staged artifacts, then use the adoption mode printed by " + "the run command (`--skill` / `--all-skills` for fan-out proposals, " + "`--legacy` for the managed skill or memory), or discard this folder._" + ) return "\n".join(lines) def _cycle_skip_note(name: str, reason: str) -> str: """One-line skip reason for report.notes. Names are untrusted free text.""" label = str(name or "").strip() or "" - return redact_secrets(f"cycle skipped skill {label}: {reason}") + redacted = str(redact_secrets(f"cycle skipped skill {label}: {reason}")) + return _one_line_display_text(redacted) def _skill_groups_from_live_baselines( @@ -351,6 +437,7 @@ def _skill_groups_from_live_baselines( List[SkillGroup], dict[str, GroupConsolidation], dict[str, str], + dict[str, str], List[str], ]: """Load each hinted group's own live skill before consolidation. @@ -365,6 +452,7 @@ def _skill_groups_from_live_baselines( groups: List[SkillGroup] = [] skipped: dict[str, GroupConsolidation] = {} live_paths: dict[str, str] = {} + live_hashes: dict[str, str] = {} notes: List[str] = [] for raw_name, rows in grouped.items(): name = str(raw_name or "").strip() @@ -384,8 +472,9 @@ def _skill_groups_from_live_baselines( notes.append(_cycle_skip_note(name, reason)) continue try: - with open(resolution.path, encoding="utf-8") as handle: - live_skill = handle.read() + with open(resolution.path, "rb") as handle: + live_bytes = handle.read() + live_skill = live_bytes.decode("utf-8") except (OSError, UnicodeError) as exc: reason = f"could not read resolved SKILL.md ({type(exc).__name__})" skipped[name] = GroupConsolidation( @@ -399,7 +488,8 @@ def _skill_groups_from_live_baselines( groups.append(SkillGroup(name, live_skill, rows)) live_paths[name] = resolution.path - return groups, skipped, live_paths, notes + live_hashes[name] = hashlib.sha256(live_bytes).hexdigest() + return groups, skipped, live_paths, live_hashes, notes def _skill_proposals_from_groups( @@ -407,6 +497,8 @@ def _skill_proposals_from_groups( group_outcomes: dict, managed_name: str, resolved_paths: Optional[dict[str, str]] = None, + resolved_hashes: Optional[dict[str, str]] = None, + reserved_live_paths: Sequence[str] = (), ) -> tuple[List[SkillProposal], List[str]]: """Stage per-skill proposals for accepted groups whose names resolve uniquely. @@ -420,6 +512,11 @@ def _skill_proposals_from_groups( roots = skill_search_roots(cfg) proposals: List[SkillProposal] = [] notes: List[str] = [] + reserved_keys = { + unicodedata.normalize("NFC", os.path.realpath(path)).casefold() + for path in reserved_live_paths + if path + } for name, new_skill in accepted_group_skills(group_outcomes).items(): if name == managed_name: continue @@ -431,6 +528,14 @@ def _skill_proposals_from_groups( if not live_path: notes.append(_cycle_skip_note(name, "no resolved live baseline")) continue + live_sha256 = ( + resolved_hashes.get(name, "") + if resolved_hashes is not None + else None + ) + if resolved_hashes is not None and not live_sha256: + notes.append(_cycle_skip_note(name, "no hashed live baseline")) + continue else: resolution = resolve_skill(name, roots) if not resolution.ok: @@ -439,7 +544,25 @@ def _skill_proposals_from_groups( ) continue live_path = resolution.path - candidate = SkillProposal(name, new_skill, live_path) + live_sha256 = None + live_key = unicodedata.normalize( + "NFC", os.path.realpath(live_path) + ).casefold() + if live_key in reserved_keys: + notes.append( + _cycle_skip_note( + name, + "same live target as the managed skill proposal", + ) + ) + continue + candidate = SkillProposal( + name, + new_skill, + live_path, + live_sha256=live_sha256, + live_realpath=live_path, + ) try: skill_proposal_rows(proposals + [candidate]) except StagingError as exc: @@ -449,6 +572,19 @@ def _skill_proposals_from_groups( return proposals, notes +def _history_for_skill_group( + history_tasks: List[TaskRecord], + skill_name: str, + managed_name: str, +) -> List[TaskRecord]: + """Keep recalled evidence inside the same routed skill boundary.""" + return [ + task + for task in history_tasks + if (str(task.skill_hint or "").strip() or managed_name) == skill_name + ] + + def run_sleep_cycle( cfg: Optional[SleepConfig] = None, *, @@ -531,9 +667,17 @@ def run_sleep_cycle( live_memory_path = os.path.join(project, "CLAUDE.md") live_skill_path = cfg.managed_skill_path() _progress(cfg, f"live skill: {live_skill_path}") - raw_skill = _read(live_skill_path) + ( + raw_skill, + live_skill_sha256, + live_skill_realpath, + ) = _read_live_baseline(live_skill_path, "skill") skill = raw_skill - memory = _read(live_memory_path) + ( + memory, + live_memory_sha256, + live_memory_realpath, + ) = _read_live_baseline(live_memory_path, "memory") if not skill: skill = ensure_skill_scaffold( "", name=cfg.get("managed_skill_name", "skillopt-sleep-learned"), @@ -714,29 +858,50 @@ def run_sleep_cycle( # group_outcomes = {} group_live_paths: dict[str, str] = {} + group_live_hashes: dict[str, str] = {} managed_name = cfg.get("managed_skill_name", "skillopt-sleep-learned") - if cfg.get("multi_skill_report", False): + if _multi_skill_fanout_enabled(cfg): grouped = group_tasks_by_skill_hint(tasks, managed_name) only_catch_all = len(grouped) == 1 and managed_name in grouped if grouped and not only_catch_all: _progress(cfg, f"multi-skill report: groups={len(grouped)}") - live_groups, skipped_groups, group_live_paths, skip_notes = ( - _skill_groups_from_live_baselines( - cfg, grouped, managed_name, skill - ) - ) - report.notes.extend(skip_notes) - consolidated_groups = consolidate_groups( - backend, + ( live_groups, - memory, - edit_budget=cfg.get("edit_budget", 4), - gate_metric=cfg.get("gate_metric", "mixed"), - gate_mixed_weight=cfg.get("gate_mixed_weight", 0.5), - gate_no_regression=cfg.get("gate_no_regression", False), - gate_mode=cfg.get("gate_mode", "on"), - night=night, + skipped_groups, + group_live_paths, + group_live_hashes, + skip_notes, + ) = _skill_groups_from_live_baselines( + cfg, grouped, managed_name, skill ) + report.notes.extend(skip_notes) + try: + consolidated_groups = consolidate_groups( + backend, + live_groups, + memory, + consolidate_fn=dream_consolidate, + group_kwargs_fn=lambda group: { + "history_tasks": _history_for_skill_group( + history_tasks, + group.skill_name, + managed_name, + ) + }, + recall_k=recall_k, + dream_rollouts=int(cfg.get("dream_rollouts", 1) or 1), + dream_factor=int(cfg.get("dream_factor", 0) or 0), + edit_budget=cfg.get("edit_budget", 4), + gate_metric=cfg.get("gate_metric", "mixed"), + gate_mixed_weight=cfg.get("gate_mixed_weight", 0.5), + gate_no_regression=cfg.get("gate_no_regression", False), + gate_mode=cfg.get("gate_mode", "on"), + evolve_skill=cfg.get("evolve_skill", True), + night=night, + ) + except CursorBackendError: + _discard_unstaged_evidence(staging_dir_pre) + raise for raw_name in grouped: name = str(raw_name or "").strip() outcome = skipped_groups.get(name) or consolidated_groups.get(name) @@ -764,7 +929,12 @@ def run_sleep_cycle( proposed_skill = result.new_skill if (cfg.get("evolve_skill") and result.accepted) else None proposed_memory = result.new_memory if (cfg.get("evolve_memory") and result.accepted) else None skill_proposals, skip_notes = _skill_proposals_from_groups( - cfg, group_outcomes, managed_name, group_live_paths + cfg, + group_outcomes, + managed_name, + group_live_paths, + group_live_hashes, + [live_skill_path] if proposed_skill is not None else [], ) report.notes.extend(skip_notes) report_md = _render_report_md(report, cfg) @@ -775,6 +945,10 @@ def run_sleep_cycle( proposed_memory=proposed_memory, live_skill_path=live_skill_path, live_memory_path=live_memory_path, + live_skill_sha256=live_skill_sha256, + live_memory_sha256=live_memory_sha256, + live_skill_realpath=live_skill_realpath, + live_memory_realpath=live_memory_realpath, report_md=report_md, out_dir=staging_dir_pre, skill_proposals=skill_proposals, diff --git a/skillopt_sleep/multi_skill.py b/skillopt_sleep/multi_skill.py index a4a4bfc1..32247639 100644 --- a/skillopt_sleep/multi_skill.py +++ b/skillopt_sleep/multi_skill.py @@ -13,8 +13,9 @@ from dataclasses import dataclass, field from typing import Callable, Dict, List, Optional, Sequence -from skillopt_sleep.backend import Backend +from skillopt_sleep.backend import Backend, CursorBackendError from skillopt_sleep.consolidate import ConsolidationResult, consolidate +from skillopt_sleep.handoff_backend import PendingCalls from skillopt_sleep.types import SkillGroupReport, TaskRecord CONSOLIDATED = "consolidated" @@ -52,6 +53,7 @@ def consolidate_groups( memory: str = "", *, consolidate_fn: Callable[..., ConsolidationResult] = consolidate, + group_kwargs_fn: Optional[Callable[[SkillGroup], Dict[str, object]]] = None, **consolidate_kwargs: object, ) -> Dict[str, GroupConsolidation]: """Consolidate each group independently, in order, isolating failures. @@ -70,10 +72,15 @@ def consolidate_groups( ``memory`` is the shared agent memory and is passed through read-only: group runs evolve skills only, so no group can rewrite another group's memory. + ``group_kwargs_fn`` can add group-scoped inputs such as recalled history; + its ordinary failures are isolated to that group. ``PendingCalls`` and + ``CursorBackendError`` from either the factory or consolidator propagate as + cycle-level pause/fail-closed control flow rather than becoming report rows. """ # This wrapper's contract is stricter than consolidate(): shared memory is # always read-only. Override a caller-supplied value instead of passing a # duplicate keyword (which would otherwise turn the group into a failure). + consolidate_kwargs = dict(consolidate_kwargs) consolidate_kwargs["evolve_memory"] = False out: Dict[str, GroupConsolidation] = {} for group in groups: @@ -95,10 +102,20 @@ def consolidate_groups( ) continue try: + group_kwargs = dict(consolidate_kwargs) + if group_kwargs_fn is not None: + group_kwargs.update(group_kwargs_fn(group)) + # A per-group factory cannot weaken the shared-memory invariant. + group_kwargs["evolve_memory"] = False result = consolidate_fn( backend, list(group.tasks), group.skill, memory, - **consolidate_kwargs, + **group_kwargs, ) + except (PendingCalls, CursorBackendError): + # These exceptions are cycle-level control flow, not isolated + # evidence about one group. Swallowing them can advance/save an + # incomplete handoff night or weaken Cursor's fail-closed path. + raise except Exception as exc: # one group's failure must not abort the night out[name] = GroupConsolidation( name, FAILED, reason=f"{type(exc).__name__}: {exc}"[:300], diff --git a/skillopt_sleep/scheduler.py b/skillopt_sleep/scheduler.py index 8cb115ed..44dfa4b8 100644 --- a/skillopt_sleep/scheduler.py +++ b/skillopt_sleep/scheduler.py @@ -5,7 +5,9 @@ """ from __future__ import annotations +import hashlib import os +import shlex import shutil import subprocess import sys @@ -59,8 +61,8 @@ def _have_schtasks() -> bool: def _win_task_name(project: str) -> str: project = os.path.abspath(project) - safe = project.replace(":\\", "_").replace("\\", "_").replace("/", "_").replace(" ", "_") - return f"SkillOpt-Sleep-{safe}" + digest = hashlib.sha256(project.encode("utf-8")).hexdigest()[:20] + return f"SkillOpt-Sleep-{digest}" def _create_win_task(task_name: str, command: str, hour: int, minute: int) -> bool: @@ -102,26 +104,51 @@ def _list_win_tasks() -> List[str]: def _runner_cmd(project: str, backend: str, extra: str, python: str) -> str: + _validate_scheduler_text("project", project) + _validate_scheduler_text("python", python) + _validate_scheduler_text("repository", _repo_root()) + _validate_scheduler_text("backend", backend) + if extra: + _validate_scheduler_text("extra scheduler arguments", extra) logdir = os.path.join(project, ".skillopt-sleep") log = os.path.join(logdir, "cron.log") - # use absolute python + -m so cron's/scheduler's minimal env still works - cmd = (f'"{python}" -m skillopt_sleep run --project "{project}" ' - f'--scope invoked --backend {backend} {extra}'.rstrip()) + args = [ + python, + "-m", + "skillopt_sleep", + "run", + "--project", + project, + "--scope", + "invoked", + "--backend", + backend, + *shlex.split(extra), + ] if sys.platform == "win32": - helper_script = os.path.join(logdir, "run.cmd") + helper_script = os.path.join(logdir, "run.ps1") try: os.makedirs(logdir, exist_ok=True) + quoted = " ".join(_powershell_literal(value) for value in args[1:]) content = ( - "@echo off\n" - f'cd /d "{_repo_root()}"\n' - f'{cmd} >> "{log}" 2>&1\n' + "$ErrorActionPreference = 'Stop'\n" + f"Set-Location -LiteralPath {_powershell_literal(_repo_root())}\n" + f"& {_powershell_literal(python)} {quoted} " + f"*>> {_powershell_literal(log)}\n" ) with open(helper_script, "w", encoding="utf-8") as f: f.write(content) - except Exception: - pass - return f'"{helper_script}"' - return f'mkdir -p "{logdir}"; cd "{_repo_root()}" && {cmd} >> "{log}" 2>&1' + except OSError as exc: + raise ValueError(f"could not create the Windows scheduler helper: {exc}") from exc + return ( + "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass " + f"-File {_windows_command_quote(helper_script)}" + ) + cmd = shlex.join(args) + return ( + f"mkdir -p {shlex.quote(logdir)}; " + f"cd {shlex.quote(_repo_root())} && {cmd} >> {shlex.quote(log)} 2>&1" + ) def _repo_root() -> str: @@ -129,8 +156,31 @@ def _repo_root() -> str: return os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +def _validate_scheduler_text(label: str, value: str) -> None: + if not isinstance(value, str) or not value: + raise ValueError(f"{label} must be a non-empty string") + if any(ord(ch) < 32 or ord(ch) == 127 for ch in value): + raise ValueError(f"{label} cannot contain control characters") + + +def _powershell_literal(value: str) -> str: + """Single-quoted PowerShell data literal.""" + _validate_scheduler_text("scheduler argument", value) + return "'" + value.replace("'", "''") + "'" + + +def _windows_command_quote(value: str) -> str: + """Quote a Windows path whose allowed characters cannot include quotes.""" + _validate_scheduler_text("scheduler path", value) + if '"' in value: + raise ValueError("scheduler path cannot contain a double quote") + return f'"{value}"' + + def _project_marker(project: str) -> str: - return f"# project={os.path.abspath(project)}" + canonical = os.path.abspath(project) + digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest() + return f"# project-sha256={digest}" def schedule(project: str, *, backend: str = "mock", hour: int = 3, minute: int = 17, @@ -140,9 +190,16 @@ def schedule(project: str, *, backend: str = "mock", hour: int = 3, minute: int Returns (installed, message). If the scheduler backend is unavailable, installed=False and the message contains instructions to add manually. """ + if type(hour) is not int or not 0 <= hour <= 23: + return False, "hour must be an integer from 0 through 23" + if type(minute) is not int or not 0 <= minute <= 59: + return False, "minute must be an integer from 0 through 59" project = os.path.abspath(project) python = python or sys.executable or "python3" - runner_cmd = _runner_cmd(project, backend, extra, python) + try: + runner_cmd = _runner_cmd(project, backend, extra, python) + except (ValueError, OSError) as exc: + return False, f"Refusing unsafe scheduler configuration: {exc}" if sys.platform == "win32": if not _have_schtasks(): @@ -195,7 +252,7 @@ def unschedule(project: Optional[str] = None, *, all_projects: bool = False) -> ok = _delete_win_task(tn) try: logdir = os.path.join(project, ".skillopt-sleep") - helper = os.path.join(logdir, "run.cmd") + helper = os.path.join(logdir, "run.ps1") if os.path.exists(helper): os.remove(helper) except Exception: diff --git a/skillopt_sleep/skill_resolver.py b/skillopt_sleep/skill_resolver.py index 318664da..e9cedd32 100644 --- a/skillopt_sleep/skill_resolver.py +++ b/skillopt_sleep/skill_resolver.py @@ -118,32 +118,72 @@ def _plugin_skills_root(plugin_dir: str) -> str: def skill_search_roots(cfg: object) -> List[str]: - """Documented local skill roots for a config: user skills, then plugin cache. + """Return every configured native skill root in deterministic order. - ``/skills`` holds hand-written skills. Installed Claude Code - plugins expose theirs under the plugin cache, in either the versioned - marketplace layout ``plugins/cache////skills`` - or the legacy ``plugins/cache///skills``. At most one - root per installed plugin is returned, in that fixed precedence order. + The resolver supports project-native Claude/Codex/Cursor/Devin layouts, + configured user homes, explicit extra roots, and installed Claude plugins. + Returning all matches is intentional: ``resolve_skill`` refuses ambiguity + instead of silently choosing one agent's file over another. """ - configured = str(getattr(cfg, "claude_home", "") or "").strip() - if not configured: - # Guard before abspath: os.path.abspath("") is the CWD, which would - # silently search a tree well outside the documented ~/.claude root. - return [] - claude_home = os.path.abspath(os.path.expanduser(configured)) - roots = [os.path.join(claude_home, "skills")] + roots: List[str] = [] + + invoked_project = str(getattr(cfg, "invoked_project", "") or "").strip() + if invoked_project: + project = os.path.abspath(os.path.expanduser(invoked_project)) + roots.extend( + os.path.join(project, relative) + for relative in ( + os.path.join(".agents", "skills"), + os.path.join(".claude", "skills"), + os.path.join(".cursor", "skills"), + os.path.join(".devin", "skills"), + ) + ) - cache = os.path.join(claude_home, "plugins", "cache") - for marketplace in _listdir(cache): - plugins_dir = os.path.join(cache, marketplace) - if not os.path.isdir(plugins_dir): + # Keep the established user-level Claude root. Other agents' user-level + # layouts are not sufficiently standardized; callers can add them through + # ``skill_roots`` without silently creating cross-agent ambiguity. + configured_claude = str(getattr(cfg, "claude_home", "") or "").strip() + if configured_claude: + roots.append( + os.path.join( + os.path.abspath(os.path.expanduser(configured_claude)), + "skills", + ) + ) + + explicit = getattr(cfg, "skill_roots", ()) + if isinstance(explicit, (list, tuple)): + for value in explicit: + if isinstance(value, str) and value.strip(): + path = os.path.expanduser(value.strip()) + if not os.path.isabs(path) and invoked_project: + path = os.path.join(invoked_project, path) + roots.append(os.path.abspath(path)) + + if configured_claude: + claude_home = os.path.abspath(os.path.expanduser(configured_claude)) + cache = os.path.join(claude_home, "plugins", "cache") + for marketplace in _listdir(cache): + plugins_dir = os.path.join(cache, marketplace) + if not os.path.isdir(plugins_dir): + continue + for plugin in _listdir(plugins_dir): + root = _plugin_skills_root(os.path.join(plugins_dir, plugin)) + if root: + roots.append(root) + + existing: List[str] = [] + seen = set() + for root in roots: + if not os.path.isdir(root): continue - for plugin in _listdir(plugins_dir): - root = _plugin_skills_root(os.path.join(plugins_dir, plugin)) - if root: - roots.append(root) - return [r for r in roots if os.path.isdir(r)] + canonical = os.path.realpath(root) + key = os.path.normcase(canonical) + if key not in seen: + seen.add(key) + existing.append(canonical) + return existing def _contained_skill_file(root: str, name: str) -> str: diff --git a/skillopt_sleep/staging.py b/skillopt_sleep/staging.py index 8a83061a..749939b2 100644 --- a/skillopt_sleep/staging.py +++ b/skillopt_sleep/staging.py @@ -7,15 +7,18 @@ """ from __future__ import annotations +import base64 +import getpass import hashlib import json import math import os import re -import shutil import stat import tempfile import time +import unicodedata +from contextlib import contextmanager from dataclasses import dataclass from typing import Any, Dict, Iterable, List, Optional, Sequence @@ -256,6 +259,22 @@ class StagingError(ValueError): """A proposal could not be staged safely (bad name, bad target, collision).""" +class StagingRecoveryError(StagingError): + """A transaction failed and could not be rolled back without data loss.""" + + def __init__( + self, + message: str, + *, + primary: Optional[BaseException] = None, + recovery_errors: Sequence[str] = (), + ) -> None: + self.primary = primary + self.recovery_errors = tuple(recovery_errors) + detail = "; ".join(self.recovery_errors) + super().__init__(f"{message}: {detail}" if detail else message) + + @dataclass class SkillProposal: """One skill's proposed document plus the live file it would replace.""" @@ -263,6 +282,33 @@ class SkillProposal: skill_name: str proposed_skill: str live_skill_path: str + # ``None`` lets low-level callers snapshot the baseline at staging time. + # The cycle supplies the exact raw-byte hash it read before consolidation, + # closing the otherwise-unchecked read -> model call -> staging window. + live_sha256: Optional[str] = None + live_realpath: str = "" + + +def _filesystem_key(value: str) -> str: + """Conservative collision key for case/normalisation-insensitive filesystems.""" + return unicodedata.normalize("NFC", value).casefold() + + +def _path_identity_key(value: str) -> str: + """Platform path identity key; distinct POSIX spellings stay distinct.""" + return os.path.normcase(os.path.normpath(value)) + + +def _is_link_or_junction(path: str) -> bool: + isjunction = getattr(os.path, "isjunction", None) + if os.path.islink(path) or bool(isjunction and isjunction(path)): + return True + if os.name == "nt" and os.path.lexists(path): + try: + return bool(getattr(os.lstat(path), "st_reparse_tag", 0)) + except OSError: + return True + return False def _safe_skill_name(name: object) -> str: @@ -298,6 +344,8 @@ def _safe_live_path(path: object) -> str: raw = path.strip() if raw.startswith("~"): return "" + if any(ord(ch) < 32 or ord(ch) == 127 for ch in raw): + return "" # Reject traversal on the RAW input, before normalising. Normalising first # would silently resolve "/live/../../etc/SKILL.md" into "/etc/SKILL.md" # and then accept it, because no ".." survives the collapse -- turning a @@ -326,26 +374,206 @@ def _sha256_text(text: str) -> str: return hashlib.sha256(text.encode("utf-8")).hexdigest() -def _write_atomic(path: str, text: str, *, create_parents: bool = True) -> None: - """Write ``text`` to ``path`` atomically, so review never sees half a file.""" +def _canonical_live_path(path: str) -> str: + try: + return os.path.realpath(path) + except (OSError, ValueError): + return "" + + +def _sha256_file_bytes(path: str) -> str: + """Hash a regular live file's raw bytes; ``""`` means it is absent.""" + data, _mode, _file_id = _file_snapshot(path) + return _bytes_sha256(data) + + +def _fsync_directory(path: str) -> None: + """Durably publish directory-entry changes where the platform supports it.""" + if os.name == "nt": + # Python exposes no portable way to open a Windows directory for + # FlushFileBuffers. The WAL makes recovery deterministic there; POSIX + # additionally gets rename/unlink durability through directory fsync. + return + flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) + fd = os.open(path or ".", flags) + try: + os.fsync(fd) + finally: + os.close(fd) + + +def _fsync_parent(path: str) -> None: + _fsync_directory(os.path.dirname(path) or ".") + + +def _unlink_fsync(path: str) -> None: + os.unlink(path) + _fsync_parent(path) + + +def _write_atomic_bytes( + path: str, + data: bytes, + *, + create_parents: bool = True, + mode: Optional[int] = None, +) -> None: + """Write raw bytes atomically, optionally restoring an exact file mode.""" directory = os.path.dirname(path) or "." if create_parents: os.makedirs(directory, exist_ok=True) - existing_mode = ( - stat.S_IMODE(os.stat(path).st_mode) if os.path.exists(path) else None - ) + existing_mode = mode + if existing_mode is None and os.path.exists(path): + existing_mode = stat.S_IMODE(os.stat(path).st_mode) fd, tmp = tempfile.mkstemp(dir=directory, prefix=".tmp-", suffix=".md") try: - with os.fdopen(fd, "w", encoding="utf-8") as f: - f.write(text) + with os.fdopen(fd, "wb") as f: + f.write(data) f.flush() + if existing_mode is not None: + if hasattr(os, "fchmod"): + os.fchmod(f.fileno(), existing_mode) + else: + os.chmod(tmp, existing_mode) os.fsync(f.fileno()) - if existing_mode is not None: - os.chmod(tmp, existing_mode) os.replace(tmp, path) + _fsync_parent(path) + except BaseException: + try: + if os.path.exists(tmp): + os.unlink(tmp) + except OSError: + # Preserve the operation's primary error; a private temp may be + # left for manual cleanup, but no caller mutation is hidden. + pass + raise + + +def _write_atomic(path: str, text: str, *, create_parents: bool = True) -> None: + """Write ``text`` to ``path`` atomically, so review never sees half a file.""" + _write_atomic_bytes( + path, + text.encode("utf-8"), + create_parents=create_parents, + ) + + +def _write_new_bytes(path: str, data: bytes, *, mode: Optional[int] = None) -> None: + """Atomically publish a complete new file and never replace an existing one.""" + directory = os.path.dirname(path) or "." + fd, tmp = tempfile.mkstemp(dir=directory, prefix=".tmp-new-", suffix=".md") + published = False + failed = False + try: + with os.fdopen(fd, "wb") as handle: + handle.write(data) + handle.flush() + if mode is not None: + if hasattr(os, "fchmod"): + os.fchmod(handle.fileno(), mode) + else: + os.chmod(tmp, mode) + os.fsync(handle.fileno()) + # Hard-link publication is an atomic no-replace operation on every + # supported local filesystem. If the filesystem cannot hard-link, fail + # before any live target is mutated rather than weakening immutability. + os.link(tmp, path) + published = True + _fsync_directory(directory) except BaseException: - if os.path.exists(tmp): + failed = True + if published: + try: + _unlink_fsync(path) + except OSError: + pass + raise + finally: + try: os.unlink(tmp) + _fsync_directory(directory) + except FileNotFoundError: + pass + except OSError: + if not failed: + raise + + +def _remove_private_temp_aliases(path: str) -> None: + """Remove crash-left hard-link publication temps for exactly ``path``.""" + if not os.path.lexists(path): + return + info = os.lstat(path) + if not stat.S_ISREG(info.st_mode) or info.st_nlink <= 1: + return + directory = os.path.dirname(path) or "." + file_id = (info.st_dev, info.st_ino) + removed = False + for entry in os.scandir(directory): + if not entry.name.startswith(".tmp-new-") or entry.path == path: + continue + try: + candidate = entry.stat(follow_symlinks=False) + if ( + stat.S_ISREG(candidate.st_mode) + and (candidate.st_dev, candidate.st_ino) == file_id + ): + os.unlink(entry.path) + removed = True + except FileNotFoundError: + continue + if removed: + _fsync_directory(directory) + + +def _artifact_snapshot(path: str) -> Optional[tuple[bytes, int]]: + """Return bytes/mode for an existing artifact, or ``None`` when absent.""" + if not os.path.lexists(path): + return None + if _is_link_or_junction(path) or not os.path.isfile(path): + raise StagingError(f"staging artifact path is not a regular file: {path}") + with open(path, "rb") as handle: + data = handle.read() + return data, stat.S_IMODE(os.stat(path).st_mode) + + +def _write_artifact_batch(artifacts: Sequence[tuple[str, str]]) -> None: + """Publish a group of artifacts atomically per file, rolling back as a set. + + Callers put the manifest last. A crash can therefore leave only an + unadoptable directory; an ordinary write failure additionally restores or + removes every artifact touched by this call. + """ + snapshots = {path: _artifact_snapshot(path) for path, _text in artifacts} + written: List[str] = [] + try: + for path, text in artifacts: + # Record the undo before the call: a filesystem wrapper can commit + # a replace and then report a late error (for example on close). + written.append(path) + _write_atomic(path, text) + except BaseException as primary: + recovery_errors: List[str] = [] + for path in reversed(written): + try: + snapshot = snapshots[path] + if snapshot is None: + if os.path.lexists(path): + _unlink_fsync(path) + else: + data, mode = snapshot + _write_atomic_bytes(path, data, mode=mode) + except BaseException as exc: + recovery_errors.append( + f"could not restore staging artifact {path}: " + f"{type(exc).__name__}: {exc}" + ) + if recovery_errors: + raise StagingRecoveryError( + "staging artifact write failed and rollback was incomplete", + primary=primary, + recovery_errors=recovery_errors, + ) from primary raise @@ -377,17 +605,27 @@ def skill_proposal_rows(proposals: Iterable[SkillProposal]) -> List[Dict[str, An f"proposed skill content for {name!r} must be text, " f"got {type(proposal.proposed_skill).__name__}" ) - live = _safe_live_path(proposal.live_skill_path) + requested_live = _safe_live_path(proposal.live_skill_path) + live = _canonical_live_path(requested_live) if requested_live else "" if not live: raise StagingError( f"unsafe live skill path for {name!r}: {proposal.live_skill_path!r}" ) + parent = os.path.dirname(live) + if os.path.basename(live) != "SKILL.md": + raise StagingError( + f"live skill path for {name!r} must be a SKILL.md file: {live}" + ) + if _filesystem_key(os.path.basename(parent)) != _filesystem_key(name): + raise StagingError( + f"live skill path for {name!r} is not {name}/SKILL.md: {live}" + ) if any(row["skill_name"] == name for row in rows): raise StagingError(f"duplicate skill name in staging fan-out: {name!r}") proposed_file = proposal_filename(name) # casefold, not lower: it folds Unicode pairs lower() leaves distinct, # which is the comparison a case-insensitive filesystem actually makes. - file_key = proposed_file.casefold() + file_key = _filesystem_key(proposed_file) if file_key in seen_files: raise StagingError( f"skills {seen_files[file_key]!r} and {name!r} stage to the same " @@ -398,7 +636,7 @@ def skill_proposal_rows(proposals: Iterable[SkillProposal]) -> List[Dict[str, An # overwrite each other's live document. casefold rather than # os.path.normcase: normcase only folds case on Windows, so it is a # no-op on the macOS box where the collision is just as real. - live_key = live.casefold() + live_key = _filesystem_key(live) if live_key in seen_paths: raise StagingError( f"skills {seen_paths[live_key]!r} and {name!r} target the same file: {live}" @@ -414,6 +652,105 @@ def skill_proposal_rows(proposals: Iterable[SkillProposal]) -> List[Dict[str, An return rows +def _prepare_skill_proposals( + proposals: Iterable[SkillProposal], +) -> tuple[List[SkillProposal], List[Dict[str, Any]]]: + """Validate proposals and pin the exact live state each one was derived from.""" + materialized = list(proposals) + rows = skill_proposal_rows(materialized) + for row, proposal in zip(rows, materialized): + name = row["skill_name"] + if not proposal.proposed_skill.strip(): + raise StagingError(f"proposed skill content for {name!r} is empty") + + live = row["live_skill_path"] + actual_realpath = _canonical_live_path(live) + if not actual_realpath: + raise StagingError(f"could not canonicalize live skill path for {name!r}") + if proposal.live_realpath: + # This is the identity captured with the baseline read. Resolving + # it again would follow a symlink created later and make an + # ancestor swap look unchanged. + expected_realpath = _safe_live_path(proposal.live_realpath) + if ( + not expected_realpath + or _path_identity_key(expected_realpath) + != _path_identity_key(actual_realpath) + ): + raise StagingError( + f"live skill canonical target for {name!r} changed during consolidation" + ) + + actual_sha256 = _sha256_file_bytes(live) + expected_sha256 = proposal.live_sha256 + if expected_sha256 is None: + expected_sha256 = actual_sha256 + elif expected_sha256 != "" and not _valid_sha256_pin(expected_sha256): + raise StagingError(f"invalid live baseline sha256 for {name!r}") + if actual_sha256 != expected_sha256: + raise StagingError( + f"live skill for {name!r} changed during consolidation; " + "discard and rerun this night" + ) + + row["live_sha256"] = expected_sha256 + row["live_realpath"] = actual_realpath + return materialized, rows + + +def _prepare_legacy_proposal( + *, + label: str, + proposed_file: str, + proposed_text: str, + live_path: str, + live_sha256: Optional[str], + live_realpath: str, +) -> Dict[str, Any]: + """Validate and pin one legacy SKILL.md/CLAUDE.md proposal.""" + if not isinstance(proposed_text, str) or ( + label == "skill" and not proposed_text.strip() + ): + raise StagingError(f"legacy {label} proposal must be valid text") + requested = _safe_live_path(live_path) + canonical = _canonical_live_path(requested) if requested else "" + if not canonical: + raise StagingError(f"unsafe legacy {label} live path: {live_path!r}") + expected_basename = "SKILL.md" if label == "skill" else "CLAUDE.md" + if os.path.basename(canonical) != expected_basename: + raise StagingError( + f"legacy {label} target must be {expected_basename}: {canonical}" + ) + actual_realpath = _canonical_live_path(canonical) + if live_realpath: + # Compare the identity captured by the original baseline read without + # resolving it again: doing so would bless a symlink/junction swap + # that happened after the caller captured the path. + expected_realpath = _safe_live_path(live_realpath) + if not expected_realpath or ( + _path_identity_key(expected_realpath) + != _path_identity_key(actual_realpath) + ): + raise StagingError( + f"legacy {label} canonical target changed during consolidation" + ) + actual_sha256 = _sha256_file_bytes(canonical) + expected_sha256 = actual_sha256 if live_sha256 is None else live_sha256 + if expected_sha256 != "" and not _valid_sha256_pin(expected_sha256): + raise StagingError(f"invalid legacy {label} live baseline sha256") + if actual_sha256 != expected_sha256: + raise StagingError( + f"legacy {label} changed during consolidation; discard and rerun this night" + ) + return { + "proposed_file": proposed_file, + "live_path": canonical, + "sha256": _sha256_text(proposed_text), + "live_sha256": expected_sha256, + "live_realpath": actual_realpath, + } + + def write_skill_proposals( out_dir: str, proposals: Iterable[SkillProposal] ) -> List[Dict[str, Any]]: @@ -423,21 +760,15 @@ def write_skill_proposals( fan-out leaves no partial files behind. """ # Materialise once. The signature accepts any Iterable, so a generator is - # legal input — and it would otherwise be drained by the validation pass, - # leaving the write loop with nothing to iterate and returning a full set - # of manifest rows for files that were never created. - proposals = list(proposals) - rows = skill_proposal_rows(proposals) + # legal input — and it would otherwise be drained by the validation pass. + proposals, rows = _prepare_skill_proposals(proposals) if not rows: return rows - for row, proposal in zip(rows, proposals): - if not str(proposal.proposed_skill).strip(): - raise StagingError( - f"proposed skill content for {row['skill_name']!r} is empty" - ) os.makedirs(out_dir, exist_ok=True) - for row, proposal in zip(rows, proposals): - _write_atomic(os.path.join(out_dir, row["proposed_file"]), proposal.proposed_skill) + _write_artifact_batch([ + (os.path.join(out_dir, row["proposed_file"]), proposal.proposed_skill) + for row, proposal in zip(rows, proposals) + ]) return rows @@ -449,30 +780,158 @@ def staging_root(project: str) -> str: return os.path.join(project, ".skillopt-sleep", "staging") +def _ensure_staging_root(project: str) -> str: + """Create the private staging root without following an injected alias.""" + root = staging_root(project) + project_real = _canonical_live_path(project) + expected_real = os.path.join(project_real, ".skillopt-sleep", "staging") + if os.path.lexists(root) and ( + _is_link_or_junction(root) or not os.path.isdir(root) + ): + raise StagingError(f"staging root is unsafe: {root}") + os.makedirs(root, exist_ok=True) + if _path_identity_key(_canonical_live_path(root)) != _path_identity_key(expected_real): + raise StagingError(f"staging root passes through a symlink: {root}") + return root + + def new_staging_dir(project: str) -> str: - """A staging path that is unique even for two runs in the same second.""" - base = os.path.join(staging_root(project), _ts_dir()) + """Atomically reserve a staging path, unique across concurrent runs.""" + root = _ensure_staging_root(project) + base = os.path.join(root, _ts_dir()) out, i = base, 2 - while os.path.exists(out): - out = f"{base}-{i}" - i += 1 - return out + while True: + try: + os.mkdir(out) + _fsync_parent(out) + _fsync_directory(out) + return out + except FileExistsError: + out = f"{base}-{i}" + i += 1 + + +_STAGING_DIR_RE = re.compile(r"^(\d{8}-\d{6})(?:-(\d+))?$") +_MANIFEST_SCHEMA = "skillopt-sleep-staging" +_MANIFEST_VERSION = 2 +_LATEST_FILENAME = ".latest" + + +def _latest_pointer_path(root: str) -> str: + return os.path.join(root, _LATEST_FILENAME) + + +def _published_night_from_pointer(root: str) -> Optional[str]: + """Return the atomically published night, or ``None`` for an old/invalid root. + """ + pointer = _latest_pointer_path(root) + if not os.path.lexists(pointer): + return None + try: + info = os.lstat(pointer) + if ( + _is_link_or_junction(pointer) + or not stat.S_ISREG(info.st_mode) + or info.st_nlink != 1 + ): + return None + raw, _mode, file_id = _file_snapshot(pointer) + current = os.lstat(pointer) + if file_id != (current.st_dev, current.st_ino) or current.st_nlink != 1: + return None + if raw is None: + return None + name = raw.decode("utf-8").strip() + except (OSError, UnicodeError, StagingError): + return None + if not _STAGING_DIR_RE.fullmatch(name) or os.path.basename(name) != name: + return None + night = os.path.join(root, name) + manifest = os.path.join(night, "manifest.json") + if ( + _is_link_or_junction(night) + or not os.path.isdir(night) + or _is_link_or_junction(manifest) + or not os.path.isfile(manifest) + ): + return None + try: + expected = os.path.join(os.path.realpath(root), name) + if _path_identity_key(os.path.realpath(night)) != _path_identity_key( + expected + ): + return None + except (OSError, ValueError): + return None + return night + + +def _publish_latest(root: str, out: str) -> None: + """Atomically make ``out`` the last successfully published staging night.""" + root_abs = os.path.abspath(root) + out_abs = os.path.abspath(out) + name = os.path.basename(out_abs) + if ( + not _STAGING_DIR_RE.fullmatch(name) + or _path_identity_key(os.path.dirname(out_abs)) + != _path_identity_key(root_abs) + or _is_link_or_junction(out_abs) + or not os.path.isdir(out_abs) + or _path_identity_key(os.path.realpath(out_abs)) + != _path_identity_key(os.path.join(os.path.realpath(root_abs), name)) + ): + raise StagingError(f"staging directory is not a safe reserved night: {out}") + manifest = os.path.join(out_abs, "manifest.json") + if _is_link_or_junction(manifest) or not os.path.isfile(manifest): + raise StagingError(f"cannot publish a staging night without a manifest: {out}") + pointer = _latest_pointer_path(root_abs) + if os.path.lexists(pointer): + info = os.lstat(pointer) + if ( + _is_link_or_junction(pointer) + or not stat.S_ISREG(info.st_mode) + or info.st_nlink != 1 + ): + raise StagingError(f"latest-staging pointer is unsafe: {pointer}") + _write_atomic_bytes(pointer, f"{name}\n".encode("utf-8"), mode=0o600) + + +def _staging_order(path: str) -> tuple: + """Order nights by the publication marker, which adoption never mutates.""" + manifest_path = os.path.join(path, "manifest.json") + try: + published_ns = os.stat(manifest_path, follow_symlinks=False).st_mtime_ns + except OSError: + published_ns = 0 + match = _STAGING_DIR_RE.fullmatch(os.path.basename(path)) + if match: + return (published_ns, 1, match.group(1), int(match.group(2) or 1)) + return (published_ns, 0, "", 0) def latest_staging(project: str) -> Optional[str]: root = staging_root(project) - if not os.path.isdir(root): + if _is_link_or_junction(root) or not os.path.isdir(root): return None - subs = sorted( - (os.path.join(root, d) for d in os.listdir(root)), - key=lambda p: os.path.getmtime(p), - reverse=True, - ) + published = _published_night_from_pointer(root) + if published is not None: + return published + subs = [] + for entry in os.listdir(root): + if not _STAGING_DIR_RE.fullmatch(entry): + continue + path = os.path.join(root, entry) + if _is_link_or_junction(path) or not os.path.isdir(path): + continue + manifest_path = os.path.join(path, "manifest.json") + if _is_link_or_junction(manifest_path) or not os.path.isfile(manifest_path): + continue + subs.append(path) + subs.sort(key=_staging_order, reverse=True) for p in subs: # Only adoptable folders count: a no-tasks night leaves evidence.jsonl # but no manifest, and adopt() needs the manifest. - if os.path.exists(os.path.join(p, "manifest.json")): - return p + return p return None @@ -484,6 +943,10 @@ def write_staging( proposed_memory: Optional[str], live_skill_path: str, live_memory_path: str, + live_skill_sha256: Optional[str] = None, + live_memory_sha256: Optional[str] = None, + live_skill_realpath: str = "", + live_memory_realpath: str = "", report_md: str, out_dir: str = "", skill_proposals: Iterable[SkillProposal] = (), @@ -498,38 +961,108 @@ def write_staging( skill for a multi-skill night. Left empty, the staging layout and manifest are exactly the legacy single-proposal ones. """ - out = out_dir or os.path.join(staging_root(project), _ts_dir()) - os.makedirs(out, exist_ok=True) - - skill_rows = write_skill_proposals(out, skill_proposals) + root = _ensure_staging_root(project) + out = out_dir or new_staging_dir(project) + if out_dir: + out_abs = os.path.abspath(out) + root_abs = os.path.abspath(root) + if ( + not _STAGING_DIR_RE.fullmatch(os.path.basename(out_abs)) + or _path_identity_key(os.path.dirname(out_abs)) + != _path_identity_key(root_abs) + ): + raise StagingError(f"staging directory is unsafe: {out}") + if os.path.lexists(out) and ( + _is_link_or_junction(out) or not os.path.isdir(out) + ): + raise StagingError(f"staging directory is unsafe: {out}") + os.makedirs(out, exist_ok=True) + + proposals, skill_rows = _prepare_skill_proposals(skill_proposals) + if proposed_skill is not None and skill_rows: + legacy_live = _safe_live_path(live_skill_path) + legacy_real = _canonical_live_path(legacy_live) if legacy_live else "" + if not legacy_real: + raise StagingError( + f"unsafe managed live skill path in mixed staging: {live_skill_path!r}" + ) + legacy_key = _filesystem_key(legacy_real) + for row in skill_rows: + if _filesystem_key(row["live_realpath"]) == legacy_key: + raise StagingError( + f"managed and per-skill proposals target the same live file: " + f"{legacy_real}" + ) + + legacy: Dict[str, Dict[str, Any]] = {} + if proposed_skill is not None: + legacy["skill"] = _prepare_legacy_proposal( + label="skill", + proposed_file="proposed_SKILL.md", + proposed_text=proposed_skill, + live_path=live_skill_path, + live_sha256=live_skill_sha256, + live_realpath=live_skill_realpath, + ) + live_skill_path = legacy["skill"]["live_path"] + if proposed_memory is not None: + legacy["memory"] = _prepare_legacy_proposal( + label="memory", + proposed_file="proposed_CLAUDE.md", + proposed_text=proposed_memory, + live_path=live_memory_path, + live_sha256=live_memory_sha256, + live_realpath=live_memory_realpath, + ) + live_memory_path = legacy["memory"]["live_path"] manifest = { + "schema": _MANIFEST_SCHEMA, + "schema_version": _MANIFEST_VERSION, "live_skill_path": live_skill_path, "live_memory_path": live_memory_path, - "has_skill": proposed_skill is not None, - "has_memory": proposed_memory is not None, + # PyPI v0.2.0 adopted these top-level flags without integrity pins. + # Keep them false so an old runtime fails closed on a new manifest. + "has_skill": False, + "has_memory": False, + "has_managed_skill": proposed_skill is not None, + "has_managed_memory": proposed_memory is not None, "accepted": report.accepted, } if skill_rows: manifest["skills"] = skill_rows + if legacy: + manifest["legacy"] = legacy + artifacts: List[tuple[str, str]] = [ + ( + os.path.join(out, row["proposed_file"]), + proposal.proposed_skill, + ) + for row, proposal in zip(skill_rows, proposals) + ] if proposed_skill is not None: - with open(os.path.join(out, "proposed_SKILL.md"), "w", encoding="utf-8") as f: - f.write(proposed_skill) + artifacts.append((os.path.join(out, "proposed_SKILL.md"), proposed_skill)) if proposed_memory is not None: - with open(os.path.join(out, "proposed_CLAUDE.md"), "w", encoding="utf-8") as f: - f.write(proposed_memory) - with open(os.path.join(out, "report.json"), "w", encoding="utf-8") as f: - json.dump( - json_safe(report.to_dict()), - f, - ensure_ascii=False, - indent=2, - allow_nan=False, - ) - with open(os.path.join(out, "report.md"), "w", encoding="utf-8") as f: - f.write(report_md) - with open(os.path.join(out, "manifest.json"), "w", encoding="utf-8") as f: - json.dump(manifest, f, ensure_ascii=False, indent=2) + artifacts.append((os.path.join(out, "proposed_CLAUDE.md"), proposed_memory)) + artifacts.extend([ + ( + os.path.join(out, "report.json"), + json.dumps( + json_safe(report.to_dict()), + ensure_ascii=False, + indent=2, + allow_nan=False, + ), + ), + (os.path.join(out, "report.md"), report_md), + # The manifest is the publication marker and must always be last. + ( + os.path.join(out, "manifest.json"), + json.dumps(manifest, ensure_ascii=False, indent=2), + ), + ]) + _write_artifact_batch(artifacts) + _publish_latest(root, out) return out @@ -544,12 +1077,156 @@ class AdoptedSkill: backup_path: str = "" # "" when there was nothing to back up +@dataclass +class _TransactionTarget: + """Fully pinned mutation used by the durable adoption transaction.""" + + key: str + live_path: str + expected_realpath: str + expected_basename: str + proposed_bytes: bytes + proposed_sha256: str + original_bytes: Optional[bytes] + original_mode: Optional[int] + original_file_id: Optional[tuple[int, int]] + baseline_sha256: str + backup_path: str + created_dirs: tuple[str, ...] = () + # Persisted identities for directories this transaction actually created. + # ``None`` is fail-closed: recovery may leave an empty directory behind, + # but it must never remove a path whose ownership was not durably recorded. + created_dir_ids: tuple[Optional[tuple[int, int]], ...] = () + + +_WAL_FILENAME = ".adopt-transaction.json" +_WAL_VERSION = 2 + + +def _bytes_sha256(data: Optional[bytes]) -> str: + return hashlib.sha256(data).hexdigest() if data is not None else "" + + +def _modes_match(actual: Optional[int], expected: Optional[int]) -> bool: + if actual is None or expected is None: + return actual is expected + if os.name == "nt": + # Windows chmod/stat expose only the portable read-only distinction. + return bool(actual & stat.S_IWRITE) == bool(expected & stat.S_IWRITE) + return actual == expected + + +def _b64(data: Optional[bytes]) -> Optional[str]: + return base64.b64encode(data).decode("ascii") if data is not None else None + + +def _from_b64(value: object, *, field: str) -> Optional[bytes]: + if value is None: + return None + if not isinstance(value, str): + raise StagingError(f"transaction WAL {field} must be base64 text or null") + try: + return base64.b64decode(value.encode("ascii"), validate=True) + except (UnicodeError, ValueError) as exc: + raise StagingError(f"transaction WAL {field} is invalid base64") from exc + + +def _file_snapshot( + path: str, +) -> tuple[Optional[bytes], Optional[int], Optional[tuple[int, int]]]: + """Read a regular file and its identity without following a final symlink.""" + if not os.path.lexists(path): + return None, None, None + if _is_link_or_junction(path) or not os.path.isfile(path): + raise StagingError(f"target is not a regular file: {path}") + flags = ( + os.O_RDONLY + | getattr(os, "O_BINARY", 0) + | getattr(os, "O_NOFOLLOW", 0) + ) + try: + fd = os.open(path, flags) + try: + before = os.fstat(fd) + if not stat.S_ISREG(before.st_mode): + raise StagingError(f"target is not a regular file: {path}") + chunks: List[bytes] = [] + while True: + chunk = os.read(fd, 1024 * 1024) + if not chunk: + break + chunks.append(chunk) + info = os.fstat(fd) + stable_fields = ( + "st_dev", + "st_ino", + "st_mode", + "st_size", + "st_mtime_ns", + "st_nlink", + ) + if any(getattr(before, field) != getattr(info, field) for field in stable_fields): + raise StagingError(f"target changed while it was being read: {path}") + finally: + os.close(fd) + except OSError as exc: + raise StagingError(f"could not snapshot target: {path}") from exc + try: + current = os.stat(path, follow_symlinks=False) + except OSError as exc: + raise StagingError(f"target changed while it was being read: {path}") from exc + file_id = (info.st_dev, info.st_ino) + if ( + (current.st_dev, current.st_ino) != file_id + or current.st_mode != info.st_mode + or current.st_size != info.st_size + or current.st_mtime_ns != info.st_mtime_ns + or current.st_nlink != info.st_nlink + ): + raise StagingError(f"target changed while it was being read: {path}") + return b"".join(chunks), stat.S_IMODE(info.st_mode), file_id + + +def _canonical_staging_dir(staging_dir: str) -> str: + """Return one stable absolute staging identity for locks, WAL, and receipts.""" + absolute = os.path.abspath(staging_dir) + if _is_link_or_junction(absolute) or not os.path.isdir(absolute): + raise StagingError(f"staging directory is unsafe: {staging_dir}") + canonical = os.path.realpath(absolute) + if not os.path.isdir(canonical): + raise StagingError(f"staging directory is unsafe: {staging_dir}") + return canonical + + +def _manifest_schema_version(manifest: Dict[str, Any]) -> int: + """Validate the explicit schema, while retaining read support for old nights.""" + schema_present = "schema" in manifest or "schema_version" in manifest + if not schema_present: + return 1 + if ( + manifest.get("schema") != _MANIFEST_SCHEMA + or type(manifest.get("schema_version")) is not int + or manifest.get("schema_version") != _MANIFEST_VERSION + ): + raise StagingError("staging manifest has an unsupported schema version") + return _MANIFEST_VERSION + + def staged_skills(staging_dir: str) -> List[Dict[str, Any]]: """Manifest rows for the per-skill proposals staged in ``staging_dir``.""" - with open(os.path.join(staging_dir, "manifest.json"), encoding="utf-8") as f: - manifest = json.load(f) + if _is_link_or_junction(staging_dir) or not os.path.isdir(staging_dir): + raise StagingError(f"staging directory is unsafe: {staging_dir}") + manifest_path = os.path.join(staging_dir, "manifest.json") + if _is_link_or_junction(manifest_path): + raise StagingError("staging manifest must not be a symlink") + try: + with open(manifest_path, encoding="utf-8") as f: + manifest = json.load(f) + except (OSError, UnicodeError, json.JSONDecodeError, ValueError) as exc: + raise StagingError(f"cannot read staging manifest: {exc}") from exc if not isinstance(manifest, dict): raise StagingError("staging manifest must be a JSON object") + _manifest_schema_version(manifest) if "skills" not in manifest: return [] rows = manifest["skills"] @@ -606,16 +1283,38 @@ def _revalidate_selected_skill_rows( for row in universe ]) seen_real: Dict[str, str] = {} + seen_files: Dict[tuple[int, int], str] = {} for row in universe: name = _safe_skill_name(row.get("skill_name")) live = _safe_live_path(row.get("live_skill_path")) if not name or not live: continue - try: - real = os.path.realpath(live) - except OSError: - real = live - key = real.casefold() + expected_real = _safe_live_path(row.get("live_realpath")) + live_pin = row.get("live_sha256") + if not expected_real or ( + live_pin != "" and not _valid_sha256_pin(live_pin) + ): + raise StagingError( + f"staged skill {name!r} is missing live baseline pins; " + "discard and restage this night" + ) + if _is_link_or_junction(live): + raise StagingError(f"live skill path for {name!r} is a symlink: {live}") + parent = os.path.dirname(live) + if _is_link_or_junction(parent): + raise StagingError( + f"live skill parent directory for {name!r} is a symlink: {parent}" + ) + if os.path.basename(live) != "SKILL.md": + raise StagingError( + f"live skill path for {name!r} must be a SKILL.md file: {live}" + ) + real = _canonical_live_path(live) + if not real or _path_identity_key(real) != _path_identity_key(expected_real): + raise StagingError( + f"live skill canonical target for {name!r} changed since staging" + ) + key = _filesystem_key(real) if key in seen_real: raise StagingError( f"skills {seen_real[key]!r} and {name!r} target the same file: {live}" @@ -625,6 +1324,15 @@ def _revalidate_selected_skill_rows( raise StagingError( f"live skill path for {name!r} exists and is not a file: {live}" ) + if os.path.isfile(live) and not _is_link_or_junction(live): + info = os.stat(live, follow_symlinks=False) + file_id = (info.st_dev, info.st_ino) + if file_id in seen_files: + raise StagingError( + f"skills {seen_files[file_id]!r} and {name!r} target the " + f"same file through a hard link: {live}" + ) + seen_files[file_id] = name def _valid_sha256_pin(value: object) -> bool: @@ -633,180 +1341,1682 @@ def _valid_sha256_pin(value: object) -> bool: return all(ch in "0123456789abcdef" for ch in value) -def _adopt_live_target_ok(name: str, live: str) -> None: - """Refuse live targets that would create dirs, follow links, or leave the skill folder.""" - if os.path.islink(live): - raise StagingError(f"live skill path for {name!r} is a symlink: {live}") +def _adopt_target_ok( + label: str, + live: str, + expected_realpath: str, + *, + expected_basename: str, +) -> None: + """Refuse targets that moved, create dirs, or traverse any symlink.""" + if _is_link_or_junction(live): + raise StagingError(f"live target for {label} is a symlink: {live}") + if os.path.lexists(live): + info = os.lstat(live) + if stat.S_ISREG(info.st_mode) and info.st_nlink != 1: + raise StagingError(f"live target for {label} has multiple hard links") parent = os.path.dirname(live) - if os.path.islink(parent): + if _is_link_or_junction(parent): raise StagingError( - f"live skill parent directory for {name!r} is a symlink: {parent}" + f"live parent directory for {label} is a symlink: {parent}" ) if not os.path.isdir(parent): raise StagingError( - f"live skill parent directory for {name!r} does not exist: {parent}" + f"live parent directory for {label} does not exist: {parent}" + ) + current_realpath = _canonical_live_path(live) + if ( + not current_realpath + or _path_identity_key(current_realpath) + != _path_identity_key(expected_realpath) + ): + raise StagingError( + f"live canonical target for {label} changed since staging" + ) + # Genuine staged rows store the canonical path itself. Any difference now + # means an ancestor was replaced by a symlink/junction after review. + if _path_identity_key(current_realpath) != _path_identity_key(live): + raise StagingError( + f"live path for {label} passes through a symlink or junction: {live}" ) - if os.path.basename(live) != "SKILL.md": + if os.path.basename(live) != expected_basename: raise StagingError( - f"live skill path for {name!r} must be a SKILL.md file: {live}" + f"live target for {label} must be {expected_basename}: {live}" ) - if os.path.basename(parent) != name: + + +def _adopt_live_target_ok(name: str, live: str, expected_realpath: str) -> None: + _adopt_target_ok( + repr(name), + live, + expected_realpath, + expected_basename="SKILL.md", + ) + parent = os.path.dirname(live) + if _filesystem_key(os.path.basename(parent)) != _filesystem_key(name): raise StagingError( f"live skill path for {name!r} is not {name}/SKILL.md: {live}" ) -def _restore_live_writes(done: Sequence[tuple]) -> None: - """Restore live files written by a failed adoption, newest first.""" - for live, original in reversed(done): +def _planned_live_directories( + label: str, + live: str, + expected_realpath: str, + *, + expected_basename: str, +) -> tuple[str, ...]: + """Validate an absent legacy target and return missing parents outer-first.""" + if os.path.basename(live) != expected_basename: + raise StagingError(f"live target for {label} must be {expected_basename}: {live}") + current_realpath = _canonical_live_path(live) + if ( + not current_realpath + or _path_identity_key(current_realpath) != _path_identity_key(expected_realpath) + or _path_identity_key(current_realpath) != _path_identity_key(live) + ): + raise StagingError( + f"live path for {label} changed or passes through a symlink/junction" + ) + missing: List[str] = [] + current = os.path.dirname(live) + while not os.path.lexists(current): + missing.append(current) + parent = os.path.dirname(current) + if parent == current: + raise StagingError(f"no existing ancestor for live target {label}") + current = parent + info = os.lstat(current) + if _is_link_or_junction(current) or not stat.S_ISDIR(info.st_mode): + raise StagingError(f"live ancestor for {label} is unsafe: {current}") + return tuple(reversed(missing)) + + +def _read_receipt_file( + path: str, +) -> tuple[ + List[Dict[str, Any]], + Optional[bytes], + Optional[int], + Optional[tuple[int, int]], +]: + if not os.path.lexists(path): + return [], None, None, None + try: + info = os.lstat(path) + except OSError as exc: + raise StagingError(f"cannot inspect adoption receipt path: {exc}") from exc + if ( + _is_link_or_junction(path) + or not stat.S_ISREG(info.st_mode) + or info.st_nlink != 1 + ): + raise StagingError("adoption receipt path is not a private regular file") + try: + original, mode, file_id = _file_snapshot(path) if original is None: - if os.path.exists(live): - os.unlink(live) + raise StagingError("adoption receipt disappeared while being read") + current = os.lstat(path) + if ( + file_id != (current.st_dev, current.st_ino) + or current.st_nlink != 1 + ): + raise StagingError("adoption receipt identity changed while being read") + payload = json.loads(original.decode("utf-8")) + except ( + OSError, + UnicodeError, + json.JSONDecodeError, + ValueError, + RecursionError, + ) as exc: + raise StagingError(f"cannot read existing adoption receipt: {exc}") from exc + if not isinstance(payload, list) or any( + not isinstance(row, dict) for row in payload + ): + raise StagingError("existing adoption receipt must be a list of objects") + return payload, original, mode, file_id + + +def _read_existing_receipts( + path: str, + staging_dir: str, +) -> tuple[ + List[Dict[str, Any]], + Optional[bytes], + Optional[int], + Optional[tuple[int, int]], +]: + """Load and verify the append-only per-skill adoption ledger.""" + payload, original, mode, file_id = _read_receipt_file(path) + seen: set[str] = set() + schema = { + "skill_name", + "live_skill_path", + "sha256_before", + "sha256_after", + "backup_path", + } + for row in payload: + if set(row) != schema: + raise StagingError("existing adoption receipt has an invalid schema") + name = _safe_skill_name(row.get("skill_name")) + if not name or row.get("skill_name") != name or name in seen: + raise StagingError("existing adoption receipt has invalid or duplicate skills") + seen.add(name) + live = _safe_live_path(row.get("live_skill_path")) + before = row.get("sha256_before") + after = row.get("sha256_after") + backup = row.get("backup_path") + if ( + not live + or os.path.basename(live) != "SKILL.md" + or _filesystem_key(os.path.basename(os.path.dirname(live))) + != _filesystem_key(name) + or (before != "" and not _valid_sha256_pin(before)) + ): + raise StagingError(f"existing adoption receipt for {name!r} is invalid") + if not _valid_sha256_pin(after) or not isinstance(backup, str): + raise StagingError(f"existing adoption receipt for {name!r} is invalid") + if before == "": + if backup: + raise StagingError( + f"existing adoption receipt for {name!r} has an unexpected backup" + ) + continue + expected = os.path.join( + staging_dir, "backup", "skills", name, "SKILL.md" + ) + if _path_identity_key(backup) != _path_identity_key(expected): + raise StagingError( + f"existing adoption receipt for {name!r} has an invalid backup path" + ) + backup_sha256 = _immutable_backup_sha256(expected, staging_dir) + if backup_sha256 is None: + raise StagingError( + f"immutable backup for previously adopted skill {name!r} is missing" + ) + if backup_sha256 != before: + raise StagingError( + f"immutable backup for previously adopted skill {name!r} changed" + ) + return payload, original, mode, file_id + + +def pending_staged_skills(staging_dir: str) -> List[Dict[str, Any]]: + """Return fan-out rows not already recorded in the validated adoption ledger.""" + staging_dir = _canonical_staging_dir(staging_dir) + if os.path.lexists(_wal_path(staging_dir)): + raise StagingError( + "an interrupted adoption must be recovered before listing pending skills" + ) + rows = staged_skills(staging_dir) + receipt_path = os.path.join(staging_dir, "adopted_skills.json") + receipts, _raw, _mode, _file_id = _read_existing_receipts( + receipt_path, staging_dir + ) + adopted = {str(row["skill_name"]) for row in receipts} + return [row for row in rows if str(row.get("skill_name")) not in adopted] + + +def has_staged_managed(staging_dir: str) -> bool: + """Return whether a validated managed skill or memory proposal is present.""" + staging_dir = _canonical_staging_dir(staging_dir) + if os.path.lexists(_wal_path(staging_dir)): + raise StagingError( + "an interrupted adoption must be recovered before listing managed proposals" + ) + return bool(_legacy_rows(_load_manifest(staging_dir))) + + +def has_pending_staged_managed(staging_dir: str) -> bool: + """Return whether a validated managed proposal target remains unadopted.""" + staging_dir = _canonical_staging_dir(staging_dir) + if os.path.lexists(_wal_path(staging_dir)): + raise StagingError( + "an interrupted adoption must be recovered before listing managed proposals" + ) + rows = _legacy_rows(_load_manifest(staging_dir)) + if not rows: + return False + receipts, _raw, _mode, _file_id = _read_legacy_receipts( + os.path.join(staging_dir, "adopted_legacy.json"), + staging_dir, + ) + adopted = {str(row["target"]) for row in receipts} + return any(label not in adopted for label in rows) + + +def _target_lock_paths(live_paths: Sequence[str]) -> List[str]: + """Stable per-target lock names shared by separate staging nights.""" + if hasattr(os, "getuid"): + identity = str(os.getuid()) + else: + user_material = f"{getpass.getuser()}|{os.path.expanduser('~')}" + identity = hashlib.sha256(user_material.encode("utf-8")).hexdigest()[:16] + root = os.path.join(tempfile.gettempdir(), f"skillopt-sleep-adopt-{identity}") + try: + os.mkdir(root, 0o700) + _fsync_parent(root) + except FileExistsError: + pass + info = os.lstat(root) + if _is_link_or_junction(root) or not stat.S_ISDIR(info.st_mode): + raise StagingError(f"adoption lock root is unsafe: {root}") + if hasattr(os, "getuid") and info.st_uid != os.getuid(): + raise StagingError(f"adoption lock root has the wrong owner: {root}") + if os.name != "nt" and stat.S_IMODE(info.st_mode) != 0o700: + raise StagingError( + f"adoption lock root permissions are unsafe; expected 0700: {root}" + ) + return [ + os.path.join( + root, + hashlib.sha256(path.encode("utf-8")).hexdigest() + ".lock", + ) + for path in sorted({_filesystem_key(path) for path in live_paths}) + ] + + +@contextmanager +def _exclusive_create_locks(paths: Sequence[str]): + """Acquire cross-platform fail-closed locks with atomic file creation.""" + acquired: List[tuple[str, int, tuple[int, int]]] = [] + try: + for path in paths: + try: + fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + except FileExistsError as exc: + raise StagingError( + "skill adoption is already in progress or a stale lock exists: " + f"{path}" + ) from exc + info = os.fstat(fd) + identity = (info.st_dev, info.st_ino) + acquired.append((path, fd, identity)) + os.write(fd, f"pid={os.getpid()}\n".encode("ascii")) + os.fsync(fd) + yield + finally: + for path, fd, identity in reversed(acquired): + try: + os.close(fd) + except OSError: + # The operation inside the lock has a definitive result; a + # cleanup error must not turn a committed adoption into a + # reported failure. A surviving lock fails closed next time. + pass + try: + info = os.lstat(path) + if (info.st_dev, info.st_ino) == identity: + os.unlink(path) + except OSError: + pass + + +def _wal_path(staging_dir: str) -> str: + return os.path.join(staging_dir, _WAL_FILENAME) + + +def _target_wal_row(target: _TransactionTarget) -> Dict[str, Any]: + created_dir_ids = target.created_dir_ids or tuple( + None for _path in target.created_dirs + ) + return { + "key": target.key, + "live_path": target.live_path, + "expected_realpath": target.expected_realpath, + "expected_basename": target.expected_basename, + "proposed_sha256": target.proposed_sha256, + "original_b64": _b64(target.original_bytes), + "original_mode": target.original_mode, + "original_file_id": ( + list(target.original_file_id) + if target.original_file_id is not None + else None + ), + "baseline_sha256": target.baseline_sha256, + "backup_path": target.backup_path, + "created_dirs": list(target.created_dirs), + "created_dir_ids": [ + list(file_id) if file_id is not None else None + for file_id in created_dir_ids + ], + } + + +def _transaction_wal( + *, + kind: str, + targets: Sequence[_TransactionTarget], + receipt_path: str, + receipt_original: Optional[bytes], + receipt_mode: Optional[int], + receipt_file_id: Optional[tuple[int, int]], + receipt_after: bytes, +) -> Dict[str, Any]: + return { + "version": _WAL_VERSION, + "kind": kind, + "targets": [_target_wal_row(target) for target in targets], + "receipt": { + "path": receipt_path, + "original_b64": _b64(receipt_original), + "original_mode": receipt_mode, + "original_file_id": ( + list(receipt_file_id) if receipt_file_id is not None else None + ), + "baseline_sha256": _bytes_sha256(receipt_original), + "proposed_sha256": hashlib.sha256(receipt_after).hexdigest(), + }, + } + + +def _write_transaction_wal(staging_dir: str, wal: Dict[str, Any]) -> None: + path = _wal_path(staging_dir) + if os.path.lexists(path): + raise StagingError( + f"an adoption recovery journal already exists; recover it first: {path}" + ) + payload = json.dumps(wal, ensure_ascii=False, indent=2).encode("utf-8") + _write_new_bytes(path, payload, mode=0o600) + + +def _rewrite_transaction_wal( + staging_dir: str, + *, + expected_wal: Dict[str, Any], + replacement_wal: Dict[str, Any], +) -> None: + """Durably add post-creation identities without accepting another journal.""" + current = _read_transaction_wal(staging_dir) + if current != expected_wal: + raise StagingError( + "adoption recovery journal changed during directory creation" + ) + payload = json.dumps( + replacement_wal, ensure_ascii=False, indent=2 + ).encode("utf-8") + _write_atomic_bytes( + _wal_path(staging_dir), payload, create_parents=False, mode=0o600 + ) + + +def _read_transaction_wal(staging_dir: str) -> Optional[Dict[str, Any]]: + path = _wal_path(staging_dir) + if not os.path.lexists(path): + return None + _remove_private_temp_aliases(path) + try: + info = os.lstat(path) + except OSError as exc: + raise StagingError(f"cannot inspect adoption recovery journal: {exc}") from exc + if ( + _is_link_or_junction(path) + or not stat.S_ISREG(info.st_mode) + or info.st_nlink != 1 + ): + raise StagingError(f"adoption recovery journal is unsafe: {path}") + try: + raw, _mode, _file_id = _file_snapshot(path) + if raw is None: + raise StagingError("adoption recovery journal disappeared") + current = os.lstat(path) + if ( + _file_id != (current.st_dev, current.st_ino) + or current.st_nlink != 1 + ): + raise StagingError("adoption recovery journal identity changed") + payload = json.loads(raw.decode("utf-8")) + except ( + OSError, + UnicodeError, + json.JSONDecodeError, + ValueError, + RecursionError, + ) as exc: + raise StagingError(f"cannot read adoption recovery journal: {exc}") from exc + if ( + not isinstance(payload, dict) + or set(payload) != {"version", "kind", "targets", "receipt"} + or type(payload.get("version")) is not int + or payload.get("version") not in {1, _WAL_VERSION} + ): + raise StagingError("adoption recovery journal has an unsupported format") + return payload + + +def _remove_transaction_wal( + staging_dir: str, *, expected_wal: Optional[Dict[str, Any]] = None +) -> None: + path = _wal_path(staging_dir) + if expected_wal is not None: + current = _read_transaction_wal(staging_dir) + if current != expected_wal: + raise StagingError("adoption recovery journal changed before commit") + if os.path.lexists(path): + _remove_private_temp_aliases(path) + info = os.lstat(path) + if ( + _is_link_or_junction(path) + or not stat.S_ISREG(info.st_mode) + or info.st_nlink != 1 + ): + raise StagingError(f"adoption recovery journal is unsafe: {path}") + _unlink_fsync(path) + + +def _path_is_within(path: str, root: str) -> bool: + try: + return os.path.commonpath( + [os.path.abspath(path), os.path.abspath(root)] + ) == os.path.abspath(root) + except ValueError: + return False + + +def _existing_path_is_canonical_staging_descendant( + path: str, staging_dir: str +) -> bool: + """Reject a staging descendant reached through a symlink or junction. + + The staging root itself may be supplied through a symlink, so compare the + resolved candidate with the same relative path beneath the resolved root. + """ + try: + relative = os.path.relpath(path, staging_dir) + except ValueError: + return False + if relative == os.pardir or relative.startswith(os.pardir + os.sep): + return False + expected_real = os.path.join(os.path.realpath(staging_dir), relative) + return _path_identity_key(os.path.realpath(path)) == _path_identity_key(expected_real) + + +def _immutable_backup_snapshot( + path: str, staging_dir: str, *, repair_temp_aliases: bool = False +) -> Optional[tuple[str, tuple[int, int]]]: + """Hash one derived backup only when its path and inode are immutable-safe.""" + if not _existing_path_is_canonical_staging_descendant(path, staging_dir): + return None + if repair_temp_aliases: + _remove_private_temp_aliases(path) + try: + before = os.lstat(path) + except OSError: + return None + if not stat.S_ISREG(before.st_mode) or before.st_nlink != 1: + return None + data, _mode, file_id = _file_snapshot(path) + try: + after = os.lstat(path) + except OSError: + return None + if file_id != (after.st_dev, after.st_ino) or after.st_nlink != 1: + return None + return _bytes_sha256(data), (after.st_dev, after.st_ino) + + +def _immutable_backup_sha256(path: str, staging_dir: str) -> Optional[str]: + snapshot = _immutable_backup_snapshot(path, staging_dir) + return snapshot[0] if snapshot is not None else None + + +def _decode_wal_targets( + staging_dir: str, wal: Dict[str, Any] +) -> List[_TransactionTarget]: + kind = wal.get("kind") + if kind not in {"skills", "legacy"}: + raise StagingError("adoption recovery journal has an invalid transaction kind") + raw_targets = wal.get("targets") + if not isinstance(raw_targets, list) or not raw_targets: + raise StagingError("adoption recovery journal has no targets") + targets: List[_TransactionTarget] = [] + seen: set[str] = set() + seen_live: set[str] = set() + seen_live_collisions: set[str] = set() + target_schema = { + "key", + "live_path", + "expected_realpath", + "expected_basename", + "proposed_sha256", + "original_b64", + "original_mode", + "original_file_id", + "baseline_sha256", + "backup_path", + "created_dirs", + } + if wal.get("version") == _WAL_VERSION: + target_schema.add("created_dir_ids") + for index, row in enumerate(raw_targets): + if not isinstance(row, dict): + raise StagingError("adoption recovery journal target must be an object") + if set(row) != target_schema: + raise StagingError("adoption recovery journal target has an invalid schema") + key = row.get("key") + live = _safe_live_path(row.get("live_path")) + expected_realpath = _safe_live_path(row.get("expected_realpath")) + expected_basename = row.get("expected_basename") + proposed_sha256 = row.get("proposed_sha256") + baseline_sha256 = row.get("baseline_sha256") + backup_path = row.get("backup_path") + raw_created_dirs = row.get("created_dirs", []) + raw_created_dir_ids = row.get("created_dir_ids", []) + if not isinstance(key, str) or not key or key in seen: + raise StagingError("adoption recovery journal has invalid target keys") + seen.add(key) + if not live or not expected_realpath: + raise StagingError("adoption recovery journal has an unsafe live path") + live_key = _path_identity_key(live) + collision_key = _filesystem_key(live) + if live_key in seen_live or collision_key in seen_live_collisions: + raise StagingError("adoption recovery journal repeats a live target") + seen_live.add(live_key) + seen_live_collisions.add(collision_key) + if _path_identity_key(expected_realpath) != live_key: + raise StagingError("adoption recovery journal target identity is invalid") + if expected_basename not in {"SKILL.md", "CLAUDE.md"}: + raise StagingError("adoption recovery journal has an unsafe target basename") + if not _valid_sha256_pin(proposed_sha256): + raise StagingError("adoption recovery journal has an invalid proposal hash") + if baseline_sha256 != "" and not _valid_sha256_pin(baseline_sha256): + raise StagingError("adoption recovery journal has an invalid baseline hash") + original = _from_b64(row.get("original_b64"), field=f"targets[{index}]") + if _bytes_sha256(original) != baseline_sha256: + raise StagingError("adoption recovery journal baseline bytes do not match") + original_mode = row.get("original_mode") + if original_mode is not None and ( + type(original_mode) is not int + or original_mode < 0 + or original_mode > 0o7777 + ): + raise StagingError("adoption recovery journal has an invalid file mode") + raw_file_id = row.get("original_file_id") + file_id = None + if raw_file_id is not None: + if not ( + isinstance(raw_file_id, list) + and len(raw_file_id) == 2 + and all(type(value) is int and value >= 0 for value in raw_file_id) + ): + raise StagingError("adoption recovery journal has an invalid file id") + file_id = (raw_file_id[0], raw_file_id[1]) + if original is None: + if ( + original_mode is not None + or file_id is not None + or baseline_sha256 != "" + or backup_path != "" + ): + raise StagingError( + "adoption recovery journal absent target has metadata" + ) + elif original_mode is None or file_id is None or not backup_path: + raise StagingError( + "adoption recovery journal existing target is missing metadata" + ) + if not isinstance(backup_path, str): + raise StagingError("adoption recovery journal has an invalid backup path") + if backup_path and not _path_is_within(backup_path, staging_dir): + raise StagingError("adoption recovery journal backup escapes staging") + if kind == "skills": + name = _safe_skill_name(os.path.basename(os.path.dirname(live))) + expected_key = f"skill {name!r}" if name else "" + expected_backup = os.path.join( + staging_dir, "backup", "skills", name, "SKILL.md" + ) if name else "" + if ( + expected_basename != "SKILL.md" + or key != expected_key + or raw_created_dirs + ): + raise StagingError("adoption recovery journal skill target is invalid") else: - with open(live, "wb") as f: - f.write(original) + label = "skill" if expected_basename == "SKILL.md" else "memory" + expected_key = f"legacy {label}" + expected_backup = os.path.join(staging_dir, "backup", expected_basename) + if key != expected_key: + raise StagingError("adoption recovery journal legacy target is invalid") + derived_backup = expected_backup if original is not None else "" + if _path_identity_key(backup_path) != _path_identity_key(derived_backup): + raise StagingError("adoption recovery journal backup is not derived") + if not isinstance(raw_created_dirs, list) or any( + not isinstance(path, str) or not os.path.isabs(path) + for path in raw_created_dirs + ): + raise StagingError("adoption recovery journal has invalid created directories") + if raw_created_dirs: + if _path_identity_key(raw_created_dirs[-1]) != _path_identity_key( + os.path.dirname(live) + ) or any( + _path_identity_key(os.path.dirname(child)) + != _path_identity_key(parent) + for parent, child in zip(raw_created_dirs, raw_created_dirs[1:]) + ): + raise StagingError( + "adoption recovery journal created directories are not derived" + ) + if wal.get("version") == _WAL_VERSION: + if ( + not isinstance(raw_created_dir_ids, list) + or len(raw_created_dir_ids) != len(raw_created_dirs) + ): + raise StagingError( + "adoption recovery journal created directory identities are invalid" + ) + created_dir_ids: List[Optional[tuple[int, int]]] = [] + for raw_id in raw_created_dir_ids: + if raw_id is None: + created_dir_ids.append(None) + elif ( + isinstance(raw_id, list) + and len(raw_id) == 2 + and all(type(value) is int and value >= 0 for value in raw_id) + ): + created_dir_ids.append((raw_id[0], raw_id[1])) + else: + raise StagingError( + "adoption recovery journal created directory identity is invalid" + ) + else: + created_dir_ids = [None] * len(raw_created_dirs) + targets.append(_TransactionTarget( + key=key, + live_path=live, + expected_realpath=expected_realpath, + expected_basename=expected_basename, + proposed_bytes=b"", + proposed_sha256=proposed_sha256, + original_bytes=original, + original_mode=original_mode, + original_file_id=file_id, + baseline_sha256=baseline_sha256, + backup_path=backup_path, + created_dirs=tuple(raw_created_dirs), + created_dir_ids=tuple(created_dir_ids), + )) + return targets + + +def _decode_wal_receipt( + staging_dir: str, + wal: Dict[str, Any], +) -> tuple[ + str, + Optional[bytes], + Optional[int], + Optional[tuple[int, int]], + str, + str, +]: + row = wal.get("receipt") + if not isinstance(row, dict): + raise StagingError("adoption recovery journal receipt must be an object") + if set(row) != { + "path", + "original_b64", + "original_mode", + "original_file_id", + "baseline_sha256", + "proposed_sha256", + }: + raise StagingError("adoption recovery journal receipt has an invalid schema") + path = row.get("path") + kind = wal.get("kind") + if kind not in {"skills", "legacy"}: + raise StagingError("adoption recovery journal has an invalid transaction kind") + expected_name = ( + "adopted_skills.json" if kind == "skills" else "adopted_legacy.json" + ) + expected_path = os.path.join(staging_dir, expected_name) + if not isinstance(path, str) or _path_identity_key(path) != _path_identity_key(expected_path): + raise StagingError("adoption recovery journal has an invalid receipt path") + original = _from_b64(row.get("original_b64"), field="receipt") + mode = row.get("original_mode") + if mode is not None and ( + type(mode) is not int or mode < 0 or mode > 0o7777 + ): + raise StagingError("adoption recovery journal has an invalid receipt mode") + raw_file_id = row.get("original_file_id") + file_id = None + if raw_file_id is not None: + if not ( + isinstance(raw_file_id, list) + and len(raw_file_id) == 2 + and all(type(value) is int and value >= 0 for value in raw_file_id) + ): + raise StagingError( + "adoption recovery journal has an invalid receipt file id" + ) + file_id = (raw_file_id[0], raw_file_id[1]) + if original is None and (mode is not None or file_id is not None): + raise StagingError("adoption recovery journal absent receipt has metadata") + if original is not None and (mode is None or file_id is None): + raise StagingError( + "adoption recovery journal existing receipt is missing metadata" + ) + baseline = row.get("baseline_sha256") + proposed = row.get("proposed_sha256") + if _bytes_sha256(original) != baseline or not _valid_sha256_pin(proposed): + raise StagingError("adoption recovery journal has invalid receipt hashes") + return path, original, mode, file_id, baseline, proposed + + +def _ensure_safe_directory(path: str, staging_dir: str) -> None: + """Create one staging descendant and reject symlinks and junction escapes.""" + if not _path_is_within(path, staging_dir): + raise StagingError(f"backup directory escapes staging: {path}") + created = False + try: + os.mkdir(path, 0o700) + created = True + except FileExistsError: + pass + info = os.lstat(path) + if _is_link_or_junction(path) or not stat.S_ISDIR(info.st_mode): + raise StagingError(f"backup directory is unsafe: {path}") + staging_real = os.path.realpath(staging_dir) + path_real = os.path.realpath(path) + if not _path_is_within(path_real, staging_real): + raise StagingError(f"backup directory escapes staging through a junction: {path}") + if created: + _fsync_parent(path) + _fsync_directory(path) + + +def _prepare_backup_parent(backup_path: str, staging_dir: str) -> None: + relative = os.path.relpath(os.path.dirname(backup_path), staging_dir) + if relative == os.pardir or relative.startswith(os.pardir + os.sep): + raise StagingError(f"backup path escapes staging: {backup_path}") + current = staging_dir + for component in relative.split(os.sep): + if not component or component == os.curdir: + continue + current = os.path.join(current, component) + _ensure_safe_directory(current, staging_dir) -def _restore_receipt_bytes(path: str, original: Optional[bytes]) -> None: - """Put ``adopted_skills.json`` back without leaving a half-written file.""" - if original is not None: - directory = os.path.dirname(path) or "." - fd, tmp = tempfile.mkstemp(dir=directory, prefix=".tmp-", suffix=".json") - try: - with os.fdopen(fd, "wb") as f: - f.write(original) - f.flush() - os.fsync(f.fileno()) - os.replace(tmp, path) - except BaseException: - if os.path.exists(tmp): - os.unlink(tmp) - raise +def _recover_target( + target: _TransactionTarget, + *, + expected_proposal_file_id: Optional[tuple[int, int]] = None, +) -> None: + if target.original_bytes is None and not os.path.lexists(target.live_path): + current_realpath = _canonical_live_path(target.live_path) + if ( + not current_realpath + or _path_identity_key(current_realpath) + != _path_identity_key(target.expected_realpath) + or _path_identity_key(current_realpath) + != _path_identity_key(target.live_path) + ): + raise StagingError( + f"recovery conflict for {target.key}: target path identity changed" + ) + return + _adopt_target_ok( + target.key, + target.live_path, + target.expected_realpath, + expected_basename=target.expected_basename, + ) + current, current_mode, current_file_id = _file_snapshot(target.live_path) + current_sha256 = _bytes_sha256(current) + if current_sha256 == target.baseline_sha256: + if current is not None and not _modes_match( + current_mode, target.original_mode + ): + raise StagingError( + f"recovery conflict for {target.key}: baseline mode changed" + ) + return + if current_sha256 != target.proposed_sha256: + raise StagingError( + f"recovery conflict for {target.key}: live content is neither baseline nor proposal" + ) + if ( + expected_proposal_file_id is not None + and current_file_id != expected_proposal_file_id + ): + raise StagingError( + f"recovery conflict for {target.key}: proposal identity changed" + ) + proposal_mode = target.original_mode if target.original_mode is not None else 0o600 + if not _modes_match(current_mode, proposal_mode): + raise StagingError( + f"recovery conflict for {target.key}: proposal mode changed" + ) + # Narrow the rollback check/use window. Cooperative adopters also hold the + # target lock; an uncooperative editor is detected whenever it wins before + # this final read. + final, final_mode, final_file_id = _file_snapshot(target.live_path) + if ( + _bytes_sha256(final) != target.proposed_sha256 + or final_file_id != current_file_id + or not _modes_match(final_mode, current_mode) + ): + raise StagingError(f"recovery conflict for {target.key}: live content changed") + if target.original_bytes is None: + _unlink_fsync(target.live_path) + else: + _write_atomic_bytes( + target.live_path, + target.original_bytes, + create_parents=False, + mode=target.original_mode, + ) + + +def _recover_receipt( + path: str, + original: Optional[bytes], + original_mode: Optional[int], + _original_file_id: Optional[tuple[int, int]], + baseline_sha256: str, + proposed_sha256: str, +) -> None: + current, current_mode, current_file_id = _file_snapshot(path) + current_sha256 = _bytes_sha256(current) + if current_sha256 == baseline_sha256: + if current is not None and not _modes_match(current_mode, original_mode): + raise StagingError("recovery conflict: adoption receipt mode changed") return - if os.path.isfile(path): - os.unlink(path) + if current_sha256 != proposed_sha256: + raise StagingError( + "recovery conflict: adoption receipt is neither baseline nor transaction receipt" + ) + proposal_mode = original_mode if original_mode is not None else 0o600 + if not _modes_match(current_mode, proposal_mode): + raise StagingError("recovery conflict: adoption receipt mode changed") + final, final_mode, final_file_id = _file_snapshot(path) + if ( + _bytes_sha256(final) != proposed_sha256 + or final_file_id != current_file_id + or not _modes_match(final_mode, current_mode) + ): + raise StagingError("recovery conflict: adoption receipt changed") + if original is None: + _unlink_fsync(path) + else: + _write_atomic_bytes(path, original, mode=original_mode) + + +def _cleanup_transaction_backups( + targets: Sequence[_TransactionTarget], staging_dir: str +) -> List[str]: + errors: List[str] = [] + for target in reversed(targets): + path = target.backup_path + if not path or not os.path.lexists(path): + continue + try: + snapshot = _immutable_backup_snapshot( + path, staging_dir, repair_temp_aliases=True + ) + if snapshot is None: + raise StagingError(f"transaction backup is not a regular file: {path}") + backup_sha256, file_id = snapshot + if backup_sha256 != target.baseline_sha256: + raise StagingError(f"transaction backup changed during recovery: {path}") + current = os.lstat(path) + if ( + (current.st_dev, current.st_ino) != file_id + or current.st_nlink != 1 + or not _existing_path_is_canonical_staging_descendant( + path, staging_dir + ) + ): + raise StagingError(f"transaction backup identity changed: {path}") + _unlink_fsync(path) + except BaseException as exc: + errors.append(f"backup {path}: {type(exc).__name__}: {exc}") + return errors + + +def _cleanup_created_directories( + targets: Sequence[_TransactionTarget], +) -> List[str]: + """Remove only empty directories whose creation identity was journaled.""" + identities: Dict[str, tuple[str, Optional[tuple[int, int]]]] = {} + ordered: List[str] = [] + for target in targets: + ids = target.created_dir_ids or tuple(None for _path in target.created_dirs) + for path, file_id in zip(target.created_dirs, ids): + key = _path_identity_key(path) + existing = identities.get(key) + if existing is not None and existing[1] != file_id: + return [f"created directory {path}: journal identities disagree"] + if existing is None: + identities[key] = (path, file_id) + ordered.append(key) + + errors: List[str] = [] + for key in reversed(ordered): + path, expected_id = identities[key] + if expected_id is None or not os.path.lexists(path): + # Old journals and crashes before the identity rewrite fail closed. + continue + try: + info = os.lstat(path) + if ( + _is_link_or_junction(path) + or not stat.S_ISDIR(info.st_mode) + or (info.st_dev, info.st_ino) != expected_id + ): + raise StagingError( + f"created directory identity changed during recovery: {path}" + ) + os.rmdir(path) + _fsync_parent(path) + except BaseException as exc: + errors.append( + f"created directory {path}: {type(exc).__name__}: {exc}" + ) + return errors + + +def _recover_transaction_locked( + staging_dir: str, + wal: Dict[str, Any], + *, + expected_proposal_file_ids: Optional[ + Dict[str, tuple[int, int]] + ] = None, +) -> List[str]: + """Idempotently roll back one WAL while its staging and target locks are held.""" + try: + targets = _decode_wal_targets(staging_dir, wal) + receipt = _decode_wal_receipt(staging_dir, wal) + except BaseException as exc: + return [f"invalid recovery journal: {type(exc).__name__}: {exc}"] + errors: List[str] = [] + for target in reversed(targets): + try: + _recover_target( + target, + expected_proposal_file_id=( + expected_proposal_file_ids or {} + ).get(target.key), + ) + except BaseException as exc: + errors.append(f"target {target.key}: {type(exc).__name__}: {exc}") + try: + _recover_receipt(*receipt) + except BaseException as exc: + errors.append(f"receipt: {type(exc).__name__}: {exc}") + if errors: + return errors + errors.extend(_cleanup_transaction_backups(targets, staging_dir)) + if errors: + return errors + errors.extend(_cleanup_created_directories(targets)) + if errors: + return errors + try: + _remove_transaction_wal(staging_dir, expected_wal=wal) + except BaseException as exc: + errors.append(f"journal removal: {type(exc).__name__}: {exc}") + return errors + + +def _verify_published_targets( + targets: Sequence[_TransactionTarget], + published_file_ids: Dict[str, tuple[int, int]], +) -> None: + """Verify the complete live set immediately around receipt publication. + + Portable filesystems do not expose a conditional replace operation, so the + final check-to-commit microgap is unavoidable. Rechecking the whole set on + both sides of receipt publication nevertheless detects edits made while + later targets or the receipt itself were being written. + """ + for target in targets: + _adopt_target_ok( + target.key, + target.live_path, + target.expected_realpath, + expected_basename=target.expected_basename, + ) + current, current_mode, current_file_id = _file_snapshot(target.live_path) + proposal_mode = ( + target.original_mode if target.original_mode is not None else 0o600 + ) + if ( + _bytes_sha256(current) != target.proposed_sha256 + or not _modes_match(current_mode, proposal_mode) + or current_file_id != published_file_ids.get(target.key) + ): + raise StagingError( + f"live target for {target.key} changed after publication" + ) + + +def _execute_transaction_locked( + staging_dir: str, + *, + kind: str, + targets: Sequence[_TransactionTarget], + receipt_path: str, + receipt_original: Optional[bytes], + receipt_mode: Optional[int], + receipt_file_id: Optional[tuple[int, int]], + receipt_after: bytes, +) -> None: + wal = _transaction_wal( + kind=kind, + targets=targets, + receipt_path=receipt_path, + receipt_original=receipt_original, + receipt_mode=receipt_mode, + receipt_file_id=receipt_file_id, + receipt_after=receipt_after, + ) + _write_transaction_wal(staging_dir, wal) + published_file_ids: Dict[str, tuple[int, int]] = {} + try: + planned_directories: List[str] = [] + seen_directories: set[str] = set() + for target in targets: + for directory in target.created_dirs: + key = _path_identity_key(directory) + if key not in seen_directories: + seen_directories.add(key) + planned_directories.append(directory) + for directory in planned_directories: + try: + os.mkdir(directory, 0o700) + _fsync_parent(directory) + _fsync_directory(directory) + except FileExistsError as exc: + raise StagingError( + f"live directory appeared during adoption: {directory}" + ) from exc + info = os.lstat(directory) + if _is_link_or_junction(directory) or not stat.S_ISDIR(info.st_mode): + raise StagingError( + f"live directory changed during adoption: {directory}" + ) + created_id = (info.st_dev, info.st_ino) + for target in targets: + ids = list( + target.created_dir_ids + or tuple(None for _path in target.created_dirs) + ) + for index, path in enumerate(target.created_dirs): + if _path_identity_key(path) == _path_identity_key(directory): + ids[index] = created_id + target.created_dir_ids = tuple(ids) + replacement_wal = _transaction_wal( + kind=kind, + targets=targets, + receipt_path=receipt_path, + receipt_original=receipt_original, + receipt_mode=receipt_mode, + receipt_file_id=receipt_file_id, + receipt_after=receipt_after, + ) + previous_wal = wal + wal = replacement_wal + _rewrite_transaction_wal( + staging_dir, + expected_wal=previous_wal, + replacement_wal=replacement_wal, + ) + + for target in targets: + if target.backup_path: + _prepare_backup_parent(target.backup_path, staging_dir) + _write_new_bytes( + target.backup_path, + target.original_bytes or b"", + mode=target.original_mode, + ) + backup_sha256 = _immutable_backup_sha256( + target.backup_path, staging_dir + ) + if backup_sha256 != target.baseline_sha256: + raise StagingError( + f"immutable backup for {target.key} was not published safely" + ) + _adopt_target_ok( + target.key, + target.live_path, + target.expected_realpath, + expected_basename=target.expected_basename, + ) + current, current_mode, current_file_id = _file_snapshot(target.live_path) + if ( + _bytes_sha256(current) != target.baseline_sha256 + or current_file_id != target.original_file_id + or not _modes_match(current_mode, target.original_mode) + ): + raise StagingError( + f"live target for {target.key} changed bytes, mode, or identity; " + "discard and rerun this night" + ) + # This final snapshot narrows, but cannot portably eliminate, the + # pre-replace editor race: Python exposes no cross-platform + # compare-and-swap rename. The whole published set is checked again + # on both sides of receipt publication below. + _write_atomic( + target.live_path, + target.proposed_bytes.decode("utf-8"), + create_parents=False, + ) + current, current_mode, current_file_id = _file_snapshot( + target.live_path + ) + proposal_mode = ( + target.original_mode if target.original_mode is not None else 0o600 + ) + if ( + _bytes_sha256(current) != target.proposed_sha256 + or not _modes_match(current_mode, proposal_mode) + ): + raise StagingError( + f"live target for {target.key} changed during publication" + ) + if current_file_id is None: + raise StagingError( + f"live target for {target.key} disappeared during publication" + ) + published_file_ids[target.key] = current_file_id + _verify_published_targets(targets, published_file_ids) + receipt_current, receipt_current_mode, receipt_current_file_id = ( + _file_snapshot(receipt_path) + ) + if ( + _bytes_sha256(receipt_current) != _bytes_sha256(receipt_original) + or receipt_current_file_id != receipt_file_id + or not _modes_match(receipt_current_mode, receipt_mode) + ): + raise StagingError( + "adoption receipt changed bytes, mode, or identity during adoption" + ) + _write_atomic( + receipt_path, + receipt_after.decode("utf-8"), + create_parents=False, + ) + receipt_current, receipt_current_mode, _receipt_file_id = _file_snapshot( + receipt_path + ) + receipt_proposal_mode = receipt_mode if receipt_mode is not None else 0o600 + if ( + _bytes_sha256(receipt_current) != hashlib.sha256(receipt_after).hexdigest() + or not _modes_match(receipt_current_mode, receipt_proposal_mode) + ): + raise StagingError("adoption receipt changed during publication") + _verify_published_targets(targets, published_file_ids) + # WAL unlink + parent fsync is the transaction commit point. + _remove_transaction_wal(staging_dir, expected_wal=wal) + except BaseException as primary: + recovery_errors = _recover_transaction_locked( + staging_dir, + wal, + expected_proposal_file_ids=published_file_ids, + ) + if recovery_errors: + if not os.path.lexists(_wal_path(staging_dir)): + try: + _write_transaction_wal(staging_dir, wal) + except BaseException as exc: + recovery_errors.append( + f"could not restore recovery journal: {type(exc).__name__}: {exc}" + ) + raise StagingRecoveryError( + "adoption failed and automatic rollback was incomplete; " + f"journal and backups retained at {staging_dir}", + primary=primary, + recovery_errors=recovery_errors, + ) from primary + raise + + +@contextmanager +def _adoption_locks(staging_dir: str, live_paths: Sequence[str]): + staging_lock = os.path.join(staging_dir, ".adopt-skills.lock") + with _exclusive_create_locks([staging_lock]): + wal = _read_transaction_wal(staging_dir) + recovery_paths: List[str] = [] + if wal is not None: + recovery_paths = [ + target.live_path for target in _decode_wal_targets(staging_dir, wal) + ] + all_paths = list(live_paths) + recovery_paths + with _exclusive_create_locks(_target_lock_paths(all_paths)): + if wal is not None: + recovery_errors = _recover_transaction_locked(staging_dir, wal) + if recovery_errors: + raise StagingRecoveryError( + "cannot recover an interrupted adoption; journal and backups retained", + recovery_errors=recovery_errors, + ) + yield + + +def _recover_before_manifest(staging_dir: str) -> None: + """Recover a WAL without depending on a still-readable staging manifest.""" + if _is_link_or_junction(staging_dir) or not os.path.isdir(staging_dir): + raise StagingError(f"staging directory is unsafe: {staging_dir}") + staging_lock = os.path.join(staging_dir, ".adopt-skills.lock") + with _exclusive_create_locks([staging_lock]): + wal = _read_transaction_wal(staging_dir) + if wal is None: + return + targets = _decode_wal_targets(staging_dir, wal) + with _exclusive_create_locks( + _target_lock_paths([target.live_path for target in targets]) + ): + recovery_errors = _recover_transaction_locked(staging_dir, wal) + if recovery_errors: + raise StagingRecoveryError( + "cannot recover an interrupted adoption; journal and backups retained", + recovery_errors=recovery_errors, + ) def adopt_skills( staging_dir: str, skill_names: Optional[Sequence[str]] = None ) -> List[AdoptedSkill]: - """Adopt an explicitly reviewed subset of staged per-skill proposals. - - ``skill_names`` selects which staged skills to adopt; ``None`` means every - staged skill. Nothing is adopted implicitly and skills outside the selection - are never touched. - - Every selected proposal is validated first, including a second uniqueness - and live-target check against the **whole** current manifest, a sha256 pin - of the staged file, and a live-path layout check. Each live file is backed - up, and the writes — including ``adopted_skills.json`` — are rolled back as - a set if any one of them fails, so a partial adoption never survives. - Returns a before/after sha256 receipt per skill and also writes them to - ``adopted_skills.json`` in the staging directory. - """ - all_rows = staged_skills(staging_dir) - rows = _selected_rows(all_rows, skill_names) - if not rows: + """Durably adopt a reviewed per-skill subset with restart-safe rollback.""" + staging_dir = _canonical_staging_dir(staging_dir) + _recover_before_manifest(staging_dir) + initial_all_rows = staged_skills(staging_dir) + initial_rows = _selected_rows(initial_all_rows, skill_names) + if not initial_rows: return [] - _revalidate_selected_skill_rows(rows, all_rows=all_rows) - - plan: List[tuple] = [] - for row in rows: - name = _safe_skill_name(row.get("skill_name")) - if not name: - raise StagingError(f"unsafe staged skill name: {row.get('skill_name')!r}") + initial_live_paths: List[str] = [] + for row in initial_rows: live = _safe_live_path(row.get("live_skill_path")) if not live: raise StagingError( - f"unsafe live skill path for {name!r}: {row.get('live_skill_path')!r}" + f"unsafe live skill path: {row.get('live_skill_path')!r}" ) - _adopt_live_target_ok(name, live) - proposed_file = row.get("proposed_file") - expected_file = proposal_filename(name) - if proposed_file != expected_file: - raise StagingError( - f"unsafe staged proposal filename for {name!r}: {proposed_file!r}; " - f"expected {expected_file!r}" - ) - staged = os.path.join(staging_dir, expected_file) - if not os.path.isfile(staged): - raise StagingError(f"staged proposal missing for {name!r}: {staged}") - with open(staged, encoding="utf-8") as f: - proposed = f.read() - pin = row.get("sha256") - if not _valid_sha256_pin(pin): - raise StagingError(f"staged proposal for {name!r} is missing a sha256 pin") - if _sha256_text(proposed) != pin: - raise StagingError( - f"staged proposal for {name!r} does not match its manifest sha256" + initial_live_paths.append(live) + + with _adoption_locks(staging_dir, initial_live_paths): + all_rows = staged_skills(staging_dir) + rows = _selected_rows(all_rows, skill_names) + locked_paths = {_path_identity_key(path) for path in initial_live_paths} + current_paths = { + _path_identity_key(str(row.get("live_skill_path") or "")) + for row in rows + } + if locked_paths != current_paths: + raise StagingError("staging manifest changed while adoption was locking") + _revalidate_selected_skill_rows(rows, all_rows=all_rows) + + receipt_path = os.path.join(staging_dir, "adopted_skills.json") + existing_receipts, receipt_original, receipt_mode, receipt_file_id = ( + _read_existing_receipts(receipt_path, staging_dir) + ) + already_adopted = { + str(row["skill_name"]) for row in existing_receipts + } + + targets: List[_TransactionTarget] = [] + receipts: List[AdoptedSkill] = [] + backup_dir = os.path.join(staging_dir, "backup", "skills") + for row in rows: + name = _safe_skill_name(row.get("skill_name")) + if not name: + raise StagingError( + f"unsafe staged skill name: {row.get('skill_name')!r}" + ) + if name in already_adopted: + raise StagingError( + f"staged skill {name!r} was already adopted from this night" + ) + live = _safe_live_path(row.get("live_skill_path")) + expected_realpath = _safe_live_path(row.get("live_realpath")) + live_pin = row.get("live_sha256") + if not live or not expected_realpath or ( + live_pin != "" and not _valid_sha256_pin(live_pin) + ): + raise StagingError( + f"staged skill {name!r} is missing safe live baseline pins; " + "discard and restage this night" + ) + _adopt_live_target_ok(name, live, expected_realpath) + + expected_file = proposal_filename(name) + if row.get("proposed_file") != expected_file: + raise StagingError( + f"unsafe staged proposal filename for {name!r}: " + f"{row.get('proposed_file')!r}; expected {expected_file!r}" + ) + staged = os.path.join(staging_dir, expected_file) + if _is_link_or_junction(staged) or not os.path.isfile(staged): + raise StagingError( + f"staged proposal for {name!r} is missing or a symlink: {staged}" + ) + try: + with open(staged, "rb") as handle: + proposed_bytes = handle.read() + proposed_bytes.decode("utf-8") + except UnicodeDecodeError as exc: + raise StagingError( + f"staged proposal for {name!r} must be valid UTF-8" + ) from exc + except OSError as exc: + raise StagingError( + f"could not read staged proposal for {name!r}: {staged}" + ) from exc + pin = row.get("sha256") + if not _valid_sha256_pin(pin): + raise StagingError( + f"staged proposal for {name!r} is missing a sha256 pin" + ) + if hashlib.sha256(proposed_bytes).hexdigest() != pin: + raise StagingError( + f"staged proposal for {name!r} does not match its manifest sha256" + ) + if not proposed_bytes.strip(): + raise StagingError(f"staged proposal for {name!r} is empty") + + original, original_mode, original_file_id = _file_snapshot(live) + if _bytes_sha256(original) != live_pin: + raise StagingError( + f"live skill for {name!r} changed since staging; " + "discard and rerun this night" + ) + backup_path = ( + os.path.join(backup_dir, name, "SKILL.md") + if original is not None + else "" ) - if not proposed.strip(): - raise StagingError(f"staged proposal for {name!r} is empty") - plan.append((name, live, proposed)) - - backup_dir = os.path.join(staging_dir, "backup", "skills") - receipts: List[AdoptedSkill] = [] - done: List[tuple] = [] # (live, original_bytes or None) for rollback - receipt_path = os.path.join(staging_dir, "adopted_skills.json") - receipt_original = None - if os.path.isfile(receipt_path): - with open(receipt_path, "rb") as f: - receipt_original = f.read() - try: - for name, live, proposed in plan: - original = None - backup_path = "" - if os.path.exists(live): - with open(live, "rb") as f: - original = f.read() - skill_backup = os.path.join(backup_dir, name) - os.makedirs(skill_backup, exist_ok=True) - backup_path = os.path.join(skill_backup, os.path.basename(live)) - shutil.copy2(live, backup_path) - before = hashlib.sha256(original).hexdigest() if original is not None else "" - _write_atomic(live, proposed, create_parents=False) - done.append((live, original)) + if backup_path and os.path.lexists(backup_path): + raise StagingError( + f"immutable backup already exists for {name!r}: {backup_path}" + ) + targets.append(_TransactionTarget( + key=f"skill {name!r}", + live_path=live, + expected_realpath=expected_realpath, + expected_basename="SKILL.md", + proposed_bytes=proposed_bytes, + proposed_sha256=pin, + original_bytes=original, + original_mode=original_mode, + original_file_id=original_file_id, + baseline_sha256=live_pin, + backup_path=backup_path, + )) receipts.append(AdoptedSkill( - skill_name=name, live_skill_path=live, sha256_before=before, - sha256_after=_sha256_text(proposed), backup_path=backup_path, + skill_name=name, + live_skill_path=live, + sha256_before=live_pin, + sha256_after=pin, + backup_path=backup_path, )) - _write_atomic( - receipt_path, - json.dumps([r.__dict__ for r in receipts], ensure_ascii=False, indent=2), + + combined_receipts = existing_receipts + [ + receipt.__dict__ for receipt in receipts + ] + receipt_after = json.dumps( + combined_receipts, + ensure_ascii=False, + indent=2, + ).encode("utf-8") + _execute_transaction_locked( + staging_dir, + kind="skills", + targets=targets, + receipt_path=receipt_path, + receipt_original=receipt_original, + receipt_mode=receipt_mode, + receipt_file_id=receipt_file_id, + receipt_after=receipt_after, ) - except BaseException: - _restore_live_writes(done) - _restore_receipt_bytes(receipt_path, receipt_original) - raise - return receipts + return receipts -def _backup(path: str, backup_dir: str) -> None: - if os.path.exists(path): - os.makedirs(backup_dir, exist_ok=True) - shutil.copy2(path, os.path.join(backup_dir, os.path.basename(path))) +def _load_manifest(staging_dir: str) -> Dict[str, Any]: + path = os.path.join(staging_dir, "manifest.json") + if _is_link_or_junction(path): + raise StagingError("staging manifest must not be a symlink") + try: + with open(path, encoding="utf-8") as handle: + payload = json.load(handle) + except (OSError, UnicodeError, json.JSONDecodeError, ValueError) as exc: + raise StagingError(f"cannot read staging manifest: {exc}") from exc + if not isinstance(payload, dict): + raise StagingError("staging manifest must be a JSON object") + _manifest_schema_version(payload) + return payload -def adopt(staging_dir: str) -> List[str]: - """Copy staged proposals over the live files, backing up first. +def _legacy_rows(manifest: Dict[str, Any]) -> Dict[str, Dict[str, Any]]: + version = _manifest_schema_version(manifest) + if version == _MANIFEST_VERSION: + if ( + manifest.get("has_skill") is not False + or manifest.get("has_memory") is not False + ): + raise StagingError( + "versioned staging manifest has unsafe legacy compatibility flags" + ) + skill_flag = manifest.get("has_managed_skill") + memory_flag = manifest.get("has_managed_memory") + if type(skill_flag) is not bool or type(memory_flag) is not bool: + raise StagingError("versioned staging manifest has invalid managed flags") + flags = (("skill", skill_flag), ("memory", memory_flag)) + if not skill_flag and not memory_flag: + return {} + else: + flags = (("skill", bool(manifest.get("has_skill"))), + ("memory", bool(manifest.get("has_memory")))) + raw = manifest.get("legacy") + if not isinstance(raw, dict): + raise StagingError( + "legacy staging manifest is missing integrity pins; discard and restage" + ) + rows: Dict[str, Dict[str, Any]] = {} + for label, present in flags: + if not present: + continue + row = raw.get(label) + if not isinstance(row, dict): + raise StagingError( + f"legacy {label} staging row is missing; discard and restage" + ) + rows[label] = row + if not rows: + return {} + return rows - Returns the list of live paths that were updated. - """ - with open(os.path.join(staging_dir, "manifest.json")) as f: - manifest = json.load(f) - backup_dir = os.path.join(staging_dir, "backup") - updated: List[str] = [] - - if manifest.get("has_skill"): - live = manifest["live_skill_path"] - os.makedirs(os.path.dirname(live), exist_ok=True) - _backup(live, backup_dir) - shutil.copy2(os.path.join(staging_dir, "proposed_SKILL.md"), live) - updated.append(live) - if manifest.get("has_memory"): - live = manifest["live_memory_path"] - os.makedirs(os.path.dirname(live), exist_ok=True) - _backup(live, backup_dir) - shutil.copy2(os.path.join(staging_dir, "proposed_CLAUDE.md"), live) - updated.append(live) - return updated + +def _read_legacy_receipts( + path: str, + staging_dir: str, +) -> tuple[ + List[Dict[str, Any]], + Optional[bytes], + Optional[int], + Optional[tuple[int, int]], +]: + payload, original, mode, file_id = _read_receipt_file(path) + seen: set[str] = set() + schema = { + "target", + "live_path", + "sha256_before", + "sha256_after", + "backup_path", + } + for row in payload: + if set(row) != schema: + raise StagingError("existing legacy adoption receipt has an invalid schema") + label = row.get("target") + live = _safe_live_path(row.get("live_path")) + before = row.get("sha256_before") + after = row.get("sha256_after") + backup = row.get("backup_path") + if label not in {"skill", "memory"} or label in seen: + raise StagingError("existing legacy adoption receipt is invalid") + seen.add(label) + expected_basename = "SKILL.md" if label == "skill" else "CLAUDE.md" + if ( + not live + or os.path.basename(live) != expected_basename + or (before != "" and not _valid_sha256_pin(before)) + ): + raise StagingError("existing legacy adoption receipt is invalid") + if not _valid_sha256_pin(after) or not isinstance(backup, str): + raise StagingError("existing legacy adoption receipt is invalid") + if before == "": + if backup: + raise StagingError("existing legacy adoption receipt has an unexpected backup") + continue + expected = os.path.join( + staging_dir, + "backup", + "SKILL.md" if label == "skill" else "CLAUDE.md", + ) + if _path_identity_key(backup) != _path_identity_key(expected): + raise StagingError("existing legacy adoption receipt has an invalid backup") + backup_sha256 = _immutable_backup_sha256(expected, staging_dir) + if backup_sha256 is None: + raise StagingError("immutable legacy backup is missing") + if backup_sha256 != before: + raise StagingError("immutable legacy backup changed") + return payload, original, mode, file_id + + +def adopt(staging_dir: str) -> List[str]: + """Durably adopt the pinned legacy SKILL.md/CLAUDE.md proposal pair.""" + staging_dir = _canonical_staging_dir(staging_dir) + _recover_before_manifest(staging_dir) + initial_manifest = _load_manifest(staging_dir) + initial_rows = _legacy_rows(initial_manifest) + if not initial_rows: + return [] + initial_paths: List[str] = [] + for row in initial_rows.values(): + live = _safe_live_path(row.get("live_path")) + if not live: + raise StagingError("legacy staging row has an unsafe live path") + initial_paths.append(live) + + with _adoption_locks(staging_dir, initial_paths): + manifest = _load_manifest(staging_dir) + rows = _legacy_rows(manifest) + current_paths = { + _path_identity_key(str(row.get("live_path") or "")) + for row in rows.values() + } + if current_paths != {_path_identity_key(path) for path in initial_paths}: + raise StagingError("legacy staging manifest changed while adoption was locking") + + receipt_path = os.path.join(staging_dir, "adopted_legacy.json") + existing, receipt_original, receipt_mode, receipt_file_id = ( + _read_legacy_receipts(receipt_path, staging_dir) + ) + already = {str(row["target"]) for row in existing} + targets: List[_TransactionTarget] = [] + new_receipts: List[Dict[str, Any]] = [] + updated: List[str] = [] + for label in ("skill", "memory"): + row = rows.get(label) + if row is None: + continue + if label in already: + raise StagingError(f"legacy {label} was already adopted from this night") + expected_file = ( + "proposed_SKILL.md" if label == "skill" else "proposed_CLAUDE.md" + ) + expected_basename = "SKILL.md" if label == "skill" else "CLAUDE.md" + live = _safe_live_path(row.get("live_path")) + expected_realpath = _safe_live_path(row.get("live_realpath")) + live_pin = row.get("live_sha256") + proposal_pin = row.get("sha256") + if ( + row.get("proposed_file") != expected_file + or not live + or not expected_realpath + or (live_pin != "" and not _valid_sha256_pin(live_pin)) + or not _valid_sha256_pin(proposal_pin) + ): + raise StagingError( + f"legacy {label} staging pins are invalid; discard and restage" + ) + created_dirs = _planned_live_directories( + f"legacy {label}", + live, + expected_realpath, + expected_basename=expected_basename, + ) + if not created_dirs: + _adopt_target_ok( + f"legacy {label}", + live, + expected_realpath, + expected_basename=expected_basename, + ) + staged = os.path.join(staging_dir, expected_file) + if _is_link_or_junction(staged) or not os.path.isfile(staged): + raise StagingError(f"legacy {label} proposal is missing or a symlink") + try: + with open(staged, "rb") as handle: + proposed = handle.read() + proposed.decode("utf-8") + except UnicodeDecodeError as exc: + raise StagingError(f"legacy {label} proposal must be valid UTF-8") from exc + if ( + label == "skill" and not proposed.strip() + ) or hashlib.sha256(proposed).hexdigest() != proposal_pin: + raise StagingError(f"legacy {label} proposal does not match its sha256") + original, original_mode, original_file_id = _file_snapshot(live) + if _bytes_sha256(original) != live_pin: + raise StagingError( + f"legacy {label} changed since staging; discard and restage" + ) + backup_path = ( + os.path.join(staging_dir, "backup", expected_basename) + if original is not None + else "" + ) + if backup_path and os.path.lexists(backup_path): + raise StagingError(f"immutable legacy backup already exists: {backup_path}") + targets.append(_TransactionTarget( + key=f"legacy {label}", + live_path=live, + expected_realpath=expected_realpath, + expected_basename=expected_basename, + proposed_bytes=proposed, + proposed_sha256=proposal_pin, + original_bytes=original, + original_mode=original_mode, + original_file_id=original_file_id, + baseline_sha256=live_pin, + backup_path=backup_path, + created_dirs=created_dirs, + )) + new_receipts.append({ + "target": label, + "live_path": live, + "sha256_before": live_pin, + "sha256_after": proposal_pin, + "backup_path": backup_path, + }) + updated.append(live) + + receipt_after = json.dumps( + existing + new_receipts, + ensure_ascii=False, + indent=2, + ).encode("utf-8") + _execute_transaction_locked( + staging_dir, + kind="legacy", + targets=targets, + receipt_path=receipt_path, + receipt_original=receipt_original, + receipt_mode=receipt_mode, + receipt_file_id=receipt_file_id, + receipt_after=receipt_after, + ) + return updated diff --git a/skillopt_sleep/state.py b/skillopt_sleep/state.py index 97f4519c..267adedd 100644 --- a/skillopt_sleep/state.py +++ b/skillopt_sleep/state.py @@ -11,9 +11,10 @@ """ from __future__ import annotations +import copy import json import os -from typing import Any, Dict, List, Optional +from typing import Any, Dict, Optional def _now_iso(clock: Optional[float] = None) -> str: @@ -37,7 +38,10 @@ def _now_iso(clock: Optional[float] = None) -> str: class SleepState: def __init__(self, path: str, data: Optional[Dict[str, Any]] = None) -> None: self.path = path - self.data = data if data is not None else dict(DEFAULT_STATE) + # DEFAULT_STATE contains mutable lists and dicts. A shallow copy makes + # independent projects in one Python process share history, harvest + # cursors, and recalled tasks until they are persisted. + self.data = data if data is not None else copy.deepcopy(DEFAULT_STATE) # io --------------------------------------------------------------------- @classmethod @@ -46,12 +50,12 @@ def load(cls, path: str) -> "SleepState": try: with open(path) as f: data = json.load(f) - merged = dict(DEFAULT_STATE) + merged = copy.deepcopy(DEFAULT_STATE) merged.update(data if isinstance(data, dict) else {}) return cls(path, merged) except Exception: pass - return cls(path, dict(DEFAULT_STATE)) + return cls(path, copy.deepcopy(DEFAULT_STATE)) def save(self) -> None: os.makedirs(os.path.dirname(self.path), exist_ok=True) diff --git a/tests/test_devin_plugin.py b/tests/test_devin_plugin.py index dde3d852..cc2fe637 100644 --- a/tests/test_devin_plugin.py +++ b/tests/test_devin_plugin.py @@ -1,5 +1,7 @@ """Tests for the Devin MCP plugin: tool schema, ATIF-v1.7 harvest, path expansion.""" +import contextlib import importlib +import io import json import os import shlex @@ -9,18 +11,30 @@ import sys import tempfile import unittest +from unittest import mock # Allow importing from the plugin directory (mirrors tests/test_mcp_schema.py) PLUGIN = os.path.join(os.path.dirname(__file__), "..", "plugins", "devin") sys.path.insert(0, PLUGIN) -import mcp_server # noqa: E402 -import harvest_devin as hw # noqa: E402 +import harvest_devin as hw # noqa: E402 +import mcp_server # noqa: E402 FIXTURES = os.path.join(PLUGIN, "fixtures") INSTALLER = os.path.join(PLUGIN, "install.sh") +def _call(name="sleep_status", arguments=None, **params): + call_params = {"name": name, "arguments": {} if arguments is None else arguments} + call_params.update(params) + return { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": call_params, + } + + def _read_jsonl(path): with open(path, encoding="utf-8") as f: return [json.loads(line) for line in f if line.strip()] @@ -58,11 +72,277 @@ def test_schema_has_key_engine_params(self): # parity with plugins/copilot's schema (tests/test_plugin_sync.py) props = set(mcp_server._TOOL_SCHEMA["properties"].keys()) for param in {"project", "backend", "scope", "source", "model", - "tasks_file", "target_skill_path", "max_sessions", + "tasks_file", "target_skill_path", "staging", "skills", + "all_skills", "legacy", "max_sessions", "max_tasks", "lookback_hours", "auto_adopt", "json", "edit_budget", "hour", "minute"}: self.assertIn(param, props) + def test_adopt_selection_schema_types(self): + props = mcp_server._TOOL_SCHEMA["properties"] + self.assertEqual(props["staging"]["type"], "string") + self.assertEqual(props["skills"]["type"], "array") + self.assertEqual(props["skills"]["items"]["type"], "string") + self.assertEqual(props["all_skills"]["type"], "boolean") + self.assertEqual(props["legacy"]["type"], "boolean") + + def test_adopt_forwards_fanout_selection_as_argv_without_sync(self): + completed = subprocess.CompletedProcess( + args=[], returncode=0, stdout="adopted\n", stderr="" + ) + arguments = { + "project": "/tmp/devin workspace", + "staging": "/tmp/night with spaces", + "skills": ["alpha", "--leading-dash", "space ; $(literal)"], + } + with mock.patch.object( + mcp_server.subprocess, "run", return_value=completed + ) as run: + result = mcp_server._run_engine("adopt", arguments) + + self.assertEqual(result.text, "[engine]\nadopted") + self.assertEqual(result.returncode, 0) + run.assert_called_once() + command = run.call_args.args[0] + self.assertEqual( + command[-7:], + [ + "--staging", "/tmp/night with spaces", + "--skill", "alpha", + "--skill=--leading-dash", + "--skill", "space ; $(literal)", + ], + ) + self.assertNotIn("shell", run.call_args.kwargs) + + def test_adoption_never_performs_post_engine_copy(self): + cases = ( + ("bare legacy success", {}, 0), + ("explicit legacy success", {"legacy": True}, 0), + ("bare adoption refused", {}, 2), + ("explicit legacy failed", {"legacy": True}, 1), + ("per-skill success", {"skills": ["alpha"]}, 0), + ("all-skills success", {"all_skills": True}, 0), + ) + for name, selection, returncode in cases: + with self.subTest(name=name): + completed = subprocess.CompletedProcess( + args=[], returncode=returncode, stdout="result", stderr="" + ) + arguments = {"project": "/tmp/devin-workspace", **selection} + with mock.patch.object( + mcp_server.subprocess, "run", return_value=completed + ) as run: + result = mcp_server._run_engine("adopt", arguments) + + command = run.call_args.args[0] + if selection.get("legacy"): + self.assertIn("--legacy", command) + if selection.get("all_skills"): + self.assertIn("--all-skills", command) + self.assertEqual(result.returncode, returncode) + self.assertNotIn("synced", result.text) + + def test_adopt_rejects_non_array_skills_without_spawning(self): + with mock.patch.object(mcp_server.subprocess, "run") as run: + with self.assertRaisesRegex(ValueError, "skills must be an array"): + mcp_server._run_engine("adopt", {"skills": "alpha"}) + + run.assert_not_called() + + +class TestDevinMcpRuntimeValidation(unittest.TestCase): + def test_malformed_request_envelopes_return_json_rpc_errors(self): + cases = ( + (None, "request must be a JSON object"), + ({"method": "ping"}, "jsonrpc must be '2.0'"), + ({"jsonrpc": "2.0", "id": 1, "method": 4}, "method must be"), + ({"jsonrpc": "2.0", "id": 1, "method": "ping", "params": False}, + "params must be an object"), + ({"jsonrpc": "2.0", "id": [], "method": "ping"}, "id must be"), + ({"jsonrpc": "2.0", "id": 1, "method": "ping", "extra": 1}, + "unknown request member"), + ) + for request, message in cases: + with self.subTest(request=request): + response = mcp_server.handle(request) + self.assertEqual(response["error"]["code"], -32600) + self.assertIn(message, response["error"]["message"]) + if type(request) is dict and type(request.get("id")) not in {str, int}: + self.assertIsNone(response["id"]) + + def test_params_and_arguments_require_known_properties_and_objects(self): + cases = ( + (_call(extra=1), "unknown params member"), + (_call(arguments=[]), "arguments must be an object"), + (_call(arguments={"unknown": True}), "unknown argument"), + ) + for request, message in cases: + with self.subTest(request=request), mock.patch.object( + mcp_server.subprocess, "run" + ) as run, mock.patch.object(mcp_server, "_run_harvest") as harvest: + response = mcp_server.handle(request) + self.assertEqual(response["error"]["code"], -32602) + self.assertIn(message, response["error"]["message"]) + run.assert_not_called() + harvest.assert_not_called() + + def test_wrong_scalar_types_and_bounds_are_rejected_before_harvest(self): + cases = ( + ({"auto_adopt": "false"}, "auto_adopt must be a boolean"), + ({"json": "false"}, "json must be a boolean"), + ({"progress": 1}, "progress must be a boolean"), + ({"max_sessions": False}, "max_sessions must be an integer"), + ({"lookback_hours": -1}, "lookback_hours must be between"), + ({"backend": "unknown"}, "unsupported backend"), + ) + for arguments, message in cases: + with self.subTest(arguments=arguments), mock.patch.object( + mcp_server, "_run_harvest" + ) as harvest, mock.patch.object(mcp_server.subprocess, "run") as run: + response = mcp_server.handle(_call("sleep_run", arguments)) + self.assertEqual(response["error"]["code"], -32602) + self.assertIn(message, response["error"]["message"]) + harvest.assert_not_called() + run.assert_not_called() + + def test_schedule_bounds_and_action_specific_arguments_are_rejected(self): + cases = ( + ("sleep_schedule", {"hour": -1}, "hour must be between"), + ("sleep_schedule", {"minute": 60}, "minute must be between"), + ("sleep_status", {"hour": 3}, "valid only for sleep_schedule"), + ("sleep_status", {"legacy": False}, "valid only for sleep_adopt"), + ) + for tool, arguments, message in cases: + with self.subTest(tool=tool, arguments=arguments), mock.patch.object( + mcp_server, "_run_harvest" + ) as harvest, mock.patch.object(mcp_server.subprocess, "run") as run: + response = mcp_server.handle(_call(tool, arguments)) + self.assertEqual(response["error"]["code"], -32602) + self.assertIn(message, response["error"]["message"]) + harvest.assert_not_called() + run.assert_not_called() + + def test_adoption_arrays_and_selection_modes_are_strict(self): + cases = ( + ({"all_skills": "false"}, "all_skills must be a boolean"), + ({"legacy": "false"}, "legacy must be a boolean"), + ({"skills": [None]}, "skills entry must be a string"), + ({"skills": ["\t"]}, "control characters"), + ({"skills": ["alpha", " alpha "]}, "must be unique"), + ({"skills": ["alpha"], "legacy": True}, "choose at most one"), + ) + for arguments, message in cases: + with self.subTest(arguments=arguments), mock.patch.object( + mcp_server.subprocess, "run" + ) as run: + response = mcp_server.handle(_call("sleep_adopt", arguments)) + self.assertEqual(response["error"]["code"], -32602) + self.assertIn(message, response["error"]["message"]) + run.assert_not_called() + + def test_every_string_boolean_and_integer_contract_is_exact_and_bounded(self): + for key in mcp_server._STRING_ARGS: + action = "adopt" if key == "staging" else "status" + with self.subTest(kind="string", key=key), self.assertRaisesRegex( + ValueError, f"{key} must be a string" + ): + mcp_server._validate_tool_arguments(action, {key: 1}) + for key in mcp_server._BOOLEAN_ARGS: + action = "adopt" if key in {"all_skills", "legacy"} else "status" + with self.subTest(kind="boolean", key=key), self.assertRaisesRegex( + ValueError, f"{key} must be a boolean" + ): + mcp_server._validate_tool_arguments(action, {key: "false"}) + for key, (minimum, maximum) in mcp_server._INTEGER_BOUNDS.items(): + action = "schedule" if key in {"hour", "minute"} else "status" + with self.subTest(kind="integer-bool", key=key), self.assertRaisesRegex( + ValueError, f"{key} must be an integer" + ): + mcp_server._validate_tool_arguments(action, {key: True}) + for value in (minimum - 1, maximum + 1): + with self.subTest(kind="integer-bound", key=key, value=value), \ + self.assertRaisesRegex(ValueError, f"{key} must be between"): + mcp_server._validate_tool_arguments(action, {key: value}) + self.assertEqual( + mcp_server._validate_tool_arguments(action, {key: minimum})[key], + minimum, + ) + self.assertEqual( + mcp_server._validate_tool_arguments(action, {key: maximum})[key], + maximum, + ) + + def test_harvest_failure_stops_engine_and_does_not_use_stale_cache(self): + failure = mcp_server.EngineResult("conversion failed", 7, "bad ATIF") + with mock.patch.object( + mcp_server, "_run_harvest", return_value=failure + ) as harvest, mock.patch.object(mcp_server.subprocess, "run") as run: + result = mcp_server._run_engine("status", {}) + + harvest.assert_called_once_with() + run.assert_not_called() + self.assertEqual(result.returncode, 7) + self.assertIn("conversion failed", result.text) + self.assertIn("bad ATIF", result.text) + + def test_harvest_subprocess_returncode_is_preserved(self): + completed = subprocess.CompletedProcess( + args=[], returncode=6, stdout="conversion stopped\n", stderr="bad source\n" + ) + with mock.patch.object(mcp_server.subprocess, "run", return_value=completed): + result = mcp_server._run_harvest() + self.assertEqual(result.returncode, 6) + self.assertEqual(result.text, "conversion stopped") + self.assertEqual(result.diagnostics, "bad source") + + def test_engine_status_maps_to_mcp_error_and_handoff_states(self): + cases = ( + (0, False, "ok"), + (1, True, "error"), + (3, False, "handoff_pending"), + ) + for returncode, is_error, status in cases: + with self.subTest(returncode=returncode), mock.patch.object( + mcp_server, "_run_engine", + return_value=mcp_server.EngineResult("engine output", returncode), + ): + result = mcp_server.handle(_call())["result"] + self.assertIs(result["isError"], is_error) + self.assertEqual(result["structuredContent"]["status"], status) + self.assertEqual(result["structuredContent"]["exit_code"], returncode) + + def test_json_stdout_is_parseable_without_harvest_or_stderr_prefixes(self): + harvest = mcp_server.EngineResult("converted 3 sessions", 0, "harvest note") + engine = subprocess.CompletedProcess( + args=[], returncode=0, stdout='{"nights": 4}\n', stderr="engine note\n" + ) + with mock.patch.object( + mcp_server, "_run_harvest", return_value=harvest + ), mock.patch.object(mcp_server.subprocess, "run", return_value=engine): + run = mcp_server._run_engine("status", {"json": True}) + + self.assertEqual(json.loads(run.text), {"nights": 4}) + self.assertNotIn("harvest", run.text) + self.assertIn("converted 3 sessions", run.diagnostics) + self.assertIn("engine note", run.diagnostics) + + def test_json_tool_result_includes_parsed_structured_output(self): + run = mcp_server.EngineResult('{"pending": true}', 3, "answer prompts") + with mock.patch.object(mcp_server, "_run_engine", return_value=run): + result = mcp_server.handle(_call(arguments={"json": True}))["result"] + self.assertEqual(result["structuredContent"]["output"], {"pending": True}) + self.assertEqual(result["structuredContent"]["status"], "handoff_pending") + self.assertFalse(result["isError"]) + + def test_main_emits_parse_error_for_malformed_json(self): + output = io.StringIO() + with mock.patch.object(sys, "stdin", io.StringIO("not-json\n")), \ + contextlib.redirect_stdout(output): + self.assertEqual(mcp_server.main(), 0) + response = json.loads(output.getvalue()) + self.assertEqual(response["error"]["code"], -32700) + class TestClaudeHomeExpansion(unittest.TestCase): """Regression: ~ must be expanded even when CLAUDE_HOME comes from the env diff --git a/tests/test_handoff_backend.py b/tests/test_handoff_backend.py index 0ecfae1a..b4b85022 100644 --- a/tests/test_handoff_backend.py +++ b/tests/test_handoff_backend.py @@ -7,6 +7,7 @@ import re import tempfile import unittest +from unittest import mock from skillopt_sleep.backend import get_backend from skillopt_sleep.config import load_config @@ -195,6 +196,96 @@ def test_corrupt_digests_pin_falls_back_to_reharvest(self): # must not crash: corrupt pin -> fresh harvest -> no tasks -> 0 self.assertEqual(rc, 0) + def test_completed_run_json_stdout_is_one_parseable_document(self): + import contextlib + import io + + from skillopt_sleep.__main__ import main + from skillopt_sleep.cycle import CycleOutcome + from skillopt_sleep.tasks_file import make_tasks_payload, write_tasks_file + from skillopt_sleep.types import SleepReport + + with tempfile.TemporaryDirectory() as proj, \ + tempfile.TemporaryDirectory() as home: + tasks_path = os.path.join(proj, "tasks.json") + payload = make_tasks_payload(_tasks(), project=proj) + payload["reviewed"] = True + write_tasks_file(tasks_path, payload) + staging_dir = os.path.join(proj, "finished-staging") + os.makedirs(staging_dir) + outcome = CycleOutcome( + report=SleepReport( + night=7, + project=proj, + n_tasks=len(_tasks()), + accepted=True, + gate_action="accept_new_best", + ), + staging_dir=staging_dir, + adopted=False, + adopted_paths=[], + ) + stdout = io.StringIO() + stderr = io.StringIO() + with mock.patch( + "skillopt_sleep.__main__.run_sleep_cycle", + return_value=outcome, + ), contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + rc = main([ + "run", "--backend", "handoff", "--json", + "--project", proj, + "--claude-home", os.path.join(home, ".claude"), + "--tasks-file", tasks_path, + ]) + + self.assertEqual(rc, 0) + result = json.loads(stdout.getvalue()) + self.assertEqual(result["night"], 7) + self.assertEqual(result["staging_dir"], staging_dir) + self.assertIn("archived round data", stderr.getvalue()) + + def test_archive_failure_never_prints_a_success_document(self): + import contextlib + import io + + from skillopt_sleep.__main__ import main + from skillopt_sleep.cycle import CycleOutcome + from skillopt_sleep.tasks_file import make_tasks_payload, write_tasks_file + from skillopt_sleep.types import SleepReport + + with tempfile.TemporaryDirectory() as proj, \ + tempfile.TemporaryDirectory() as home: + tasks_path = os.path.join(proj, "tasks.json") + payload = make_tasks_payload(_tasks(), project=proj) + payload["reviewed"] = True + write_tasks_file(tasks_path, payload) + staging_dir = os.path.join(proj, "finished-staging") + os.makedirs(staging_dir) + outcome = CycleOutcome( + report=SleepReport(night=7, project=proj, accepted=True), + staging_dir=staging_dir, + adopted=False, + adopted_paths=[], + ) + stdout = io.StringIO() + with mock.patch( + "skillopt_sleep.__main__.run_sleep_cycle", return_value=outcome + ), mock.patch( + "skillopt_sleep.__main__.os.rename", + side_effect=OSError("archive denied"), + ), contextlib.redirect_stdout(stdout): + rc = main([ + "run", "--backend", "handoff", "--json", + "--project", proj, + "--claude-home", os.path.join(home, ".claude"), + "--tasks-file", tasks_path, + ]) + self.assertEqual(rc, 1) + result = json.loads(stdout.getvalue()) + self.assertEqual(result["ok"], False) + self.assertEqual(result["error"], "staging_refused") + self.assertNotIn("night", result) + def test_run_with_no_tasks_exits_0_and_advances_harvest_window(self): from skillopt_sleep.__main__ import main from skillopt_sleep.config import load_config diff --git a/tests/test_mcp_schema.py b/tests/test_mcp_schema.py index f8960b1d..0d2c1c3e 100644 --- a/tests/test_mcp_schema.py +++ b/tests/test_mcp_schema.py @@ -1,37 +1,289 @@ """Tests for the Copilot MCP server schema completeness.""" +import contextlib +import importlib.util +import io +import json import os +import subprocess import sys import unittest +from unittest import mock -# Allow importing from the plugin directory -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "plugins", "copilot")) +PLUGIN = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "plugins", "copilot") +) +MODULE_PATH = os.path.join(PLUGIN, "mcp_server.py") +SPEC = importlib.util.spec_from_file_location("copilot_mcp_server_test", MODULE_PATH) +assert SPEC is not None and SPEC.loader is not None +mcp_server = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(mcp_server) + + +def _call(name="sleep_status", arguments=None, **params): + call_params = {"name": name, "arguments": {} if arguments is None else arguments} + call_params.update(params) + return { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": call_params, + } class TestMcpSchema(unittest.TestCase): def test_schema_includes_all_engine_flags(self): - from mcp_server import _TOOL_SCHEMA required_params = { "project", "backend", "scope", "source", "model", - "tasks_file", "target_skill_path", "progress", + "tasks_file", "target_skill_path", "staging", "skills", + "all_skills", "legacy", "progress", "max_sessions", "max_tasks", "lookback_hours", "auto_adopt", "json", "edit_budget", } - schema_props = set(_TOOL_SCHEMA["properties"].keys()) + schema_props = set(mcp_server._TOOL_SCHEMA["properties"].keys()) missing = required_params - schema_props self.assertEqual(missing, set(), f"MCP schema missing: {missing}") + def test_adopt_selection_schema_types(self): + props = mcp_server._TOOL_SCHEMA["properties"] + self.assertEqual(props["staging"]["type"], "string") + self.assertEqual(props["skills"]["type"], "array") + self.assertEqual(props["skills"]["items"]["type"], "string") + self.assertEqual(props["all_skills"]["type"], "boolean") + self.assertEqual(props["legacy"]["type"], "boolean") + def test_all_backends_in_enum(self): - from mcp_server import _TOOL_SCHEMA - backends = _TOOL_SCHEMA["properties"]["backend"]["enum"] - for b in ["mock", "claude", "codex", "copilot"]: + backends = mcp_server._TOOL_SCHEMA["properties"]["backend"]["enum"] + for b in ["mock", "claude", "codex", "copilot", "handoff"]: self.assertIn(b, backends) def test_schedule_tools_exist(self): - from mcp_server import TOOLS - names = {t["name"] for t in TOOLS} + names = {t["name"] for t in mcp_server.TOOLS} self.assertIn("sleep_schedule", names) self.assertIn("sleep_unschedule", names) + def test_adopt_forwards_staging_and_repeated_skills_as_argv(self): + completed = subprocess.CompletedProcess( + args=[], returncode=0, stdout="adopted\n", stderr="" + ) + arguments = { + "staging": "/tmp/night with spaces", + "skills": ["alpha", "--leading-dash", "space ; $(literal)"], + } + with mock.patch.object( + mcp_server.subprocess, "run", return_value=completed + ) as run: + result = mcp_server._run_engine("adopt", arguments) + + self.assertEqual(result.text, "adopted") + self.assertEqual(result.returncode, 0) + run.assert_called_once() + command = run.call_args.args[0] + self.assertEqual( + command[-7:], + [ + "--staging", "/tmp/night with spaces", + "--skill", "alpha", + "--skill=--leading-dash", + "--skill", "space ; $(literal)", + ], + ) + self.assertNotIn("shell", run.call_args.kwargs) + + def test_adopt_forwards_boolean_selection_flags(self): + completed = subprocess.CompletedProcess( + args=[], returncode=0, stdout="", stderr="" + ) + for argument, flag in (("all_skills", "--all-skills"), ("legacy", "--legacy")): + with self.subTest(argument=argument), mock.patch.object( + mcp_server.subprocess, "run", return_value=completed + ) as run: + mcp_server._run_engine("adopt", {argument: True}) + self.assertEqual(run.call_args.args[0][-1], flag) + + def test_adopt_rejects_non_array_skills_without_spawning(self): + with mock.patch.object(mcp_server.subprocess, "run") as run: + with self.assertRaisesRegex(ValueError, "skills must be an array"): + mcp_server._run_engine("adopt", {"skills": "alpha"}) + + run.assert_not_called() + + +class TestMcpRuntimeValidation(unittest.TestCase): + def test_malformed_request_envelopes_return_json_rpc_errors(self): + cases = ( + ([], "request must be a JSON object"), + ({"method": "ping", "id": 1}, "jsonrpc must be '2.0'"), + ({"jsonrpc": "2.0", "id": 1, "method": "", "params": {}}, + "method must be a non-empty string"), + ({"jsonrpc": "2.0", "id": 1, "method": "ping", "params": []}, + "params must be an object"), + ({"jsonrpc": "2.0", "id": False, "method": "ping"}, + "id must be a string"), + ({"jsonrpc": "2.0", "id": 1, "method": "ping", "extra": 1}, + "unknown request member"), + ) + for request, message in cases: + with self.subTest(request=request): + response = mcp_server.handle(request) + self.assertEqual(response["error"]["code"], -32600) + self.assertIn(message, response["error"]["message"]) + if type(request) is dict and type(request.get("id")) not in {str, int}: + self.assertIsNone(response["id"]) + + def test_known_method_params_reject_unknown_or_wrong_typed_properties(self): + cases = ( + (_call(extra="value"), "unknown params member"), + ({"jsonrpc": "2.0", "id": 1, "method": "tools/list", + "params": {"cursor": 4}}, "cursor must be a string"), + ({"jsonrpc": "2.0", "id": 1, "method": "ping", + "params": {"_meta": "bad"}}, "_meta must be an object"), + ) + for request, message in cases: + with self.subTest(request=request): + response = mcp_server.handle(request) + self.assertEqual(response["error"]["code"], -32602) + self.assertIn(message, response["error"]["message"]) + + def test_invalid_tool_arguments_never_spawn(self): + cases = ( + ([], "arguments must be an object"), + ({"bogus": 1}, "unknown argument"), + ({"json": "false"}, "json must be a boolean"), + ({"auto_adopt": "false"}, "auto_adopt must be a boolean"), + ({"max_tasks": True}, "max_tasks must be an integer"), + ({"max_tasks": -1}, "max_tasks must be between"), + ({"backend": "other"}, "unsupported backend"), + ({"skills": ["alpha"]}, "valid only for sleep_adopt"), + ({"hour": 24}, "hour must be between"), + ({"minute": -1}, "minute must be between"), + ) + for arguments, message in cases: + request = _call(arguments=arguments) + if "hour" in arguments or "minute" in arguments: + request = _call("sleep_schedule", arguments) + with self.subTest(arguments=arguments), mock.patch.object( + mcp_server.subprocess, "run" + ) as run: + response = mcp_server.handle(request) + self.assertEqual(response["error"]["code"], -32602) + self.assertIn(message, response["error"]["message"]) + run.assert_not_called() + + def test_adoption_modes_and_skill_array_are_strict(self): + cases = ( + ({"all_skills": "false"}, "all_skills must be a boolean"), + ({"legacy": "false"}, "legacy must be a boolean"), + ({"skills": [1]}, "skills entry must be a string"), + ({"skills": [" "]}, "skills entry must be non-empty"), + ({"skills": ["alpha", " alpha "]}, "skills entries must be unique"), + ({"skills": ["alpha"], "all_skills": True}, "choose at most one"), + ({"all_skills": True, "legacy": True}, "choose at most one"), + ) + for arguments, message in cases: + with self.subTest(arguments=arguments), mock.patch.object( + mcp_server.subprocess, "run" + ) as run: + response = mcp_server.handle(_call("sleep_adopt", arguments)) + self.assertEqual(response["error"]["code"], -32602) + self.assertIn(message, response["error"]["message"]) + run.assert_not_called() + + def test_every_string_boolean_and_integer_contract_is_exact_and_bounded(self): + for key in mcp_server._STRING_ARGS: + action = "adopt" if key == "staging" else "status" + with self.subTest(kind="string", key=key), self.assertRaisesRegex( + ValueError, f"{key} must be a string" + ): + mcp_server._validate_tool_arguments(action, {key: 1}) + for key in mcp_server._BOOLEAN_ARGS: + action = "adopt" if key in {"all_skills", "legacy"} else "status" + with self.subTest(kind="boolean", key=key), self.assertRaisesRegex( + ValueError, f"{key} must be a boolean" + ): + mcp_server._validate_tool_arguments(action, {key: "false"}) + for key, (minimum, maximum) in mcp_server._INTEGER_BOUNDS.items(): + action = "schedule" if key in {"hour", "minute"} else "status" + with self.subTest(kind="integer-bool", key=key), self.assertRaisesRegex( + ValueError, f"{key} must be an integer" + ): + mcp_server._validate_tool_arguments(action, {key: False}) + for value in (minimum - 1, maximum + 1): + with self.subTest(kind="integer-bound", key=key, value=value), \ + self.assertRaisesRegex(ValueError, f"{key} must be between"): + mcp_server._validate_tool_arguments(action, {key: value}) + self.assertEqual( + mcp_server._validate_tool_arguments(action, {key: minimum})[key], + minimum, + ) + self.assertEqual( + mcp_server._validate_tool_arguments(action, {key: maximum})[key], + maximum, + ) + + def test_engine_status_maps_to_mcp_error_and_handoff_states(self): + cases = ( + (0, False, "ok"), + (2, True, "error"), + (3, False, "handoff_pending"), + ) + for returncode, is_error, status in cases: + with self.subTest(returncode=returncode), mock.patch.object( + mcp_server, "_run_engine", + return_value=mcp_server.EngineResult("engine output", returncode), + ): + response = mcp_server.handle(_call()) + result = response["result"] + self.assertIs(result["isError"], is_error) + self.assertEqual(result["structuredContent"]["status"], status) + self.assertEqual(result["structuredContent"]["exit_code"], returncode) + + def test_subprocess_exit_status_reaches_mcp_result(self): + completed = subprocess.CompletedProcess( + args=[], returncode=9, stdout="failed", stderr="details" + ) + with mock.patch.object(mcp_server.subprocess, "run", return_value=completed): + result = mcp_server.handle(_call())["result"] + self.assertTrue(result["isError"]) + self.assertEqual(result["structuredContent"]["exit_code"], 9) + + def test_json_failure_keeps_stderr_visible_when_stdout_is_empty(self): + completed = subprocess.CompletedProcess( + args=[], returncode=4, stdout="", stderr="actionable failure\n" + ) + with mock.patch.object(mcp_server.subprocess, "run", return_value=completed): + result = mcp_server.handle(_call(arguments={"json": True}))["result"] + self.assertTrue(result["isError"]) + self.assertEqual(result["content"][0]["text"], "actionable failure") + + def test_json_output_is_parseable_and_stderr_is_diagnostic_only(self): + completed = subprocess.CompletedProcess( + args=[], returncode=0, stdout='{"ok": true}\n', stderr="provider warning\n" + ) + with mock.patch.object(mcp_server.subprocess, "run", return_value=completed): + run = mcp_server._run_engine("status", {"json": True}) + + self.assertEqual(json.loads(run.text), {"ok": True}) + self.assertEqual(run.diagnostics, "provider warning") + self.assertNotIn("stderr", run.text) + + def test_json_tool_result_includes_parsed_structured_output(self): + completed = subprocess.CompletedProcess( + args=[], returncode=0, stdout='{"nights": 2}\n', stderr="" + ) + with mock.patch.object(mcp_server.subprocess, "run", return_value=completed): + result = mcp_server.handle(_call(arguments={"json": True}))["result"] + self.assertEqual(result["structuredContent"]["output"], {"nights": 2}) + self.assertEqual(json.loads(result["content"][0]["text"]), {"nights": 2}) + + def test_main_emits_parse_error_for_malformed_json(self): + output = io.StringIO() + with mock.patch.object(sys, "stdin", io.StringIO("{bad json\n")), \ + contextlib.redirect_stdout(output): + self.assertEqual(mcp_server.main(), 0) + response = json.loads(output.getvalue()) + self.assertEqual(response["error"]["code"], -32700) + self.assertIsNone(response["id"]) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_plugin_sync.py b/tests/test_plugin_sync.py index e49a8994..9e861805 100644 --- a/tests/test_plugin_sync.py +++ b/tests/test_plugin_sync.py @@ -18,7 +18,10 @@ } MCP_SERVER = os.path.join(REPO, "plugins/copilot/mcp_server.py") +COPILOT_README = os.path.join(REPO, "plugins/copilot/README.md") COPILOT_INSTRUCTIONS = os.path.join(REPO, "plugins/copilot/copilot-instructions.snippet.md") +DEVIN_README = os.path.join(REPO, "plugins/devin/README.md") +DEVIN_RULES = os.path.join(REPO, "plugins/devin/devin-rules.snippet.md") CANONICAL_BACKENDS = {"mock", "claude", "codex", "copilot"} CURSOR_MANIFEST = os.path.join(REPO, "plugins/cursor/.cursor-plugin/plugin.json") @@ -184,10 +187,24 @@ def test_mcp_server_has_schedule_tools(self): def test_mcp_schema_has_key_params(self): text = _read(MCP_SERVER) for param in ["source", "tasks_file", "target_skill_path", + "staging", "skills", "all_skills", "legacy", "max_sessions", "max_tasks", "auto_adopt", "json"]: self.assertIn(f'"{param}"', text, f"MCP schema missing param '{param}'") + def test_mcp_adoption_docs_cover_fanout_selection(self): + for path in (COPILOT_README, COPILOT_INSTRUCTIONS, DEVIN_README, DEVIN_RULES): + text = _read(path) + for param in ("staging", "skills", "all_skills", "legacy"): + self.assertIn(f"`{param}`", text, f"{path} missing `{param}`") + + def test_devin_docs_disclaim_post_adoption_copy(self): + for path in (DEVIN_README, DEVIN_RULES): + text = _read(path).lower() + self.assertIn("no post-adoption copy", text) + self.assertIn("target_skill_path", text) + self.assertIn("core engine", text) + def test_all_skill_mds_mention_memory_consolidation(self): for name, path in PLUGIN_SKILL_MDS.items(): text = _read(path).lower() diff --git a/tests/test_scheduler_windows.py b/tests/test_scheduler_windows.py index 442f68c5..151777a1 100644 --- a/tests/test_scheduler_windows.py +++ b/tests/test_scheduler_windows.py @@ -1,14 +1,13 @@ -import os -import sys import unittest from unittest import mock + class TestSchedulerWindows(unittest.TestCase): @mock.patch("sys.platform", "win32") @mock.patch("shutil.which", return_value="C:\\Windows\\System32\\schtasks.exe") def test_schedule_windows(self, mock_which): from skillopt_sleep.scheduler import schedule - + calls = [] def fake_run(cmd, **kwargs): calls.append(cmd) @@ -17,7 +16,7 @@ class Proc: stdout = "SUCCESS: The scheduled task ... has successfully been created." stderr = "" return Proc() - + mock_open = mock.mock_open() with mock.patch("subprocess.run", side_effect=fake_run), \ mock.patch("os.makedirs") as mock_makedirs, \ @@ -31,9 +30,9 @@ class Proc: self.assertEqual(cmd[1], "/create") self.assertEqual(cmd[2], "/tn") self.assertTrue(cmd[3].startswith("SkillOpt-Sleep-")) - self.assertIn("my_project", cmd[3]) + self.assertRegex(cmd[3], r"^SkillOpt-Sleep-[0-9a-f]{20}$") self.assertEqual(cmd[4], "/tr") - self.assertIn("run.cmd", cmd[5]) + self.assertIn("run.ps1", cmd[5]) self.assertEqual(cmd[6], "/sc") self.assertEqual(cmd[7], "daily") self.assertEqual(cmd[8], "/st") @@ -45,14 +44,14 @@ class Proc: # Verify the content written to the helper script handle = mock_open() written = "".join(call[0][0] for call in handle.write.call_args_list) - self.assertIn("@echo off", written) - self.assertIn("run --project", written) + self.assertIn("Set-Location -LiteralPath", written) + self.assertIn("'run' '--project'", written) @mock.patch("sys.platform", "win32") @mock.patch("shutil.which", return_value="C:\\Windows\\System32\\schtasks.exe") def test_unschedule_windows(self, mock_which): from skillopt_sleep.scheduler import unschedule - + calls = [] def fake_run(cmd, **kwargs): calls.append(cmd) @@ -61,7 +60,7 @@ class Proc: stdout = "SUCCESS: The scheduled task ... was successfully deleted." stderr = "" return Proc() - + with mock.patch("subprocess.run", side_effect=fake_run), \ mock.patch("os.path.exists", return_value=True), \ mock.patch("os.remove") as mock_remove: diff --git a/tests/test_sleep_adopt_skill_subset.py b/tests/test_sleep_adopt_skill_subset.py index 1153f562..f873df8d 100644 --- a/tests/test_sleep_adopt_skill_subset.py +++ b/tests/test_sleep_adopt_skill_subset.py @@ -10,17 +10,23 @@ import os import stat import tempfile +import threading import unittest from unittest import mock from skillopt_sleep.staging import ( SkillProposal, StagingError, + StagingRecoveryError, + adopt, adopt_skills, + has_pending_staged_managed, + latest_staging, + pending_staged_skills, staged_skills, write_staging, ) -from skillopt_sleep.types import SleepReport +from skillopt_sleep.types import EditRecord, SleepReport def _sha(text): @@ -41,13 +47,24 @@ def _write(path, text): class TwoSkillNight: """End-to-end fixture: a staged night with two per-skill proposals.""" - def __init__(self, tmp): + def __init__( + self, + tmp, + *, + alpha_body="# alpha v1\n", + beta_body="# beta v1\n", + ): self.tmp = tmp self.live_root = os.path.join(tmp, "live") self.alpha_live = os.path.join(self.live_root, "alpha", "SKILL.md") self.beta_live = os.path.join(self.live_root, "beta", "SKILL.md") - _write(self.alpha_live, "# alpha v1\n") - _write(self.beta_live, "# beta v1\n") + for path, body in ( + (self.alpha_live, alpha_body), + (self.beta_live, beta_body), + ): + os.makedirs(os.path.dirname(path), exist_ok=True) + if body is not None: + _write(path, body) self.staging = write_staging( tmp, report=SleepReport(night=1, project=tmp, accepted=True), @@ -69,6 +86,34 @@ def test_rows_are_readable_from_the_manifest(self): rows = staged_skills(night.staging) self.assertEqual([r["skill_name"] for r in rows], ["alpha", "beta"]) + def test_pending_rows_exclude_validated_incremental_receipts(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + self.assertEqual( + [row["skill_name"] for row in pending_staged_skills(night.staging)], + ["alpha", "beta"], + ) + adopt_skills(night.staging, ["alpha"]) + pending = pending_staged_skills(night.staging) + self.assertEqual([row["skill_name"] for row in pending], ["beta"]) + adopt_skills( + night.staging, [str(row["skill_name"]) for row in pending] + ) + self.assertEqual(pending_staged_skills(night.staging), []) + + def test_pending_rows_fail_closed_on_an_invalid_receipt(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + adopt_skills(night.staging, ["alpha"]) + receipt = os.path.join(night.staging, "adopted_skills.json") + with open(receipt, encoding="utf-8") as handle: + payload = json.load(handle) + payload[0]["unvalidated"] = True + with open(receipt, "w", encoding="utf-8") as handle: + json.dump(payload, handle) + with self.assertRaisesRegex(StagingError, "invalid schema"): + pending_staged_skills(night.staging) + def test_legacy_single_proposal_night_has_no_staged_skills(self): with tempfile.TemporaryDirectory() as tmp: out = write_staging( @@ -94,6 +139,16 @@ def test_malformed_skills_manifest_shape_is_refused(self): with self.assertRaises(StagingError, msg=repr(malformed)): staged_skills(night.staging) + def test_adopting_an_older_night_does_not_make_it_latest(self): + with tempfile.TemporaryDirectory() as tmp, mock.patch( + "skillopt_sleep.staging._ts_dir", return_value="20260815-010203" + ): + older = TwoSkillNight(tmp) + newer = TwoSkillNight(tmp) + self.assertEqual(latest_staging(tmp), newer.staging) + adopt_skills(older.staging, ["alpha"]) + self.assertEqual(latest_staging(tmp), newer.staging) + class TestAdoptSkillSubset(unittest.TestCase): def test_adopting_one_skill_leaves_the_other_untouched(self): @@ -142,8 +197,7 @@ def test_selecting_every_skill_adopts_all_of_them(self): def test_a_new_live_file_reports_an_empty_before_hash(self): with tempfile.TemporaryDirectory() as tmp: - night = TwoSkillNight(tmp) - os.unlink(night.beta_live) + night = TwoSkillNight(tmp, beta_body=None) receipt = [r for r in adopt_skills(night.staging) if r.skill_name == "beta"][0] self.assertEqual(receipt.sha256_before, "") self.assertEqual(receipt.backup_path, "") @@ -223,13 +277,38 @@ def boom(path, text, *, create_parents=True): self.assertFalse( os.path.exists(os.path.join(night.staging, "adopted_skills.json"))) - def test_rollback_removes_files_that_did_not_exist_before(self): + def test_post_commit_live_write_error_rolls_the_whole_selection_back(self): from skillopt_sleep import staging as staging_mod with tempfile.TemporaryDirectory() as tmp: night = TwoSkillNight(tmp) - os.unlink(night.alpha_live) - os.unlink(night.beta_live) + real_write = staging_mod._write_atomic + + def commit_then_fail(path, text, *, create_parents=True): + result = real_write(path, text, create_parents=create_parents) + if path == night.beta_live: + raise OSError("late close failure") + return result + + with mock.patch.object( + staging_mod, "_write_atomic", side_effect=commit_then_fail + ), self.assertRaisesRegex(OSError, "late close failure"): + adopt_skills(night.staging) + + self.assertEqual(_read(night.alpha_live), "# alpha v1\n") + self.assertEqual(_read(night.beta_live), "# beta v1\n") + self.assertFalse(os.path.exists(os.path.join( + night.staging, "adopted_skills.json" + ))) + backup_root = os.path.join(night.staging, "backup") + for _root, _dirs, files in os.walk(backup_root): + self.assertEqual(files, []) + + def test_rollback_removes_files_that_did_not_exist_before(self): + from skillopt_sleep import staging as staging_mod + + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp, alpha_body=None, beta_body=None) real_write = staging_mod._write_atomic def boom(path, text, *, create_parents=True): @@ -293,10 +372,152 @@ def test_receipt_write_failure_rolls_back_live_files(self): with tempfile.TemporaryDirectory() as tmp: night = TwoSkillNight(tmp) os.makedirs(os.path.join(night.staging, "adopted_skills.json")) - with self.assertRaises(OSError): + with self.assertRaisesRegex(StagingError, "receipt path"): + adopt_skills(night.staging, ["alpha"]) + self.assertEqual(_read(night.alpha_live), "# alpha v1\n") + self.assertEqual(_read(night.beta_live), "# beta v1\n") + + def test_live_file_changed_since_staging_is_never_overwritten(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + _write(night.alpha_live, "# human edit after review\n") + with self.assertRaisesRegex(StagingError, "changed since staging"): + adopt_skills(night.staging, ["alpha"]) + self.assertEqual(_read(night.alpha_live), "# human edit after review\n") + self.assertEqual(_read(night.beta_live), "# beta v1\n") + self.assertFalse(os.path.exists(os.path.join( + night.staging, "adopted_skills.json" + ))) + + def test_live_file_deleted_since_staging_is_never_recreated(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + os.unlink(night.alpha_live) + with self.assertRaisesRegex(StagingError, "changed since staging"): + adopt_skills(night.staging, ["alpha"]) + self.assertFalse(os.path.exists(night.alpha_live)) + + def test_absent_live_file_created_since_staging_is_never_overwritten(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp, alpha_body=None) + _write(night.alpha_live, "# created by user after review\n") + with self.assertRaisesRegex(StagingError, "changed since staging"): + adopt_skills(night.staging, ["alpha"]) + self.assertEqual(_read(night.alpha_live), "# created by user after review\n") + + def test_one_stale_target_aborts_the_entire_selection_before_writes(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + _write(night.beta_live, "# beta changed after review\n") + with self.assertRaisesRegex(StagingError, "changed since staging"): + adopt_skills(night.staging) + self.assertEqual(_read(night.alpha_live), "# alpha v1\n") + self.assertEqual(_read(night.beta_live), "# beta changed after review\n") + self.assertFalse(os.path.exists(os.path.join( + night.staging, "adopted_skills.json" + ))) + + def test_incremental_subset_adoption_accumulates_an_immutable_receipt(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + adopt_skills(night.staging, ["alpha"]) + alpha_backup = os.path.join( + night.staging, "backup", "skills", "alpha", "SKILL.md" + ) + self.assertEqual(_read(alpha_backup), "# alpha v1\n") + + adopt_skills(night.staging, ["beta"]) + receipt_path = os.path.join(night.staging, "adopted_skills.json") + with open(receipt_path, encoding="utf-8") as handle: + receipts = json.load(handle) + self.assertEqual( + [row["skill_name"] for row in receipts], ["alpha", "beta"] + ) + self.assertEqual(_read(alpha_backup), "# alpha v1\n") + + receipt_before = _read(receipt_path) + with self.assertRaisesRegex( + StagingError, "already adopted|changed since staging" + ): adopt_skills(night.staging, ["alpha"]) + self.assertEqual(_read(alpha_backup), "# alpha v1\n") + self.assertEqual(_read(receipt_path), receipt_before) + + def test_repeated_noop_adoption_cannot_rewrite_receipt_or_backup(self): + with tempfile.TemporaryDirectory() as tmp: + live = os.path.join(tmp, "live", "alpha", "SKILL.md") + _write(live, "# unchanged\n") + staging = write_staging( + tmp, + report=SleepReport(night=1, project=tmp, accepted=True), + proposed_skill=None, + proposed_memory=None, + live_skill_path=live, + live_memory_path=os.path.join(tmp, "live", "CLAUDE.md"), + report_md="# report\n", + skill_proposals=[SkillProposal("alpha", "# unchanged\n", live)], + ) + adopt_skills(staging, ["alpha"]) + receipt_path = os.path.join(staging, "adopted_skills.json") + backup_path = os.path.join( + staging, "backup", "skills", "alpha", "SKILL.md" + ) + receipt_before = _read(receipt_path) + backup_before = _read(backup_path) + with self.assertRaisesRegex(StagingError, "already adopted"): + adopt_skills(staging, ["alpha"]) + self.assertEqual(_read(receipt_path), receipt_before) + self.assertEqual(_read(backup_path), backup_before) + + def test_rollback_restores_original_mode_as_well_as_bytes(self): + from skillopt_sleep import staging as staging_mod + + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + os.chmod(night.alpha_live, 0o640) + real_write = staging_mod._write_atomic + + def boom(path, text, *, create_parents=True): + if path == night.beta_live: + raise OSError("disk full") + return real_write(path, text, create_parents=create_parents) + + with mock.patch.object(staging_mod, "_write_atomic", side_effect=boom): + with self.assertRaises(OSError): + adopt_skills(night.staging) + self.assertEqual(_read(night.alpha_live), "# alpha v1\n") + self.assertEqual( + stat.S_IMODE(os.stat(night.alpha_live).st_mode), 0o640 + ) + + def test_backup_failure_rolls_back_prior_live_writes(self): + from skillopt_sleep import staging as staging_mod + + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + real_write_new = staging_mod._write_new_bytes + beta_backup = os.path.join( + night.staging, "backup", "skills", "beta", "SKILL.md" + ) + + def boom(path, data, *, mode=None): + if path == beta_backup: + raise OSError("backup device full") + return real_write_new(path, data, mode=mode) + + with mock.patch.object( + staging_mod, "_write_new_bytes", side_effect=boom + ): + with self.assertRaises(OSError): + adopt_skills(night.staging) self.assertEqual(_read(night.alpha_live), "# alpha v1\n") self.assertEqual(_read(night.beta_live), "# beta v1\n") + self.assertFalse(os.path.exists(os.path.join( + night.staging, "adopted_skills.json" + ))) + backup_root = os.path.join(night.staging, "backup") + for _root, _dirs, files in os.walk(backup_root): + self.assertEqual(files, []) class TestCycleStagesResolvedSkillSubset(unittest.TestCase): @@ -361,6 +582,19 @@ def test_cycle_stages_both_skills_and_subset_adopt_touches_only_one(self): self.assertIn(programming_marker, programming_proposal) self.assertNotIn(research_marker, programming_proposal) self.assertNotIn(managed_marker, programming_proposal) + row_by_name = {row["skill_name"]: row for row in rows} + self.assertEqual( + row_by_name["research-skill"]["live_sha256"], + _sha(f"# research-skill v1\n{research_marker}\n"), + ) + self.assertEqual( + row_by_name["programming-skill"]["live_sha256"], + _sha(f"# programming-skill v1\n{programming_marker}\n"), + ) + self.assertEqual( + row_by_name["research-skill"]["live_realpath"], + os.path.realpath(research_live), + ) self.assertEqual( _read(research_live), f"# research-skill v1\n{research_marker}\n" ) @@ -390,6 +624,48 @@ def _cli(self, argv): rc = main(argv) return rc, stdout.getvalue() + def test_human_error_text_removes_terminal_controls(self): + from skillopt_sleep.__main__ import _display_error + + rendered = _display_error( + ValueError("bad\x1b[31m red\x1b[0m\nnext\u202e hidden\a") + ) + self.assertEqual(rendered, "bad red next hidden") + self.assertNotIn("\x1b", rendered) + self.assertNotIn("\u202e", rendered) + + def test_human_run_report_sanitizes_model_edit_text(self): + import contextlib + import io + from types import SimpleNamespace + + from skillopt_sleep.__main__ import _print_run_report + + hostile = "line\nforged\x1b[31m\u202e api_key=SUPERSECRET123456789" + report = SleepReport( + night=1, + project="/tmp/project", + edits=[EditRecord("skill", "add", hostile)], + rejected_edits=[EditRecord("skill", "add", hostile)], + ) + output = io.StringIO() + with contextlib.redirect_stdout(output): + _print_run_report( + SimpleNamespace( + report=report, + staging_dir="", + adopted=False, + adopted_paths=[], + ), + SimpleNamespace(json=False), + {}, + ) + rendered = output.getvalue() + self.assertNotIn("\x1b", rendered) + self.assertNotIn("\u202e", rendered) + self.assertNotIn("SUPERSECRET", rendered) + self.assertNotIn("\nforged", rendered) + def test_status_lists_staged_skill_names(self): with tempfile.TemporaryDirectory() as tmp: night = TwoSkillNight(tmp) @@ -401,8 +677,86 @@ def test_status_lists_staged_skill_names(self): self.assertEqual(rc, 0) payload = json.loads(out) self.assertEqual(payload["staged_skills"], ["alpha", "beta"]) + self.assertEqual(payload["adopted_skills"], []) + self.assertFalse(payload["has_managed_proposal"]) self.assertEqual(payload["latest_staging"], night.staging) + def test_status_and_all_skills_operate_on_pending_rows_only(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + claude_home = os.path.join(tmp, ".claude") + os.makedirs(claude_home, exist_ok=True) + self.assertEqual( + self._cli([ + "adopt", "--project", tmp, "--claude-home", claude_home, + "--skill", "alpha", + ])[0], + 0, + ) + rc, out = self._cli([ + "status", "--project", tmp, "--claude-home", claude_home, + "--json", + ]) + self.assertEqual(rc, 0) + payload = json.loads(out) + self.assertEqual(payload["staged_skills"], ["beta"]) + self.assertEqual(payload["adopted_skills"], ["alpha"]) + + rc, out = self._cli([ + "adopt", "--project", tmp, "--claude-home", claude_home, + "--all-skills", "--json", + ]) + self.assertEqual(rc, 0, out) + self.assertEqual( + [row["skill_name"] for row in json.loads(out)["adopted_skills"]], + ["beta"], + ) + self.assertEqual(_read(night.alpha_live), "# alpha v2\n") + self.assertEqual(_read(night.beta_live), "# beta v2\n") + + def test_status_reports_a_corrupt_latest_manifest(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + with open( + os.path.join(night.staging, "manifest.json"), + "w", + encoding="utf-8", + ) as handle: + handle.write("{not json") + claude_home = os.path.join(tmp, ".claude") + os.makedirs(claude_home, exist_ok=True) + rc, out = self._cli([ + "status", "--project", tmp, "--claude-home", claude_home, + "--json", + ]) + self.assertEqual(rc, 1) + payload = json.loads(out) + self.assertEqual(payload["latest_staging"], night.staging) + self.assertTrue(payload["staging_error"]) + self.assertEqual(payload["staged_skills"], []) + + def test_human_status_sanitizes_tampered_report_and_paths(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + hostile = "api_key=SUPERSECRET123456789\x1b[31m\u202eforged" + _write(os.path.join(night.staging, "report.md"), hostile + "\n# row\n") + manifest_path = os.path.join(night.staging, "manifest.json") + with open(manifest_path, encoding="utf-8") as handle: + manifest = json.load(handle) + manifest["skills"][0]["live_skill_path"] = hostile + with open(manifest_path, "w", encoding="utf-8") as handle: + json.dump(manifest, handle) + claude_home = os.path.join(tmp, ".claude") + os.makedirs(claude_home, exist_ok=True) + rc, out = self._cli([ + "status", "--project", tmp, "--claude-home", claude_home, + ]) + self.assertEqual(rc, 0, out) + self.assertNotIn("SUPERSECRET", out) + self.assertNotIn("\x1b", out) + self.assertNotIn("\u202e", out) + self.assertIn("[REDACTED]", out) + def test_bare_adopt_on_a_multi_skill_night_lists_and_refuses(self): with tempfile.TemporaryDirectory() as tmp: TwoSkillNight(tmp) @@ -439,8 +793,11 @@ def test_run_guidance_keeps_skill_names_out_of_shell_commands(self): ) stdout = io.StringIO() with mock.patch( - "skillopt_sleep.__main__.staged_skills", + "skillopt_sleep.__main__.pending_staged_skills", return_value=[{"skill_name": name} for name in names], + ), mock.patch( + "skillopt_sleep.__main__.has_pending_staged_managed", + return_value=False, ), contextlib.redirect_stdout(stdout): _print_run_report( outcome, @@ -455,6 +812,7 @@ def test_run_guidance_keeps_skill_names_out_of_shell_commands(self): self.assertTrue(command_lines) self.assertIn("--skill NAME", output) self.assertIn("python -m skillopt_sleep adopt --all-skills", output) + self.assertNotIn("--legacy", output) for name in names: self.assertIn(repr(name), output) self.assertFalse(any(name in line for line in command_lines)) @@ -472,6 +830,25 @@ def test_adopt_skill_flag_promotes_only_the_named_skill(self): self.assertEqual(_read(night.alpha_live), "# alpha v2\n") self.assertEqual(_read(night.beta_live), "# beta v1\n") + def test_adopt_json_returns_machine_readable_receipts(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + claude_home = os.path.join(tmp, ".claude") + os.makedirs(claude_home, exist_ok=True) + rc, out = self._cli([ + "adopt", "--project", tmp, "--claude-home", claude_home, + "--skill", "alpha", "--json", + ]) + self.assertEqual(rc, 0, out) + payload = json.loads(out) + self.assertTrue(payload["ok"]) + self.assertEqual(payload["staging_dir"], night.staging) + self.assertEqual( + [row["skill_name"] for row in payload["adopted_skills"]], + ["alpha"], + ) + self.assertEqual(payload["updated_paths"], [night.alpha_live]) + def test_all_skills_flag_promotes_every_staged_skill(self): with tempfile.TemporaryDirectory() as tmp: night = TwoSkillNight(tmp) @@ -508,7 +885,10 @@ def test_skill_and_all_skills_together_are_refused(self): "--skill", "alpha", "--all-skills", ]) self.assertEqual(rc, 2) - self.assertIn("not both", out) + self.assertIn( + "use exactly one of --skill, --all-skills, or --legacy.", + out, + ) self.assertEqual(_read(night.alpha_live), "# alpha v1\n") self.assertEqual(_read(night.beta_live), "# beta v1\n") @@ -533,6 +913,51 @@ def test_legacy_night_bare_adopt_still_copies_the_managed_pair(self): self.assertEqual(_read(live), "# live v2\n") self.assertEqual(_read(memory), "# mem v2\n") + def test_mixed_night_legacy_and_per_skill_adoptions_are_independent(self): + with tempfile.TemporaryDirectory() as tmp: + managed = os.path.join(tmp, "live", "managed", "SKILL.md") + memory = os.path.join(tmp, "live", "CLAUDE.md") + alpha = os.path.join(tmp, "live", "alpha", "SKILL.md") + _write(managed, "# managed v1\n") + _write(memory, "# memory v1\n") + _write(alpha, "# alpha v1\n") + staging = write_staging( + tmp, + report=SleepReport(night=1, project=tmp, accepted=True), + proposed_skill="# managed v2\n", + proposed_memory="# memory v2\n", + live_skill_path=managed, + live_memory_path=memory, + report_md="# report\n", + skill_proposals=[SkillProposal("alpha", "# alpha v2\n", alpha)], + ) + claude_home = os.path.join(tmp, ".claude") + os.makedirs(claude_home, exist_ok=True) + + rc, out = self._cli([ + "adopt", "--project", tmp, "--claude-home", claude_home, + "--staging", staging, "--legacy", "--json", + ]) + self.assertEqual(rc, 0, out) + self.assertEqual(json.loads(out)["mode"], "legacy") + self.assertFalse(has_pending_staged_managed(staging)) + self.assertEqual(_read(managed), "# managed v2\n") + self.assertEqual(_read(memory), "# memory v2\n") + self.assertEqual(_read(alpha), "# alpha v1\n") + + rc, out = self._cli([ + "adopt", "--project", tmp, "--claude-home", claude_home, + "--staging", staging, "--skill", "alpha", "--json", + ]) + self.assertEqual(rc, 0, out) + self.assertEqual( + [row["skill_name"] for row in json.loads(out)["adopted_skills"]], + ["alpha"], + ) + self.assertEqual(_read(alpha), "# alpha v2\n") + self.assertEqual(_read(managed), "# managed v2\n") + self.assertEqual(_read(memory), "# memory v2\n") + def test_skill_flag_on_a_legacy_night_is_refused(self): with tempfile.TemporaryDirectory() as tmp: live = os.path.join(tmp, "live", "SKILL.md") @@ -768,6 +1193,52 @@ def test_missing_live_skill_is_skipped_not_aborted(self): report_json = json.load(handle) self.assertIn(skip_note, report_json["notes"]) + def test_skip_note_cannot_inject_markdown_or_terminal_controls(self): + import unicodedata + + from skillopt_sleep.cycle import _cycle_skip_note, _render_report_md + + note = _cycle_skip_note( + "research\n## Forged heading\x1b[31mred\x1b[0m\u202e", + "missing\r\n- forged item\x07\u2066", + ) + rendered = _render_report_md( + SleepReport(night=1, project="/tmp/project", notes=[note]), + { + "backend": "mock", + "replay_mode": "live", + "gate_no_regression": False, + "gate_mode": "on", + }, + ) + self.assertNotIn("\n", note) + self.assertNotIn("\r", note) + self.assertNotIn("\x1b", note) + self.assertNotIn("\x07", note) + self.assertNotIn("\u202e", note) + self.assertNotIn("\u2066", note) + self.assertNotIn("[31m", rendered) + self.assertNotIn("[0m", rendered) + self.assertNotIn("\r", rendered) + self.assertNotIn("\x1b", rendered) + self.assertNotIn("\x07", rendered) + self.assertNotIn("\u202e", rendered) + self.assertNotIn("\u2066", rendered) + self.assertFalse(any( + unicodedata.category(ch) in {"Cc", "Cf"} + for ch in rendered + if ch != "\n" + )) + self.assertEqual( + sum( + line.startswith("- cycle skipped skill") + for line in rendered.splitlines() + ), + 1, + ) + self.assertIn("research", note) + self.assertIn("Forged heading", note) + def test_report_off_stages_no_per_skill_proposals(self): from skillopt_sleep.config import load_config from skillopt_sleep.cycle import run_sleep_cycle @@ -787,6 +1258,36 @@ def test_report_off_stages_no_per_skill_proposals(self): outcome = run_sleep_cycle(cfg, seed_tasks=self._hinted_tasks()) self.assertEqual(staged_skills(outcome.staging_dir), []) + def test_evolve_skill_false_disables_per_skill_fanout_too(self): + from skillopt_sleep.config import load_config + from skillopt_sleep.cycle import run_sleep_cycle + + with tempfile.TemporaryDirectory() as proj, tempfile.TemporaryDirectory() as home: + claude_home = os.path.join(home, ".claude") + research_live = os.path.join( + claude_home, "skills", "research-skill", "SKILL.md" + ) + programming_live = os.path.join( + claude_home, "skills", "programming-skill", "SKILL.md" + ) + _write(research_live, "# research-skill v1\n") + _write(programming_live, "# programming-skill v1\n") + cfg = load_config( + invoked_project=proj, + projects="invoked", + backend="mock", + claude_home=claude_home, + managed_skill_name="skillopt-sleep-learned", + auto_adopt=False, + multi_skill_report=True, + evolve_skill=False, + gate_mode="off", + ) + outcome = run_sleep_cycle(cfg, seed_tasks=self._hinted_tasks()) + self.assertEqual(staged_skills(outcome.staging_dir), []) + self.assertEqual(_read(research_live), "# research-skill v1\n") + self.assertEqual(_read(programming_live), "# programming-skill v1\n") + def test_auto_adopt_does_not_promote_per_skill_live_files(self): from skillopt_sleep.config import load_config from skillopt_sleep.cycle import run_sleep_cycle @@ -863,7 +1364,9 @@ def test_symlink_live_file_is_refused_without_writing(self): os.symlink(elsewhere, night.alpha_live) except OSError: self.skipTest("symlinks unavailable") - with self.assertRaisesRegex(StagingError, "is a symlink"): + with self.assertRaisesRegex( + StagingError, r"symlink|(?:not|must be).*SKILL\.md|canonical" + ): adopt_skills(night.staging, ["alpha"]) self.assertEqual(_read(elsewhere), "# elsewhere\n") self.assertTrue(os.path.islink(night.alpha_live)) @@ -890,10 +1393,197 @@ def test_symlink_parent_directory_is_refused_without_writing(self): os.path.join(night.staging, "proposed_SKILL.alpha.md"), os.path.join(night.staging, "proposed_SKILL.alias-alpha.md"), ) - with self.assertRaisesRegex(StagingError, "is a symlink"): + with self.assertRaisesRegex( + StagingError, r"symlink|(?:not|must be).*SKILL\.md|canonical" + ): adopt_skills(night.staging, ["alias-alpha"]) self.assertEqual(_read(night.alpha_live), "# alpha v1\n") + def test_ancestor_symlink_swap_is_refused_without_writing(self): + with tempfile.TemporaryDirectory() as tmp: + apparent = os.path.join(tmp, "apparent") + night = TwoSkillNight(apparent) + real_tree = os.path.join(tmp, "moved-after-staging") + os.rename(night.live_root, real_tree) + try: + os.symlink(real_tree, night.live_root) + except OSError: + self.skipTest("symlinks unavailable") + + outside_alpha = os.path.join(real_tree, "alpha", "SKILL.md") + with self.assertRaisesRegex( + StagingError, "symlink|canonical target|changed since staging" + ): + adopt_skills(night.staging, ["alpha"]) + self.assertEqual(_read(outside_alpha), "# alpha v1\n") + + def test_hardlinked_live_targets_are_refused_as_one_file(self): + with tempfile.TemporaryDirectory() as tmp: + live_root = os.path.join(tmp, "live") + alpha = os.path.join(live_root, "alpha", "SKILL.md") + beta = os.path.join(live_root, "beta", "SKILL.md") + _write(alpha, "# shared baseline\n") + os.makedirs(os.path.dirname(beta), exist_ok=True) + try: + os.link(alpha, beta) + except OSError: + self.skipTest("hard links unavailable") + staging = write_staging( + tmp, + report=SleepReport(night=1, project=tmp, accepted=True), + proposed_skill=None, + proposed_memory=None, + live_skill_path=alpha, + live_memory_path=os.path.join(live_root, "CLAUDE.md"), + report_md="# report\n", + skill_proposals=[ + SkillProposal("alpha", "# alpha v2\n", alpha), + SkillProposal("beta", "# beta v2\n", beta), + ], + ) + with self.assertRaisesRegex(StagingError, "same file|hard link"): + adopt_skills(staging, ["alpha"]) + self.assertEqual(_read(alpha), "# shared baseline\n") + self.assertEqual(_read(beta), "# shared baseline\n") + + def test_symlink_staged_proposal_is_refused_even_when_bytes_match(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + staged = os.path.join(night.staging, "proposed_SKILL.alpha.md") + outside = os.path.join(tmp, "outside-proposal.md") + _write(outside, "# alpha v2\n") + os.unlink(staged) + try: + os.symlink(outside, staged) + except OSError: + self.skipTest("symlinks unavailable") + with self.assertRaisesRegex(StagingError, "symlink"): + adopt_skills(night.staging, ["alpha"]) + self.assertEqual(_read(night.alpha_live), "# alpha v1\n") + + def test_invalid_utf8_staged_proposal_is_a_safe_refusal(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + staged = os.path.join(night.staging, "proposed_SKILL.alpha.md") + with open(staged, "wb") as handle: + handle.write(b"\xff\xfe") + manifest_path = os.path.join(night.staging, "manifest.json") + with open(manifest_path, encoding="utf-8") as handle: + manifest = json.load(handle) + manifest["skills"][0]["sha256"] = hashlib.sha256(b"\xff\xfe").hexdigest() + with open(manifest_path, "w", encoding="utf-8") as handle: + json.dump(manifest, handle) + with self.assertRaisesRegex(StagingError, "UTF-8"): + adopt_skills(night.staging, ["alpha"]) + self.assertEqual(_read(night.alpha_live), "# alpha v1\n") + + def test_concurrent_adoption_cleanly_refuses_one_writer(self): + from skillopt_sleep import staging as staging_mod + + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + entered = threading.Event() + release = threading.Event() + first_errors = [] + real_write = staging_mod._write_atomic + + def pause_first_live_write(path, text, *, create_parents=True): + if path == night.alpha_live and not entered.is_set(): + entered.set() + if not release.wait(5): + raise RuntimeError("test timed out waiting for release") + return real_write(path, text, create_parents=create_parents) + + def first_adoption(): + try: + adopt_skills(night.staging, ["alpha"]) + except BaseException as exc: # surfaced in the parent thread + first_errors.append(exc) + + with mock.patch.object( + staging_mod, "_write_atomic", side_effect=pause_first_live_write + ): + worker = threading.Thread(target=first_adoption) + worker.start() + self.assertTrue(entered.wait(5), "first adoption never reached write") + try: + with self.assertRaisesRegex(StagingError, "in progress|locked"): + adopt_skills(night.staging, ["alpha"]) + finally: + release.set() + worker.join(5) + self.assertFalse(worker.is_alive()) + self.assertEqual(first_errors, []) + self.assertEqual(_read(night.alpha_live), "# alpha v2\n") + + def test_separate_nights_share_the_same_live_target_lock(self): + from skillopt_sleep import staging as staging_mod + + with tempfile.TemporaryDirectory() as tmp: + live = os.path.join(tmp, "live", "alpha", "SKILL.md") + _write(live, "# alpha v1\n") + + def stage(proposal): + return write_staging( + tmp, + report=SleepReport(night=1, project=tmp, accepted=True), + proposed_skill=None, + proposed_memory=None, + live_skill_path=live, + live_memory_path=os.path.join(tmp, "live", "CLAUDE.md"), + report_md="# report\n", + skill_proposals=[SkillProposal("alpha", proposal, live)], + ) + + first = stage("# alpha from first night\n") + second = stage("# alpha from second night\n") + entered = threading.Event() + release = threading.Event() + first_errors = [] + real_write = staging_mod._write_atomic + + def pause_first_live_write(path, text, *, create_parents=True): + if path == live and not entered.is_set(): + entered.set() + if not release.wait(5): + raise RuntimeError("test timed out waiting for release") + return real_write(path, text, create_parents=create_parents) + + def first_adoption(): + try: + adopt_skills(first, ["alpha"]) + except BaseException as exc: + first_errors.append(exc) + + with mock.patch.object( + staging_mod, "_write_atomic", side_effect=pause_first_live_write + ): + worker = threading.Thread(target=first_adoption) + worker.start() + self.assertTrue(entered.wait(5), "first adoption never reached write") + try: + with self.assertRaisesRegex(StagingError, "in progress|stale lock"): + adopt_skills(second, ["alpha"]) + finally: + release.set() + worker.join(5) + self.assertFalse(worker.is_alive()) + self.assertEqual(first_errors, []) + self.assertEqual(_read(live), "# alpha from first night\n") + + def test_partial_lock_acquisition_cleans_earlier_locks(self): + from skillopt_sleep import staging as staging_mod + + with tempfile.TemporaryDirectory() as tmp: + first = os.path.join(tmp, "first.lock") + occupied = os.path.join(tmp, "occupied.lock") + _write(occupied, "held\n") + with self.assertRaisesRegex(StagingError, "in progress|stale lock"): + with staging_mod._exclusive_create_locks([first, occupied]): + self.fail("lock acquisition should have refused the occupied lock") + self.assertFalse(os.path.lexists(first)) + self.assertEqual(_read(occupied), "held\n") + def test_subset_adopt_refuses_unselected_sibling_realpath_collision(self): with tempfile.TemporaryDirectory() as tmp: night = TwoSkillNight(tmp) @@ -947,5 +1637,557 @@ def test_cycle_skips_empty_proposed_skill_with_a_note(self): self.assertTrue(any("empty proposed_skill" in note for note in notes)) +class TestDurableAdoptionTransaction(unittest.TestCase): + def _legacy_night(self, tmp): + skill = os.path.join(tmp, "live", "skill", "SKILL.md") + memory = os.path.join(tmp, "live", "CLAUDE.md") + _write(skill, "# skill v1\n") + _write(memory, "# memory v1\n") + staging = write_staging( + tmp, + report=SleepReport(night=1, project=tmp, accepted=True), + proposed_skill="# skill v2\n", + proposed_memory="# memory v2\n", + live_skill_path=skill, + live_memory_path=memory, + report_md="# report\n", + ) + return staging, skill, memory + + def test_wal_is_durable_before_first_backup_and_removed_at_commit(self): + from skillopt_sleep import staging as staging_mod + + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + wal_path = os.path.join(night.staging, ".adopt-transaction.json") + observed = [] + real_write_new = staging_mod._write_new_bytes + + def observe_backup(path, data, *, mode=None): + if path == wal_path: + return real_write_new(path, data, mode=mode) + with open(wal_path, encoding="utf-8") as handle: + wal = json.load(handle) + observed.append((path, wal["kind"], len(wal["targets"]))) + return real_write_new(path, data, mode=mode) + + with mock.patch.object( + staging_mod, "_write_new_bytes", side_effect=observe_backup + ): + adopt_skills(night.staging, ["alpha"]) + self.assertEqual(len(observed), 1) + self.assertEqual(observed[0][1:], ("skills", 1)) + self.assertFalse(os.path.lexists(wal_path)) + + def test_interrupted_transaction_is_recovered_before_retry(self): + from skillopt_sleep import staging as staging_mod + + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + wal_path = os.path.join(night.staging, ".adopt-transaction.json") + real_write = staging_mod._write_atomic + + def commit_then_fail(path, text, *, create_parents=True): + result = real_write(path, text, create_parents=create_parents) + if path == night.alpha_live: + raise OSError("simulated process interruption") + return result + + with mock.patch.object( + staging_mod, "_write_atomic", side_effect=commit_then_fail + ), mock.patch.object( + staging_mod, + "_recover_transaction_locked", + return_value=["simulated process terminated before rollback"], + ): + with self.assertRaises(StagingRecoveryError): + adopt_skills(night.staging, ["alpha"]) + self.assertEqual(_read(night.alpha_live), "# alpha v2\n") + self.assertTrue(os.path.isfile(wal_path)) + + receipts = adopt_skills(night.staging, ["alpha"]) + self.assertEqual([receipt.skill_name for receipt in receipts], ["alpha"]) + self.assertEqual(_read(night.alpha_live), "# alpha v2\n") + self.assertFalse(os.path.lexists(wal_path)) + with open( + os.path.join(night.staging, "adopted_skills.json"), + encoding="utf-8", + ) as handle: + self.assertEqual(len(json.load(handle)), 1) + + def test_interrupted_transaction_recovers_before_corrupt_manifest_read(self): + from skillopt_sleep import staging as staging_mod + + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + wal_path = os.path.join(night.staging, ".adopt-transaction.json") + real_write = staging_mod._write_atomic + + def commit_then_fail(path, text, *, create_parents=True): + result = real_write(path, text, create_parents=create_parents) + if path == night.alpha_live: + raise OSError("simulated interruption") + return result + + with mock.patch.object( + staging_mod, "_write_atomic", side_effect=commit_then_fail + ), mock.patch.object( + staging_mod, + "_recover_transaction_locked", + return_value=["process stopped before rollback"], + ): + with self.assertRaises(StagingRecoveryError): + adopt_skills(night.staging, ["alpha"]) + self.assertEqual(_read(night.alpha_live), "# alpha v2\n") + self.assertTrue(os.path.isfile(wal_path)) + + _write(os.path.join(night.staging, "manifest.json"), "{broken") + with self.assertRaisesRegex(StagingError, "manifest"): + adopt_skills(night.staging, ["alpha"]) + self.assertEqual(_read(night.alpha_live), "# alpha v1\n") + self.assertFalse(os.path.lexists(wal_path)) + self.assertFalse(os.path.lexists(os.path.join( + night.staging, "backup", "skills", "alpha", "SKILL.md" + ))) + + def test_interrupted_relative_staging_recovers_via_absolute_path(self): + from skillopt_sleep import staging as staging_mod + + with tempfile.TemporaryDirectory() as tmp: + live = os.path.join(tmp, "live", "alpha", "SKILL.md") + _write(live, "# alpha v1\n") + previous_cwd = os.getcwd() + try: + os.chdir(tmp) + relative_staging = write_staging( + ".", + report=SleepReport(night=1, project=tmp, accepted=True), + proposed_skill=None, + proposed_memory=None, + live_skill_path=live, + live_memory_path=os.path.join(tmp, "live", "CLAUDE.md"), + report_md="# report\n", + skill_proposals=[ + SkillProposal("alpha", "# alpha v2\n", live), + ], + ) + absolute_staging = os.path.abspath(relative_staging) + real_write = staging_mod._write_atomic + + def commit_then_fail(path, text, *, create_parents=True): + result = real_write(path, text, create_parents=create_parents) + if path == live: + raise OSError("simulated interruption") + return result + + with mock.patch.object( + staging_mod, "_write_atomic", side_effect=commit_then_fail + ), mock.patch.object( + staging_mod, + "_recover_transaction_locked", + return_value=["process stopped before rollback"], + ): + with self.assertRaises(StagingRecoveryError): + adopt_skills(relative_staging, ["alpha"]) + finally: + os.chdir(previous_cwd) + + self.assertEqual(_read(live), "# alpha v2\n") + adopt_skills(absolute_staging, ["alpha"]) + self.assertEqual(_read(live), "# alpha v2\n") + self.assertFalse(os.path.lexists(os.path.join( + absolute_staging, ".adopt-transaction.json" + ))) + + def test_restart_cleans_own_hardlink_publication_temp(self): + from skillopt_sleep import staging as staging_mod + + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + real_write = staging_mod._write_atomic + + def commit_then_fail(path, text, *, create_parents=True): + result = real_write(path, text, create_parents=create_parents) + if path == night.alpha_live: + raise OSError("simulated interruption") + return result + + with mock.patch.object( + staging_mod, "_write_atomic", side_effect=commit_then_fail + ), mock.patch.object( + staging_mod, + "_recover_transaction_locked", + return_value=["process stopped before rollback"], + ): + with self.assertRaises(StagingRecoveryError): + adopt_skills(night.staging, ["alpha"]) + + backup = os.path.join( + night.staging, "backup", "skills", "alpha", "SKILL.md" + ) + alias = os.path.join(os.path.dirname(backup), ".tmp-new-crash.md") + try: + os.link(backup, alias) + except OSError: + self.skipTest("hard links unavailable") + adopt_skills(night.staging, ["alpha"]) + self.assertFalse(os.path.lexists(alias)) + self.assertFalse(os.path.lexists(os.path.join( + night.staging, ".adopt-transaction.json" + ))) + + def test_rollback_preserves_concurrent_human_edit_and_retains_wal(self): + from skillopt_sleep import staging as staging_mod + + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + real_write = staging_mod._write_atomic + + def fail_beta_after_human_edit(path, text, *, create_parents=True): + if path == night.beta_live: + _write(night.alpha_live, "# concurrent human edit\n") + raise OSError("beta disk failure") + return real_write(path, text, create_parents=create_parents) + + with mock.patch.object( + staging_mod, + "_write_atomic", + side_effect=fail_beta_after_human_edit, + ): + with self.assertRaises(StagingRecoveryError) as caught: + adopt_skills(night.staging) + self.assertIsInstance(caught.exception.primary, OSError) + self.assertEqual(_read(night.alpha_live), "# concurrent human edit\n") + self.assertTrue(os.path.isfile(os.path.join( + night.staging, ".adopt-transaction.json" + ))) + self.assertTrue(os.path.isfile(os.path.join( + night.staging, "backup", "skills", "alpha", "SKILL.md" + ))) + + def test_edit_during_receipt_publication_never_commits_a_false_receipt(self): + from skillopt_sleep import staging as staging_mod + + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + receipt_path = os.path.join(night.staging, "adopted_skills.json") + real_write = staging_mod._write_atomic + + def edit_live_before_receipt(path, text, *, create_parents=True): + if path == receipt_path: + _write(night.alpha_live, "# concurrent human edit\n") + return real_write(path, text, create_parents=create_parents) + + with mock.patch.object( + staging_mod, "_write_atomic", side_effect=edit_live_before_receipt + ): + with self.assertRaises(StagingRecoveryError) as caught: + adopt_skills(night.staging, ["alpha"]) + self.assertIn("changed after publication", str(caught.exception.primary)) + self.assertEqual(_read(night.alpha_live), "# concurrent human edit\n") + self.assertFalse(os.path.lexists(receipt_path)) + self.assertTrue(os.path.isfile(os.path.join( + night.staging, ".adopt-transaction.json" + ))) + + def test_prior_backup_must_still_match_immutable_receipt(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + receipt = adopt_skills(night.staging, ["alpha"])[0] + _write(receipt.backup_path, "# tampered backup\n") + with self.assertRaisesRegex(StagingError, "immutable backup.*changed"): + adopt_skills(night.staging, ["beta"]) + self.assertEqual(_read(night.beta_live), "# beta v1\n") + + def test_prior_backup_cannot_be_reached_through_symlinked_parent(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + receipt = adopt_skills(night.staging, ["alpha"])[0] + backup_parent = os.path.dirname(receipt.backup_path) + outside_parent = os.path.join(tmp, "outside-backup") + os.rename(backup_parent, outside_parent) + try: + os.symlink(outside_parent, backup_parent) + except OSError: + self.skipTest("symlinks unavailable") + with self.assertRaisesRegex(StagingError, "immutable backup.*missing"): + adopt_skills(night.staging, ["beta"]) + self.assertEqual(_read(os.path.join(outside_parent, "SKILL.md")), "# alpha v1\n") + self.assertEqual(_read(night.beta_live), "# beta v1\n") + + def test_existing_receipt_requires_the_exact_schema(self): + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + adopt_skills(night.staging, ["alpha"]) + receipt_path = os.path.join(night.staging, "adopted_skills.json") + with open(receipt_path, encoding="utf-8") as handle: + receipt = json.load(handle) + receipt[0]["unexpected"] = True + with open(receipt_path, "w", encoding="utf-8") as handle: + json.dump(receipt, handle) + with self.assertRaisesRegex(StagingError, "invalid schema"): + adopt_skills(night.staging, ["beta"]) + self.assertEqual(_read(night.beta_live), "# beta v1\n") + + def test_case_only_live_retarget_is_not_the_pinned_posix_identity(self): + if os.path.normcase("a") == os.path.normcase("A"): + self.skipTest("case-insensitive platform path identity") + with tempfile.TemporaryDirectory() as tmp: + night = TwoSkillNight(tmp) + alternate = os.path.join(tmp, "live", "Alpha", "SKILL.md") + _write(alternate, "# alpha v1\n") + manifest_path = os.path.join(night.staging, "manifest.json") + with open(manifest_path, encoding="utf-8") as handle: + manifest = json.load(handle) + manifest["skills"][0]["live_skill_path"] = alternate + with open(manifest_path, "w", encoding="utf-8") as handle: + json.dump(manifest, handle) + with self.assertRaisesRegex(StagingError, "canonical target"): + adopt_skills(night.staging, ["alpha"]) + self.assertEqual(_read(alternate), "# alpha v1\n") + self.assertEqual(_read(night.alpha_live), "# alpha v1\n") + + def test_unicode_equivalent_skill_directory_adopts(self): + with tempfile.TemporaryDirectory() as tmp: + name = "caf\u00e9" + on_disk_name = "cafe\u0301" + live = os.path.join(tmp, "live", on_disk_name, "SKILL.md") + _write(live, "# cafe v1\n") + staging = write_staging( + tmp, + report=SleepReport(night=1, project=tmp, accepted=True), + proposed_skill=None, + proposed_memory=None, + live_skill_path=live, + live_memory_path=os.path.join(tmp, "live", "CLAUDE.md"), + report_md="# report\n", + skill_proposals=[SkillProposal(name, "# cafe v2\n", live)], + ) + receipts = adopt_skills(staging, [name]) + self.assertEqual([row.skill_name for row in receipts], [name]) + self.assertEqual(_read(live), "# cafe v2\n") + + def test_legacy_manifest_is_pinned_and_adoption_has_a_receipt(self): + with tempfile.TemporaryDirectory() as tmp: + staging, skill, memory = self._legacy_night(tmp) + with open(os.path.join(staging, "manifest.json"), encoding="utf-8") as handle: + legacy = json.load(handle)["legacy"] + self.assertEqual(legacy["skill"]["live_sha256"], _sha("# skill v1\n")) + self.assertEqual(legacy["memory"]["live_sha256"], _sha("# memory v1\n")) + self.assertEqual(adopt(staging), [skill, memory]) + self.assertEqual(_read(skill), "# skill v2\n") + self.assertEqual(_read(memory), "# memory v2\n") + with open( + os.path.join(staging, "adopted_legacy.json"), encoding="utf-8" + ) as handle: + self.assertEqual( + [row["target"] for row in json.load(handle)], + ["skill", "memory"], + ) + + def test_legacy_missing_targets_can_share_one_new_parent(self): + with tempfile.TemporaryDirectory() as tmp: + live_root = os.path.join(tmp, "new-live") + skill = os.path.join(live_root, "SKILL.md") + memory = os.path.join(live_root, "CLAUDE.md") + staging = write_staging( + tmp, + report=SleepReport(night=1, project=tmp, accepted=True), + proposed_skill="# skill v2\n", + proposed_memory="# memory v2\n", + live_skill_path=skill, + live_memory_path=memory, + report_md="# report\n", + ) + self.assertEqual(adopt(staging), [skill, memory]) + self.assertEqual(_read(skill), "# skill v2\n") + self.assertEqual(_read(memory), "# memory v2\n") + + def test_failed_legacy_adoption_removes_its_exact_new_directory_tree(self): + from skillopt_sleep import staging as staging_mod + + with tempfile.TemporaryDirectory() as tmp: + live_root = os.path.join(tmp, "new", "nested", "live") + skill = os.path.join(live_root, "SKILL.md") + memory = os.path.join(live_root, "CLAUDE.md") + staging = write_staging( + tmp, + report=SleepReport(night=1, project=tmp, accepted=True), + proposed_skill="# skill v2\n", + proposed_memory="# memory v2\n", + live_skill_path=skill, + live_memory_path=memory, + report_md="# report\n", + ) + receipt = os.path.join(staging, "adopted_legacy.json") + real_write = staging_mod._write_atomic + + def fail_receipt(path, text, *, create_parents=True): + if path == receipt: + raise OSError("receipt device full") + return real_write(path, text, create_parents=create_parents) + + with mock.patch.object( + staging_mod, "_write_atomic", side_effect=fail_receipt + ), self.assertRaisesRegex(OSError, "receipt device full"): + adopt(staging) + self.assertFalse(os.path.lexists(os.path.join(tmp, "new"))) + self.assertFalse(os.path.lexists(os.path.join( + staging, ".adopt-transaction.json" + ))) + + def test_recovery_never_removes_a_replaced_created_directory(self): + from skillopt_sleep import staging as staging_mod + + with tempfile.TemporaryDirectory() as tmp: + live_root = os.path.join(tmp, "new-live") + skill = os.path.join(live_root, "SKILL.md") + memory = os.path.join(live_root, "CLAUDE.md") + staging = write_staging( + tmp, + report=SleepReport(night=1, project=tmp, accepted=True), + proposed_skill="# skill v2\n", + proposed_memory="# memory v2\n", + live_skill_path=skill, + live_memory_path=memory, + report_md="# report\n", + ) + receipt = os.path.join(staging, "adopted_legacy.json") + moved_original = os.path.join(tmp, "transaction-owned-directory") + real_write = staging_mod._write_atomic + + def fail_receipt(path, text, *, create_parents=True): + if path == receipt: + raise OSError("receipt device full") + return real_write(path, text, create_parents=create_parents) + + def replace_before_directory_cleanup(targets, staging_dir): + os.rename(live_root, moved_original) + os.mkdir(live_root) + return [] + + with mock.patch.object( + staging_mod, "_write_atomic", side_effect=fail_receipt + ), mock.patch.object( + staging_mod, + "_cleanup_transaction_backups", + side_effect=replace_before_directory_cleanup, + ): + with self.assertRaises(StagingRecoveryError): + adopt(staging) + self.assertTrue(os.path.isdir(live_root)) + self.assertTrue(os.path.isdir(moved_original)) + self.assertTrue(os.path.isfile(os.path.join( + staging, ".adopt-transaction.json" + ))) + + def test_restart_recovery_removes_journaled_created_directories(self): + from skillopt_sleep import staging as staging_mod + + with tempfile.TemporaryDirectory() as tmp: + live_root = os.path.join(tmp, "restart", "live") + skill = os.path.join(live_root, "SKILL.md") + memory = os.path.join(live_root, "CLAUDE.md") + staging = write_staging( + tmp, + report=SleepReport(night=1, project=tmp, accepted=True), + proposed_skill="# skill v2\n", + proposed_memory="# memory v2\n", + live_skill_path=skill, + live_memory_path=memory, + report_md="# report\n", + ) + receipt = os.path.join(staging, "adopted_legacy.json") + real_write = staging_mod._write_atomic + + def fail_receipt(path, text, *, create_parents=True): + if path == receipt: + raise OSError("simulated interruption") + return real_write(path, text, create_parents=create_parents) + + with mock.patch.object( + staging_mod, "_write_atomic", side_effect=fail_receipt + ), mock.patch.object( + staging_mod, + "_recover_transaction_locked", + return_value=["process stopped before rollback"], + ), self.assertRaises(StagingRecoveryError): + adopt(staging) + self.assertTrue(os.path.isdir(live_root)) + + _write(os.path.join(staging, "manifest.json"), "{broken") + with self.assertRaisesRegex(StagingError, "manifest"): + adopt(staging) + self.assertFalse(os.path.lexists(os.path.join(tmp, "restart"))) + self.assertFalse(os.path.lexists(os.path.join( + staging, ".adopt-transaction.json" + ))) + + def test_legacy_unpinned_manifest_refuses_and_requires_restage(self): + with tempfile.TemporaryDirectory() as tmp: + staging, skill, _memory = self._legacy_night(tmp) + manifest_path = os.path.join(staging, "manifest.json") + with open(manifest_path, encoding="utf-8") as handle: + manifest = json.load(handle) + del manifest["legacy"] + with open(manifest_path, "w", encoding="utf-8") as handle: + json.dump(manifest, handle) + with self.assertRaisesRegex(StagingError, "discard and restage"): + adopt(staging) + self.assertEqual(_read(skill), "# skill v1\n") + + def test_legacy_symlink_swap_never_writes_through_to_outside(self): + with tempfile.TemporaryDirectory() as tmp: + staging, skill, _memory = self._legacy_night(tmp) + outside = os.path.join(tmp, "outside.md") + _write(outside, "# outside\n") + os.unlink(skill) + try: + os.symlink(outside, skill) + except OSError: + self.skipTest("symlinks unavailable") + with self.assertRaisesRegex(StagingError, "symlink"): + adopt(staging) + self.assertEqual(_read(outside), "# outside\n") + + def test_legacy_second_target_failure_rolls_back_first(self): + from skillopt_sleep import staging as staging_mod + + with tempfile.TemporaryDirectory() as tmp: + staging, skill, memory = self._legacy_night(tmp) + real_write = staging_mod._write_atomic + + def fail_memory(path, text, *, create_parents=True): + if path == memory: + raise OSError("memory disk failure") + return real_write(path, text, create_parents=create_parents) + + with mock.patch.object( + staging_mod, "_write_atomic", side_effect=fail_memory + ): + with self.assertRaisesRegex(OSError, "memory disk failure"): + adopt(staging) + self.assertEqual(_read(skill), "# skill v1\n") + self.assertEqual(_read(memory), "# memory v1\n") + self.assertFalse(os.path.lexists(os.path.join( + staging, ".adopt-transaction.json" + ))) + + def test_insecure_existing_lock_root_is_rejected(self): + from skillopt_sleep import staging as staging_mod + + if not hasattr(os, "getuid"): + self.skipTest("POSIX ownership and mode check") + with tempfile.TemporaryDirectory() as tmp: + root = os.path.join(tmp, f"skillopt-sleep-adopt-{os.getuid()}") + os.mkdir(root) + os.chmod(root, 0o777) + with mock.patch.object( + staging_mod.tempfile, "gettempdir", return_value=tmp + ): + with self.assertRaisesRegex(StagingError, "permissions are unsafe"): + staging_mod._target_lock_paths([os.path.join(tmp, "SKILL.md")]) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_sleep_engine.py b/tests/test_sleep_engine.py index ec484749..b0fc3761 100644 --- a/tests/test_sleep_engine.py +++ b/tests/test_sleep_engine.py @@ -6,6 +6,7 @@ """ from __future__ import annotations +import hashlib import json import os import tempfile @@ -585,6 +586,12 @@ def test_cli_report_payload_includes_rejected_edits(self): project="/p", edits=[EditRecord("skill", "add", "accepted rule")], rejected_edits=[EditRecord("skill", "add", "rejected rule")], + skill_groups=[SkillGroupReport( + skill_name="research-skill", + status="consolidated", + accepted=True, + n_tasks=3, + )], ) outcome = type("Outcome", (), {"staging_dir": "", "adopted": False})() @@ -593,6 +600,11 @@ def test_cli_report_payload_includes_rejected_edits(self): self.assertEqual(payload["n_accepted_edits"], 1) self.assertEqual(payload["n_rejected_edits"], 1) self.assertEqual(payload["rejected_edits"][0]["content"], "rejected rule") + self.assertEqual( + payload["skill_groups"][0]["skill_name"], + "research-skill", + ) + self.assertEqual(payload["staged_skills"], []) def test_tasks_file_roundtrip_and_split_assignment(self): from skillopt_sleep.tasks_file import load_tasks_file, make_tasks_payload, write_tasks_file @@ -1203,7 +1215,7 @@ def fake_mkdtemp(*args, **kwargs): return d with mock.patch("tempfile.mkdtemp", side_effect=fake_mkdtemp): be.attempt_with_tools(task, "", "", ["search"]) - + self.assertEqual(len(temp_dirs), 1) work_dir = temp_dirs[0] shim_path = os.path.join(work_dir, "search.cmd") @@ -1381,6 +1393,11 @@ def test_cycle_can_target_repo_scoped_skill_path(self): with open(manifest_path, encoding="utf-8") as f: manifest = json.load(f) self.assertEqual(manifest["live_skill_path"], target) + self.assertEqual(manifest["legacy"]["skill"]["live_sha256"], "") + self.assertEqual( + manifest["legacy"]["skill"]["live_realpath"], + os.path.realpath(target), + ) self.assertFalse(os.path.exists(target)) updated = adopt(outcome.staging_dir) @@ -1388,6 +1405,147 @@ def test_cycle_can_target_repo_scoped_skill_path(self): self.assertIn(target, updated) self.assertTrue(os.path.exists(target)) + def test_cycle_pins_the_exact_managed_skill_and_memory_bytes_it_read(self): + from skillopt_sleep.consolidate import ConsolidationResult + + with tempfile.TemporaryDirectory() as proj, tempfile.TemporaryDirectory() as home: + target = os.path.join(proj, ".agents", "skills", "taste", "SKILL.md") + memory_path = os.path.join(proj, "CLAUDE.md") + os.makedirs(os.path.dirname(target), exist_ok=True) + skill_bytes = b"# managed baseline\nrule\n" + memory_bytes = b"# memory baseline\npreference\n" + with open(target, "wb") as handle: + handle.write(skill_bytes) + with open(memory_path, "wb") as handle: + handle.write(memory_bytes) + cfg = load_config( + invoked_project=proj, + projects="invoked", + backend="mock", + claude_home=os.path.join(home, ".claude"), + target_skill_path=target, + auto_adopt=False, + ) + tasks = assign_splits( + researcher_persona(), + holdout_fraction=0.34, + seed=42, + ) + result = ConsolidationResult( + accepted=True, + gate_action="accept_new_best", + baseline_score=0.1, + candidate_score=0.2, + new_skill="# managed proposal\n", + new_memory="# memory proposal\n", + applied_edits=[], + rejected_edits=[], + holdout_baseline=0.1, + holdout_candidate=0.2, + ) + + with mock.patch( + "skillopt_sleep.cycle.dream_consolidate", + return_value=result, + ): + outcome = run_sleep_cycle(cfg, seed_tasks=tasks) + + with open( + os.path.join(outcome.staging_dir, "manifest.json"), + encoding="utf-8", + ) as handle: + manifest = json.load(handle) + skill_row = manifest["legacy"]["skill"] + memory_row = manifest["legacy"]["memory"] + self.assertEqual( + skill_row["live_sha256"], + hashlib.sha256(skill_bytes).hexdigest(), + ) + self.assertEqual( + memory_row["live_sha256"], + hashlib.sha256(memory_bytes).hexdigest(), + ) + self.assertEqual(skill_row["live_realpath"], os.path.realpath(target)) + self.assertEqual( + memory_row["live_realpath"], + os.path.realpath(memory_path), + ) + + def test_managed_skill_change_during_consolidation_refuses_the_night(self): + from skillopt_sleep.consolidate import ConsolidationResult + from skillopt_sleep.staging import StagingError, latest_staging + + with tempfile.TemporaryDirectory() as proj, tempfile.TemporaryDirectory() as home: + target = os.path.join(proj, ".agents", "skills", "taste", "SKILL.md") + os.makedirs(os.path.dirname(target), exist_ok=True) + with open(target, "w", encoding="utf-8") as handle: + handle.write("# baseline v1\n") + cfg = load_config( + invoked_project=proj, + projects="invoked", + backend="mock", + claude_home=os.path.join(home, ".claude"), + target_skill_path=target, + auto_adopt=False, + ) + tasks = assign_splits( + researcher_persona(), + holdout_fraction=0.34, + seed=42, + ) + result = ConsolidationResult( + accepted=True, + gate_action="accept_new_best", + baseline_score=0.1, + candidate_score=0.2, + new_skill="# proposal derived from v1\n", + new_memory="", + applied_edits=[], + rejected_edits=[], + holdout_baseline=0.1, + holdout_candidate=0.2, + ) + + def _edit_live_after_read(*args, **kwargs): + with open(target, "w", encoding="utf-8") as handle: + handle.write("# concurrent human edit\n") + return result + + with mock.patch( + "skillopt_sleep.cycle.dream_consolidate", + side_effect=_edit_live_after_read, + ), self.assertRaisesRegex(StagingError, "changed during consolidation"): + run_sleep_cycle(cfg, seed_tasks=tasks) + + with open(target, encoding="utf-8") as handle: + self.assertEqual(handle.read(), "# concurrent human edit\n") + self.assertIsNone(latest_staging(proj)) + + def test_invalid_utf8_managed_skill_is_not_treated_as_an_empty_baseline(self): + from skillopt_sleep.staging import StagingError + + with tempfile.TemporaryDirectory() as proj, tempfile.TemporaryDirectory() as home: + target = os.path.join(proj, ".agents", "skills", "taste", "SKILL.md") + os.makedirs(os.path.dirname(target), exist_ok=True) + with open(target, "wb") as handle: + handle.write(b"\xff\xfe\x00not-utf8") + cfg = load_config( + invoked_project=proj, + projects="invoked", + backend="mock", + claude_home=os.path.join(home, ".claude"), + target_skill_path=target, + auto_adopt=False, + ) + tasks = assign_splits( + researcher_persona(), + holdout_fraction=0.34, + seed=42, + ) + + with self.assertRaisesRegex(StagingError, "not valid UTF-8"): + run_sleep_cycle(cfg, seed_tasks=tasks) + class TestCopilotBackend(unittest.TestCase): """Pure-logic tests for CopilotCliBackend — no `copilot` CLI required.""" @@ -1920,10 +2078,30 @@ def test_cursor_failure_aborts_without_state_or_staging_and_cli_returns_nonzero( rc = main(["dry-run", "--project", project, "--backend", "cursor"]) self.assertEqual(rc, 1) - self.assertIn("Cursor backend failed", stderr.getvalue()) + self.assertIn("backend failed", stderr.getvalue()) self.assertIn("[REDACTED]", stderr.getvalue()) self.assertNotIn("cursor-secret", stderr.getvalue()) + def test_run_json_failure_is_one_redacted_document(self): + import contextlib + import io + + from skillopt_sleep.__main__ import main + from skillopt_sleep.staging import StagingError + + stdout = io.StringIO() + with mock.patch( + "skillopt_sleep.__main__.run_sleep_cycle", + side_effect=StagingError("api_key=SUPERSECRET123456789\x1b[31m"), + ), contextlib.redirect_stdout(stdout): + rc = main(["run", "--json", "--project", tempfile.gettempdir()]) + self.assertEqual(rc, 1) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["ok"], False) + self.assertEqual(payload["error"], "staging_refused") + self.assertNotIn("SUPERSECRET", payload["message"]) + self.assertNotIn("\x1b", payload["message"]) + class TestClaudeCliBackendBare(unittest.TestCase): """Issue #68: --bare must be conditional on ANTHROPIC_API_KEY.""" @@ -2028,13 +2206,13 @@ def attempt(self, task: TaskRecord, skill: str, memory: str, sample_id: int = 0) return str(task.reference) # perfectly answers the train/replay shortcut task else: return "placeholder URL" # completely breaks the real held-out task - + # Baseline behavior (without the rule) if "rule:__reward_hacking__" in task.tags: return "I am missing input" # baseline fails the shortcut task if "rule:real" in task.tags: return str(task.reference) # baseline gets the real task right - + return super().attempt(task, skill, memory, sample_id) def reflect(self, failures, successes, skill, memory, **kwargs): @@ -2054,13 +2232,13 @@ def attempt(self, task: TaskRecord, skill: str, memory: str, sample_id: int = 0) return str(task.reference) # improves the train task if "rule:real" in task.tags: return str(task.reference) # improves the real held-out task - + # Baseline behavior (without the rule) if "rule:__beneficial__" in task.tags: return "I am missing input" # baseline fails the train task if "rule:real" in task.tags: return "baseline fails too" # baseline fails the real task - + return super().attempt(task, skill, memory, sample_id) def reflect(self, failures, successes, skill, memory, **kwargs): @@ -2118,7 +2296,7 @@ def test_gate_rejects_reward_hacking_edit(self): tasks = [train_task, val_task] res = consolidate(be, tasks, "", "", edit_budget=4, gate_metric="hard", night=1) - + self.assertFalse(res.accepted) self.assertEqual(res.gate_action, "reject") self.assertEqual(res.holdout_baseline, 1.0) @@ -2133,7 +2311,7 @@ def test_gate_accepts_beneficial_edit(self): tasks = [train_task, val_task] res = consolidate(be, tasks, "", "", edit_budget=4, gate_metric="hard", night=1) - + self.assertTrue(res.accepted) self.assertEqual(res.gate_action, "accept_new_best") self.assertEqual(res.holdout_baseline, 0.0) @@ -2244,6 +2422,7 @@ def test_diagnostics_json_on_disk_has_no_secret(self): """End-to-end: a codex-style 401 stderr captured in call_error must not reach diagnostics.json verbatim once written to the staging dir.""" import json + from skillopt_sleep.staging import redact_secrets # Mirror exactly what cycle.py writes (the fields that carry free text). secret_stderr = ( @@ -2274,7 +2453,6 @@ def test_diagnostics_json_on_disk_has_no_secret(self): def test_codex_auth_error_log_is_redacted(self): """The codex auth-error log line (a secondary on-disk sink when a file log handler is attached) must not emit the raw stderr token verbatim.""" - import logging from skillopt_sleep.backend import CodexCliBackend be = CodexCliBackend.__new__(CodexCliBackend) # no __init__ side effects be.timeout = 1 @@ -2428,6 +2606,22 @@ def test_multi_skill_report_is_off_by_default(self): # Opt-in: hinted evidence alone must not add rows or extra calls. self.assertEqual(outcome.report.skill_groups, []) + def test_multi_skill_fanout_is_the_canonical_flag(self): + with tempfile.TemporaryDirectory() as proj, tempfile.TemporaryDirectory() as home: + claude_home = os.path.join(home, ".claude") + self._write_live_skills(claude_home, "research-skill") + cfg = load_config( + invoked_project=proj, + projects="invoked", + backend="mock", + claude_home=claude_home, + managed_skill_name="skillopt-sleep-learned", + multi_skill_fanout=True, + multi_skill_report=False, + ) + outcome = run_sleep_cycle(cfg, seed_tasks=self._hinted_tasks()) + self.assertTrue(outcome.report.skill_groups) + def test_a_mixed_night_emits_one_independent_row_per_skill(self): with tempfile.TemporaryDirectory() as proj, tempfile.TemporaryDirectory() as home: claude_home = os.path.join(home, ".claude") @@ -2598,6 +2792,35 @@ def test_report_md_keeps_untrusted_group_text_inside_one_table_row(self): self.assertIn("skill|`one next", row) self.assertIn("backend `bad` line | broken", row) + def test_report_md_sanitizes_every_untrusted_prose_field(self): + hostile = "first\n## forged \x1b[31m\u202e [link](javascript:x)" + edit = EditRecord( + target=hostile, + op=hostile, + content=hostile, + anchor=hostile, + rationale=hostile, + ) + report = SleepReport( + night=1, + project=hostile, + gate_action=hostile, + edits=[edit], + rejected_edits=[edit], + unmatched_edits=[edit], + notes=[hostile], + ) + md = _render_report_md( + report, + {"backend": hostile, "replay_mode": hostile}, + ) + self.assertNotIn("\x1b", md) + self.assertNotIn("\u202e", md) + self.assertNotIn("