Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions docs/sleep/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down Expand Up @@ -299,6 +300,12 @@ gate keeps the worst case bounded; keep it **on** by default.

## Learn more

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
[`plugins/`](https://github.com/microsoft/SkillOpt/tree/main/plugins).
142 changes: 142 additions & 0 deletions docs/sleep/multi-skill-staging.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
# Multi-skill staging and subset adoption

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, 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.

## 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",
"sha256": "<sha256 of proposed_SKILL.alpha.md>"
},
{
"skill_name": "beta",
"proposed_file": "proposed_SKILL.beta.md",
"live_skill_path": "/home/dev/.claude/skills/beta/SKILL.md",
"sha256": "<sha256 of proposed_SKILL.beta.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

Low-level API:

```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
```

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 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_name>/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/<skill>/` 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.
74 changes: 71 additions & 3 deletions skillopt_sleep/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 StagingError, adopt_skills, latest_staging, staged_skills
from skillopt_sleep.staging import adopt as adopt_staging
from skillopt_sleep.staging import latest_staging
from skillopt_sleep.state import SleepState
from skillopt_sleep.tasks_file import load_tasks_file, make_tasks_payload, write_tasks_file

Expand Down Expand Up @@ -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)}")

Expand Down Expand Up @@ -414,13 +425,20 @@ 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,
"project": project,
"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))
Expand All @@ -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:
Expand All @@ -445,6 +467,44 @@ def cmd_adopt(args) -> int:
if not target or not os.path.isdir(target):
print("[sleep] nothing to adopt (no staging dir).")
return 1
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.")
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:
Expand Down Expand Up @@ -498,7 +558,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"),
Expand Down Expand Up @@ -535,6 +595,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")
Expand Down
Loading