feat(sleep): adopt reviewed skill subsets safely (#212)
* feat(sleep): adopt reviewed skill subsets safely * 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 * 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 * 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 * fix(sleep): harden multi-skill fan-out adoption end to end --------- Co-authored-by: Yif-Yang <yif_yang@qq.com>
This commit is contained in:
committed by
GitHub
parent
fb1c305f99
commit
faf4700ae2
+13
-2
@@ -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).
|
||||
- Additive `section_contains` rule-judge operator for literal,
|
||||
case-insensitive matching within numbered, bilingual, or annotated ATX
|
||||
Markdown headings. The legacy `section_present` behavior is unchanged.
|
||||
@@ -120,8 +131,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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+17
-2
@@ -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
|
||||
|
||||
@@ -144,11 +145,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. 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
|
||||
|
||||
+38
-13
@@ -100,7 +100,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
|
||||
```
|
||||
|
||||
@@ -108,8 +110,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
|
||||
@@ -292,22 +294,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.
|
||||
It is reporting-only for now: every group starts from the same managed skill
|
||||
document, and Sleep does not yet resolve and update several live `SKILL.md` files
|
||||
automatically. Nights containing only the managed catch-all group keep the existing
|
||||
single-consolidation behavior.
|
||||
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, 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
|
||||
|
||||
@@ -363,6 +381,13 @@ 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 documents the opt-in nightly fan-out, where every hinted group
|
||||
derives its proposal from its own resolved live `SKILL.md` while adoption remains
|
||||
an explicit human decision.
|
||||
|
||||
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).
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
# Multi-skill staging and reviewed adoption
|
||||
|
||||
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.
|
||||
|
||||
There are two independent proposal modes, and one night can contain both:
|
||||
|
||||
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.<name>.md` each. A reviewer chooses an explicit subset.
|
||||
|
||||
`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.
|
||||
|
||||
This feature landed after PyPI 0.2.0. Install from `main` until the next release.
|
||||
|
||||
## How the nightly fan-out works
|
||||
|
||||
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
|
||||
|
||||
A mixed night can contain managed and per-skill artifacts together:
|
||||
|
||||
```text
|
||||
.skillopt-sleep/staging/20260815-013000/
|
||||
├── manifest.json
|
||||
├── proposed_SKILL.md
|
||||
├── proposed_CLAUDE.md
|
||||
├── proposed_SKILL.alpha.md
|
||||
├── proposed_SKILL.beta.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
|
||||
{
|
||||
"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": "<proposed raw-byte sha256>",
|
||||
"live_sha256": "<baseline raw-byte sha256>",
|
||||
"live_realpath": "/repo/.agents/skills/managed/SKILL.md"
|
||||
},
|
||||
"memory": {
|
||||
"proposed_file": "proposed_CLAUDE.md",
|
||||
"live_path": "/repo/CLAUDE.md",
|
||||
"sha256": "<proposed raw-byte sha256>",
|
||||
"live_sha256": "<baseline raw-byte 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": "<proposed raw-byte sha256>",
|
||||
"live_sha256": "<baseline raw-byte sha256>",
|
||||
"live_realpath": "/home/dev/.claude/skills/alpha/SKILL.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
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`.
|
||||
|
||||
`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.
|
||||
|
||||
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.
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
Selection modes are mutually exclusive:
|
||||
|
||||
- `--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"])
|
||||
```
|
||||
|
||||
`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
|
||||
backup/skills/<name>/SKILL.md # per-skill original
|
||||
backup/SKILL.md # managed skill original
|
||||
backup/CLAUDE.md # managed memory original
|
||||
```
|
||||
|
||||
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.
|
||||
@@ -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:
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -60,8 +60,11 @@ what the optimizer writes, add `--preferences "<your house rules>"`.
|
||||
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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+230
-22
@@ -9,7 +9,7 @@ Tools exposed:
|
||||
- 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 <action> ...` and returns its
|
||||
@@ -21,6 +21,7 @@ import json
|
||||
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 @@ TOOLS = [
|
||||
{"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 @@ _TOOL_SCHEMA = {
|
||||
"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 @@ _TOOL_SCHEMA = {
|
||||
"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"}
|
||||
|
||||
def _run_engine(action: str, args: dict) -> str:
|
||||
|
||||
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 _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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
+24
-2
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+232
-42
@@ -10,8 +10,8 @@ Before each data-reading action this server runs `harvest_devin.py` to convert
|
||||
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 @@ from __future__ import annotations
|
||||
|
||||
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 @@ PLUGIN_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
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 @@ TOOLS = [
|
||||
{"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 @@ _TOOL_SCHEMA = {
|
||||
"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 @@ _TOOL_SCHEMA = {
|
||||
# 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"}
|
||||
|
||||
def _run_harvest() -> str:
|
||||
|
||||
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() -> 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 _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}"
|
||||
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:
|
||||
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()
|
||||
|
||||
+348
-32
@@ -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:
|
||||
@@ -33,11 +34,19 @@ from typing import Any, Dict
|
||||
|
||||
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,
|
||||
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.staging import json_safe, latest_staging
|
||||
from skillopt_sleep.state import SleepState
|
||||
from skillopt_sleep.tasks_file import load_tasks_file, make_tasks_payload, write_tasks_file
|
||||
|
||||
@@ -51,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,
|
||||
@@ -66,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,
|
||||
})
|
||||
|
||||
@@ -110,6 +132,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-name>/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",
|
||||
@@ -184,6 +213,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):
|
||||
@@ -211,12 +250,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
|
||||
@@ -235,17 +283,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 = []
|
||||
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:
|
||||
@@ -267,6 +344,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:
|
||||
@@ -363,7 +463,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.
|
||||
@@ -381,7 +484,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
|
||||
|
||||
|
||||
@@ -417,7 +523,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):
|
||||
@@ -425,8 +530,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
|
||||
|
||||
|
||||
@@ -435,6 +550,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 = []
|
||||
all_skills = []
|
||||
has_managed = False
|
||||
staging_error = ""
|
||||
if latest:
|
||||
try:
|
||||
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,
|
||||
@@ -442,36 +571,211 @@ 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],
|
||||
"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}")
|
||||
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" {_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
|
||||
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)")
|
||||
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):
|
||||
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))
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
return fail(2, "adoption_refused", f"[sleep] adopt refused: {exc}")
|
||||
except OSError as exc:
|
||||
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:
|
||||
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" {_display_value(row.get('skill_name', ''))!r} -> "
|
||||
f"{_display_value(row.get('live_skill_path', ''))!r}"
|
||||
)
|
||||
return 2
|
||||
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
|
||||
|
||||
|
||||
@@ -519,18 +823,18 @@ 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"),
|
||||
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
|
||||
|
||||
|
||||
@@ -539,7 +843,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
|
||||
|
||||
|
||||
@@ -556,6 +860,18 @@ 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_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")
|
||||
|
||||
@@ -75,12 +75,16 @@ DEFAULTS: Dict[str, Any] = {
|
||||
"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 <name>/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",
|
||||
|
||||
+344
-32
@@ -9,12 +9,15 @@ CI use. With backend="anthropic" it spends the user's budget for real lift.
|
||||
"""
|
||||
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
|
||||
@@ -25,15 +28,30 @@ 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
|
||||
from skillopt_sleep.multi_skill import (
|
||||
SKIPPED,
|
||||
GroupConsolidation,
|
||||
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,
|
||||
json_safe,
|
||||
redact_secrets,
|
||||
skill_proposal_rows,
|
||||
write_staging,
|
||||
)
|
||||
from skillopt_sleep.staging import adopt as adopt_staging
|
||||
from skillopt_sleep.staging import json_safe, redact_secrets, write_staging
|
||||
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:
|
||||
@@ -148,6 +166,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)
|
||||
@@ -167,9 +212,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("<", "<")
|
||||
@@ -179,6 +242,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:
|
||||
@@ -191,16 +262,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}",
|
||||
@@ -258,7 +333,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
|
||||
@@ -268,7 +349,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)")
|
||||
@@ -277,8 +361,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
|
||||
@@ -313,19 +404,188 @@ 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 "<unnamed>"
|
||||
redacted = str(redact_secrets(f"cycle skipped skill {label}: {reason}"))
|
||||
return _one_line_display_text(redacted)
|
||||
|
||||
|
||||
def _skill_groups_from_live_baselines(
|
||||
cfg: SleepConfig,
|
||||
grouped: dict[str, List[TaskRecord]],
|
||||
managed_name: str,
|
||||
managed_skill: str,
|
||||
) -> tuple[
|
||||
List[SkillGroup],
|
||||
dict[str, GroupConsolidation],
|
||||
dict[str, str],
|
||||
dict[str, str],
|
||||
List[str],
|
||||
]:
|
||||
"""Load each hinted group's own live skill before consolidation.
|
||||
|
||||
The managed catch-all keeps the already-loaded managed document. Every
|
||||
other group must resolve uniquely and be readable; otherwise it is
|
||||
reported and skipped before a proposal can be derived from the wrong
|
||||
baseline. Resolved paths are returned with the groups so staging targets
|
||||
the same live file that supplied the proposal's input document.
|
||||
"""
|
||||
roots = skill_search_roots(cfg)
|
||||
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()
|
||||
if name == managed_name:
|
||||
groups.append(SkillGroup(name, managed_skill, rows))
|
||||
continue
|
||||
|
||||
resolution = resolve_skill(name, roots)
|
||||
if not resolution.ok:
|
||||
reason = resolution.reason or resolution.status
|
||||
skipped[name] = GroupConsolidation(
|
||||
name,
|
||||
SKIPPED,
|
||||
reason=reason,
|
||||
n_tasks=len(rows),
|
||||
)
|
||||
notes.append(_cycle_skip_note(name, reason))
|
||||
continue
|
||||
try:
|
||||
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(
|
||||
name,
|
||||
SKIPPED,
|
||||
reason=reason,
|
||||
n_tasks=len(rows),
|
||||
)
|
||||
notes.append(_cycle_skip_note(name, reason))
|
||||
continue
|
||||
|
||||
groups.append(SkillGroup(name, live_skill, rows))
|
||||
live_paths[name] = resolution.path
|
||||
live_hashes[name] = hashlib.sha256(live_bytes).hexdigest()
|
||||
return groups, skipped, live_paths, live_hashes, notes
|
||||
|
||||
|
||||
def _skill_proposals_from_groups(
|
||||
cfg: SleepConfig,
|
||||
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.
|
||||
|
||||
In the cycle, ``resolved_paths`` pins each accepted result to the live
|
||||
``SKILL.md`` that supplied its consolidation baseline. Direct low-level
|
||||
callers may omit it and resolve names here. Unresolved, ambiguous, 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] = []
|
||||
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
|
||||
if not str(new_skill or "").strip():
|
||||
notes.append(_cycle_skip_note(name, "empty proposed_skill"))
|
||||
continue
|
||||
if resolved_paths is not None:
|
||||
live_path = resolved_paths.get(name, "")
|
||||
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:
|
||||
notes.append(
|
||||
_cycle_skip_note(name, resolution.reason or resolution.status)
|
||||
)
|
||||
continue
|
||||
live_path = resolution.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:
|
||||
notes.append(_cycle_skip_note(name, str(exc)))
|
||||
continue
|
||||
proposals.append(candidate)
|
||||
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,
|
||||
*,
|
||||
@@ -413,9 +673,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"),
|
||||
@@ -594,27 +862,57 @@ 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.
|
||||
if cfg.get("multi_skill_report", False):
|
||||
managed_name = cfg.get("managed_skill_name", "skillopt-sleep-learned")
|
||||
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 _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)}")
|
||||
group_outcomes = consolidate_groups(
|
||||
backend,
|
||||
[SkillGroup(name, skill, rows) for name, rows in grouped.items()],
|
||||
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,
|
||||
(
|
||||
live_groups,
|
||||
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)
|
||||
if outcome is not None:
|
||||
group_outcomes[name] = outcome
|
||||
group_rows = skill_group_reports(group_outcomes)
|
||||
# Skill hints and isolated backend failures are both untrusted
|
||||
# free text. Scrub them before either report.json or report.md is
|
||||
@@ -634,9 +932,18 @@ def run_sleep_cycle(
|
||||
adopted_paths: List[str] = []
|
||||
if not dry_run:
|
||||
_progress(cfg, "staging start")
|
||||
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, skip_notes = _skill_proposals_from_groups(
|
||||
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)
|
||||
staging_dir = write_staging(
|
||||
project,
|
||||
report=report,
|
||||
@@ -644,8 +951,13 @@ 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,
|
||||
)
|
||||
if ev is not None:
|
||||
ev.log("stage", "staged", staging_dir=staging_dir,
|
||||
|
||||
@@ -13,8 +13,9 @@ from __future__ import annotations
|
||||
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],
|
||||
|
||||
+73
-16
@@ -5,7 +5,9 @@ runs the sleep cycle automatically, so the user doesn't have to wire it manually
|
||||
"""
|
||||
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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
``<claude_home>/skills`` holds hand-written skills. Installed Claude Code
|
||||
plugins expose theirs under the plugin cache, in either the versioned
|
||||
marketplace layout ``plugins/cache/<marketplace>/<plugin>/<version>/skills``
|
||||
or the legacy ``plugins/cache/<marketplace>/<plugin>/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] = []
|
||||
|
||||
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):
|
||||
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"),
|
||||
)
|
||||
)
|
||||
|
||||
# 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:
|
||||
|
||||
+2554
-78
File diff suppressed because it is too large
Load Diff
@@ -11,9 +11,10 @@ short-term -> long-term transfer). It records:
|
||||
"""
|
||||
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 @@ DEFAULT_STATE: Dict[str, Any] = {
|
||||
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 @@ class 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)
|
||||
|
||||
+283
-3
@@ -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 subprocess
|
||||
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 @@ class TestDevinMcpSchema(unittest.TestCase):
|
||||
# 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
|
||||
|
||||
@@ -7,6 +7,7 @@ import os
|
||||
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 @@ class TestHandoffCli(unittest.TestCase):
|
||||
# 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
|
||||
|
||||
+262
-10
@@ -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()
|
||||
|
||||
@@ -18,7 +18,10 @@ PLUGIN_SKILL_MDS = {
|
||||
}
|
||||
|
||||
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")
|
||||
@@ -202,10 +205,24 @@ assert backend.name == "mock", backend.name
|
||||
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()
|
||||
|
||||
@@ -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 TestSchedulerWindows(unittest.TestCase):
|
||||
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 TestSchedulerWindows(unittest.TestCase):
|
||||
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 TestSchedulerWindows(unittest.TestCase):
|
||||
# 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 TestSchedulerWindows(unittest.TestCase):
|
||||
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:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+551
-14
@@ -6,6 +6,7 @@ Run: python3.12 -m pytest tests/test_sleep_engine.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
@@ -585,6 +586,12 @@ class TestHarvest(unittest.TestCase):
|
||||
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 @@ class TestHarvest(unittest.TestCase):
|
||||
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 @@ class TestCodexBackend(unittest.TestCase):
|
||||
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 @@ class TestFullCycleAndAdopt(unittest.TestCase):
|
||||
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 @@ class TestFullCycleAndAdopt(unittest.TestCase):
|
||||
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."""
|
||||
@@ -1940,10 +2098,30 @@ class TestCursorBackend(unittest.TestCase):
|
||||
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."""
|
||||
@@ -2048,13 +2226,13 @@ class MockRewardHackingBackend(MockBackend):
|
||||
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):
|
||||
@@ -2074,13 +2252,13 @@ class MockBeneficialBackend(MockBackend):
|
||||
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):
|
||||
@@ -2138,7 +2316,7 @@ class TestVerifierDiscipline(unittest.TestCase):
|
||||
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)
|
||||
@@ -2153,7 +2331,7 @@ class TestVerifierDiscipline(unittest.TestCase):
|
||||
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)
|
||||
@@ -2264,6 +2442,7 @@ class TestDiagnosticsRedaction(unittest.TestCase):
|
||||
"""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 = (
|
||||
@@ -2294,7 +2473,6 @@ class TestDiagnosticsRedaction(unittest.TestCase):
|
||||
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
|
||||
@@ -2419,6 +2597,13 @@ if __name__ == "__main__":
|
||||
class TestMultiSkillReportWiring(unittest.TestCase):
|
||||
"""The per-skill rows reach the emitted report (issue #120 follow-up)."""
|
||||
|
||||
def _write_live_skills(self, claude_home, *names):
|
||||
for name in names:
|
||||
path = os.path.join(claude_home, "skills", name, "SKILL.md")
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as handle:
|
||||
handle.write(f"# {name}\n")
|
||||
|
||||
def _hinted_tasks(self):
|
||||
# Two skills' worth of evidence in one night. dataclasses.replace keeps
|
||||
# the personas' real task shape rather than inventing a fixture.
|
||||
@@ -2441,11 +2626,31 @@ class TestMultiSkillReportWiring(unittest.TestCase):
|
||||
# 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")
|
||||
self._write_live_skills(
|
||||
claude_home, "research-skill", "programming-skill"
|
||||
)
|
||||
cfg = load_config(
|
||||
invoked_project=proj, projects="invoked", backend="mock",
|
||||
claude_home=os.path.join(home, ".claude"),
|
||||
claude_home=claude_home,
|
||||
managed_skill_name="skillopt-sleep-learned", auto_adopt=False,
|
||||
multi_skill_report=True,
|
||||
)
|
||||
@@ -2475,9 +2680,11 @@ class TestMultiSkillReportWiring(unittest.TestCase):
|
||||
if task.skill_hint == "research-skill"
|
||||
]
|
||||
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=os.path.join(home, ".claude"),
|
||||
claude_home=claude_home,
|
||||
managed_skill_name="skillopt-sleep-learned", auto_adopt=False,
|
||||
multi_skill_report=True,
|
||||
)
|
||||
@@ -2529,9 +2736,16 @@ class TestMultiSkillReportWiring(unittest.TestCase):
|
||||
tasks = self._hinted_tasks()
|
||||
tasks.append(replace(tasks[0], id="thin-1", skill_hint="thin-skill"))
|
||||
with tempfile.TemporaryDirectory() as proj, tempfile.TemporaryDirectory() as home:
|
||||
claude_home = os.path.join(home, ".claude")
|
||||
self._write_live_skills(
|
||||
claude_home,
|
||||
"research-skill",
|
||||
"programming-skill",
|
||||
"thin-skill",
|
||||
)
|
||||
cfg = load_config(
|
||||
invoked_project=proj, projects="invoked", backend="mock",
|
||||
claude_home=os.path.join(home, ".claude"),
|
||||
claude_home=claude_home,
|
||||
managed_skill_name="skillopt-sleep-learned", auto_adopt=False,
|
||||
multi_skill_report=True,
|
||||
)
|
||||
@@ -2553,9 +2767,16 @@ class TestMultiSkillReportWiring(unittest.TestCase):
|
||||
tasks = self._hinted_tasks()
|
||||
tasks.append(replace(tasks[0], id="thin-1", skill_hint="thin-skill"))
|
||||
with tempfile.TemporaryDirectory() as proj, tempfile.TemporaryDirectory() as home:
|
||||
claude_home = os.path.join(home, ".claude")
|
||||
self._write_live_skills(
|
||||
claude_home,
|
||||
"research-skill",
|
||||
"programming-skill",
|
||||
"thin-skill",
|
||||
)
|
||||
cfg = load_config(
|
||||
invoked_project=proj, projects="invoked", backend="mock",
|
||||
claude_home=os.path.join(home, ".claude"),
|
||||
claude_home=claude_home,
|
||||
managed_skill_name="skillopt-sleep-learned", auto_adopt=False,
|
||||
multi_skill_report=True,
|
||||
)
|
||||
@@ -2591,6 +2812,35 @@ class TestMultiSkillReportWiring(unittest.TestCase):
|
||||
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 <script>x</script> \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("<script>", md)
|
||||
self.assertNotIn("\n## forged", md)
|
||||
self.assertNotIn("[link](javascript:x)", md)
|
||||
self.assertIn("<script>x</script>", md)
|
||||
|
||||
def test_report_md_has_no_group_section_when_the_feature_is_off(self):
|
||||
with tempfile.TemporaryDirectory() as proj, tempfile.TemporaryDirectory() as home:
|
||||
cfg = load_config(
|
||||
@@ -2604,10 +2854,16 @@ class TestMultiSkillReportWiring(unittest.TestCase):
|
||||
self.assertNotIn("## Per-skill groups", handle.read())
|
||||
|
||||
def test_group_rows_survive_into_the_staged_report_json(self):
|
||||
from skillopt_sleep.__main__ import _report_payload
|
||||
|
||||
with tempfile.TemporaryDirectory() as proj, tempfile.TemporaryDirectory() as home:
|
||||
claude_home = os.path.join(home, ".claude")
|
||||
self._write_live_skills(
|
||||
claude_home, "research-skill", "programming-skill"
|
||||
)
|
||||
cfg = load_config(
|
||||
invoked_project=proj, projects="invoked", backend="mock",
|
||||
claude_home=os.path.join(home, ".claude"),
|
||||
claude_home=claude_home,
|
||||
managed_skill_name="skillopt-sleep-learned", auto_adopt=False,
|
||||
multi_skill_report=True,
|
||||
)
|
||||
@@ -2620,3 +2876,284 @@ class TestMultiSkillReportWiring(unittest.TestCase):
|
||||
self.assertTrue(payload.get("skill_groups"))
|
||||
self.assertIn(payload["skill_groups"][0]["skill_name"],
|
||||
{"research-skill", "programming-skill"})
|
||||
cli_payload = _report_payload(outcome.report, outcome)
|
||||
self.assertEqual(
|
||||
set(cli_payload["staged_skills"]),
|
||||
{"research-skill", "programming-skill"},
|
||||
)
|
||||
self.assertEqual(
|
||||
{row["skill_name"] for row in cli_payload["skill_groups"]},
|
||||
{"research-skill", "programming-skill"},
|
||||
)
|
||||
|
||||
def test_group_runs_inherit_dream_gate_budget_and_scoped_recall_config(self):
|
||||
"""The fan-out must be the configured dream pipeline, not a side path."""
|
||||
from dataclasses import replace
|
||||
|
||||
from skillopt_sleep.dream import dream_consolidate as real_dream_consolidate
|
||||
from skillopt_sleep.state import SleepState
|
||||
|
||||
with tempfile.TemporaryDirectory() as proj, tempfile.TemporaryDirectory() as home:
|
||||
claude_home = os.path.join(home, ".claude")
|
||||
self._write_live_skills(
|
||||
claude_home,
|
||||
"research-skill",
|
||||
"programming-skill",
|
||||
)
|
||||
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,
|
||||
recall_k=7,
|
||||
dream_rollouts=3,
|
||||
dream_factor=2,
|
||||
edit_budget=6,
|
||||
gate_metric="mixed",
|
||||
gate_mixed_weight=0.37,
|
||||
gate_no_regression=True,
|
||||
gate_mode="off",
|
||||
evolve_skill=True,
|
||||
evolve_memory=True,
|
||||
)
|
||||
tonight = self._hinted_tasks()
|
||||
research_history = replace(
|
||||
tonight[0],
|
||||
id="history-research",
|
||||
skill_hint="research-skill",
|
||||
)
|
||||
programming_seed = next(
|
||||
task for task in tonight
|
||||
if task.skill_hint == "programming-skill"
|
||||
)
|
||||
programming_history = replace(
|
||||
programming_seed,
|
||||
id="history-programming",
|
||||
skill_hint="programming-skill",
|
||||
)
|
||||
state = SleepState.load(cfg.state_path)
|
||||
state.add_to_archive([
|
||||
research_history.to_dict(),
|
||||
programming_history.to_dict(),
|
||||
])
|
||||
state.save()
|
||||
|
||||
calls = []
|
||||
|
||||
def _spy(backend, tasks, skill, memory, **kwargs):
|
||||
calls.append({
|
||||
"hints": {task.skill_hint for task in tasks},
|
||||
"kwargs": dict(kwargs),
|
||||
})
|
||||
# Keep this regression fast while preserving the exact
|
||||
# arguments observed at the public orchestration boundary.
|
||||
safe = dict(kwargs)
|
||||
safe.update(recall_k=0, dream_rollouts=1, dream_factor=0)
|
||||
return real_dream_consolidate(
|
||||
backend,
|
||||
tasks,
|
||||
skill,
|
||||
memory,
|
||||
**safe,
|
||||
)
|
||||
|
||||
with mock.patch(
|
||||
"skillopt_sleep.cycle.dream_consolidate",
|
||||
side_effect=_spy,
|
||||
):
|
||||
run_sleep_cycle(cfg, seed_tasks=tonight)
|
||||
|
||||
self.assertEqual(len(calls), 3)
|
||||
aggregate = calls[0]
|
||||
group_calls = calls[1:]
|
||||
self.assertEqual(
|
||||
aggregate["hints"],
|
||||
{"research-skill", "programming-skill"},
|
||||
)
|
||||
self.assertTrue(aggregate["kwargs"]["evolve_memory"])
|
||||
self.assertEqual(
|
||||
{
|
||||
task.skill_hint
|
||||
for task in aggregate["kwargs"]["history_tasks"]
|
||||
},
|
||||
{"research-skill", "programming-skill"},
|
||||
)
|
||||
|
||||
expected = {
|
||||
"recall_k": 7,
|
||||
"dream_rollouts": 3,
|
||||
"dream_factor": 2,
|
||||
"edit_budget": 6,
|
||||
"gate_metric": "mixed",
|
||||
"gate_mixed_weight": 0.37,
|
||||
"gate_no_regression": True,
|
||||
"gate_mode": "off",
|
||||
"evolve_skill": True,
|
||||
}
|
||||
self.assertEqual(
|
||||
{next(iter(call["hints"])) for call in group_calls},
|
||||
{"research-skill", "programming-skill"},
|
||||
)
|
||||
for call in group_calls:
|
||||
kwargs = call["kwargs"]
|
||||
for key, value in expected.items():
|
||||
self.assertEqual(kwargs[key], value, key)
|
||||
self.assertFalse(kwargs["evolve_memory"])
|
||||
hint = next(iter(call["hints"]))
|
||||
self.assertEqual(
|
||||
{task.skill_hint for task in kwargs["history_tasks"]},
|
||||
{hint},
|
||||
)
|
||||
|
||||
def test_managed_and_fanout_proposals_never_target_the_same_live_file(self):
|
||||
from skillopt_sleep.staging import staged_skills
|
||||
|
||||
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",
|
||||
)
|
||||
os.makedirs(os.path.dirname(research_live), exist_ok=True)
|
||||
os.makedirs(os.path.dirname(programming_live), exist_ok=True)
|
||||
with open(research_live, "w", encoding="utf-8") as handle:
|
||||
handle.write("# research baseline\n")
|
||||
with open(programming_live, "w", encoding="utf-8") as handle:
|
||||
handle.write("# programming baseline\n")
|
||||
cfg = load_config(
|
||||
invoked_project=proj,
|
||||
projects="invoked",
|
||||
backend="mock",
|
||||
claude_home=claude_home,
|
||||
target_skill_path=research_live,
|
||||
managed_skill_name="skillopt-sleep-learned",
|
||||
auto_adopt=False,
|
||||
multi_skill_report=True,
|
||||
gate_mode="off",
|
||||
)
|
||||
|
||||
outcome = run_sleep_cycle(cfg, seed_tasks=self._hinted_tasks())
|
||||
|
||||
self.assertEqual(
|
||||
[row["skill_name"] for row in staged_skills(outcome.staging_dir)],
|
||||
["programming-skill"],
|
||||
)
|
||||
self.assertTrue(any(
|
||||
"research-skill" in note
|
||||
and "same live target as the managed skill" in note
|
||||
for note in outcome.report.notes
|
||||
))
|
||||
with open(
|
||||
os.path.join(outcome.staging_dir, "manifest.json"),
|
||||
encoding="utf-8",
|
||||
) as handle:
|
||||
manifest = json.load(handle)
|
||||
self.assertFalse(manifest["has_skill"])
|
||||
self.assertTrue(manifest["has_managed_skill"])
|
||||
self.assertEqual(
|
||||
manifest["legacy"]["skill"]["live_realpath"],
|
||||
os.path.realpath(research_live),
|
||||
)
|
||||
|
||||
def test_single_contained_symlink_alias_is_reported_but_not_staged(self):
|
||||
from dataclasses import replace
|
||||
|
||||
from skillopt_sleep.staging import staged_skills
|
||||
|
||||
with tempfile.TemporaryDirectory() as proj, tempfile.TemporaryDirectory() as home:
|
||||
claude_home = os.path.join(home, ".claude")
|
||||
skills_root = os.path.join(claude_home, "skills")
|
||||
real_dir = os.path.join(skills_root, "real-skill")
|
||||
alias_dir = os.path.join(skills_root, "alias-skill")
|
||||
os.makedirs(real_dir, exist_ok=True)
|
||||
with open(
|
||||
os.path.join(real_dir, "SKILL.md"),
|
||||
"w",
|
||||
encoding="utf-8",
|
||||
) as handle:
|
||||
handle.write("# real baseline\n")
|
||||
try:
|
||||
os.symlink(real_dir, alias_dir)
|
||||
except OSError:
|
||||
self.skipTest("symlinks unavailable")
|
||||
tasks = [
|
||||
replace(task, skill_hint="alias-skill")
|
||||
for task in assign_splits(
|
||||
researcher_persona(),
|
||||
holdout_fraction=0.34,
|
||||
seed=42,
|
||||
)
|
||||
]
|
||||
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,
|
||||
gate_mode="off",
|
||||
)
|
||||
|
||||
outcome = run_sleep_cycle(cfg, seed_tasks=tasks)
|
||||
|
||||
self.assertEqual(staged_skills(outcome.staging_dir), [])
|
||||
self.assertTrue(any(
|
||||
"alias-skill" in note
|
||||
and "is not alias-skill/SKILL.md" in note
|
||||
for note in outcome.report.notes
|
||||
))
|
||||
with open(
|
||||
os.path.join(outcome.staging_dir, "report.md"),
|
||||
encoding="utf-8",
|
||||
) as handle:
|
||||
self.assertIn("alias-skill", handle.read())
|
||||
|
||||
def test_group_control_flow_exceptions_never_publish_or_advance_a_night(self):
|
||||
from skillopt_sleep.backend import CursorBackendError
|
||||
from skillopt_sleep.handoff_backend import PendingCalls
|
||||
from skillopt_sleep.staging import latest_staging
|
||||
|
||||
cases = (
|
||||
PendingCalls({"group-call": {"prompt": "continue", "max_tokens": 20}}),
|
||||
CursorBackendError("group backend authentication failed"),
|
||||
)
|
||||
for failure in cases:
|
||||
with self.subTest(failure=type(failure).__name__), (
|
||||
tempfile.TemporaryDirectory()
|
||||
) as proj, tempfile.TemporaryDirectory() as home:
|
||||
claude_home = os.path.join(home, ".claude")
|
||||
self._write_live_skills(
|
||||
claude_home,
|
||||
"research-skill",
|
||||
"programming-skill",
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
with mock.patch(
|
||||
"skillopt_sleep.cycle.consolidate_groups",
|
||||
side_effect=failure,
|
||||
), self.assertRaises(type(failure)):
|
||||
run_sleep_cycle(cfg, seed_tasks=self._hinted_tasks())
|
||||
|
||||
self.assertIsNone(latest_staging(proj))
|
||||
self.assertFalse(os.path.exists(cfg.state_path))
|
||||
|
||||
@@ -147,6 +147,35 @@ class TestConsolidateGroups(unittest.TestCase):
|
||||
self.assertEqual(outcomes["research-skill"].status, CONSOLIDATED)
|
||||
self.assertTrue(outcomes["research-skill"].accepted)
|
||||
|
||||
def test_pending_handoff_calls_propagate_instead_of_becoming_failed_rows(self):
|
||||
from skillopt_sleep.handoff_backend import PendingCalls
|
||||
|
||||
pending = {"prompt-id": {"prompt": "answer me", "max_tokens": 20}}
|
||||
|
||||
def _pending(*args, **kwargs):
|
||||
raise PendingCalls(pending)
|
||||
|
||||
with self.assertRaises(PendingCalls) as caught:
|
||||
consolidate_groups(
|
||||
MockBackend(),
|
||||
[SkillGroup("research-skill", "# research\n", _tasks(researcher_persona))],
|
||||
consolidate_fn=_pending,
|
||||
)
|
||||
self.assertEqual(caught.exception.pending, pending)
|
||||
|
||||
def test_fatal_cursor_error_propagates_instead_of_becoming_a_failed_row(self):
|
||||
from skillopt_sleep.backend import CursorBackendError
|
||||
|
||||
def _fatal(*args, **kwargs):
|
||||
raise CursorBackendError("authentication failed")
|
||||
|
||||
with self.assertRaises(CursorBackendError):
|
||||
consolidate_groups(
|
||||
MockBackend(),
|
||||
[SkillGroup("research-skill", "# research\n", _tasks(researcher_persona))],
|
||||
consolidate_fn=_fatal,
|
||||
)
|
||||
|
||||
def test_group_runs_do_not_evolve_shared_memory(self):
|
||||
seen = {}
|
||||
|
||||
@@ -178,6 +207,72 @@ class TestConsolidateGroups(unittest.TestCase):
|
||||
self.assertEqual(outcomes["research-skill"].status, CONSOLIDATED)
|
||||
self.assertFalse(seen["evolve_memory"])
|
||||
|
||||
def test_group_specific_dream_kwargs_preserve_skill_boundaries(self):
|
||||
seen = {}
|
||||
|
||||
def _fake(backend, tasks, skill, memory, **kwargs):
|
||||
seen[skill] = kwargs.pop("history_tasks")
|
||||
return consolidate(backend, tasks, skill, memory, **kwargs)
|
||||
|
||||
histories = {
|
||||
"# research\n": ["research-history"],
|
||||
"# programming\n": ["programming-history"],
|
||||
}
|
||||
outcomes = consolidate_groups(
|
||||
MockBackend(),
|
||||
[
|
||||
SkillGroup("research-skill", "# research\n", _tasks(researcher_persona)),
|
||||
SkillGroup(
|
||||
"programming-skill",
|
||||
"# programming\n",
|
||||
_tasks(programmer_persona, seed=1),
|
||||
),
|
||||
],
|
||||
consolidate_fn=_fake,
|
||||
group_kwargs_fn=lambda group: {
|
||||
"history_tasks": histories[group.skill]
|
||||
},
|
||||
edit_budget=4,
|
||||
night=1,
|
||||
)
|
||||
self.assertEqual(seen, histories)
|
||||
self.assertEqual(outcomes["research-skill"].status, CONSOLIDATED)
|
||||
self.assertEqual(outcomes["programming-skill"].status, CONSOLIDATED)
|
||||
|
||||
def test_group_kwargs_failure_is_isolated_like_a_group_backend_failure(self):
|
||||
calls = []
|
||||
|
||||
def _kwargs(group):
|
||||
if group.skill_name == "broken-skill":
|
||||
raise ValueError("invalid recalled history")
|
||||
return {"history_tasks": []}
|
||||
|
||||
def _fake(backend, tasks, skill, memory, **kwargs):
|
||||
calls.append(skill)
|
||||
kwargs.pop("history_tasks")
|
||||
return consolidate(backend, tasks, skill, memory, **kwargs)
|
||||
|
||||
outcomes = consolidate_groups(
|
||||
MockBackend(),
|
||||
[
|
||||
SkillGroup("broken-skill", "# broken\n", _tasks(researcher_persona)),
|
||||
SkillGroup(
|
||||
"research-skill",
|
||||
set_learned("", []),
|
||||
_tasks(researcher_persona),
|
||||
),
|
||||
],
|
||||
consolidate_fn=_fake,
|
||||
group_kwargs_fn=_kwargs,
|
||||
edit_budget=4,
|
||||
night=1,
|
||||
)
|
||||
|
||||
self.assertEqual(outcomes["broken-skill"].status, FAILED)
|
||||
self.assertIn("ValueError: invalid recalled history", outcomes["broken-skill"].reason)
|
||||
self.assertEqual(outcomes["research-skill"].status, CONSOLIDATED)
|
||||
self.assertEqual(len(calls), 1)
|
||||
|
||||
def test_accepted_group_skills_lists_only_accepted_updates(self):
|
||||
outcomes = {
|
||||
"kept": GroupConsolidation(
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Safety and portability checks for the built-in sleep scheduler."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shlex
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from skillopt_sleep import scheduler
|
||||
|
||||
|
||||
class TestSleepSchedulerSafety(unittest.TestCase):
|
||||
def test_posix_runner_quotes_every_path_and_argument(self):
|
||||
with tempfile.TemporaryDirectory(prefix="sleep $' quote ") as project:
|
||||
command = scheduler._runner_cmd(
|
||||
project,
|
||||
"mock;not-a-command",
|
||||
"--auto-adopt",
|
||||
"/tmp/python with spaces",
|
||||
)
|
||||
self.assertIn(shlex.quote(project), command)
|
||||
self.assertIn(shlex.quote("mock;not-a-command"), command)
|
||||
self.assertIn(shlex.quote("/tmp/python with spaces"), command)
|
||||
self.assertNotIn("$(", command)
|
||||
|
||||
def test_control_characters_are_refused_before_crontab_write(self):
|
||||
with mock.patch.object(scheduler, "_write_crontab") as write:
|
||||
ok, message = scheduler.schedule("/tmp/project\n* * * * * injected")
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("control characters", message)
|
||||
write.assert_not_called()
|
||||
|
||||
def test_backend_and_extra_control_characters_are_refused(self):
|
||||
cases = (
|
||||
("mock\n* * * * * injected", ""),
|
||||
("mock", "--auto-adopt\n* * * * * injected"),
|
||||
)
|
||||
for backend, extra in cases:
|
||||
with self.subTest(backend=backend, extra=extra):
|
||||
with mock.patch.object(scheduler, "_write_crontab") as write:
|
||||
ok, message = scheduler.schedule(
|
||||
"/tmp/project", backend=backend, extra=extra
|
||||
)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("control characters", message)
|
||||
write.assert_not_called()
|
||||
|
||||
def test_project_marker_contains_only_a_digest(self):
|
||||
project = "/tmp/project with secret api_key=SUPERSECRET123456789"
|
||||
marker = scheduler._project_marker(project)
|
||||
self.assertRegex(marker, r"^# project-sha256=[0-9a-f]{64}$")
|
||||
self.assertNotIn("project with secret", marker)
|
||||
|
||||
def test_schedule_time_ranges_fail_closed(self):
|
||||
for hour, minute in ((-1, 0), (24, 0), (0, -1), (0, 60)):
|
||||
with self.subTest(hour=hour, minute=minute):
|
||||
ok, _message = scheduler.schedule(
|
||||
os.getcwd(), hour=hour, minute=minute
|
||||
)
|
||||
self.assertFalse(ok)
|
||||
|
||||
def test_windows_helper_uses_powershell_data_literals(self):
|
||||
with tempfile.TemporaryDirectory(prefix="sleep ' percent% ") as project:
|
||||
with mock.patch.object(scheduler.sys, "platform", "win32"):
|
||||
command = scheduler._runner_cmd(
|
||||
project,
|
||||
"mock",
|
||||
"--auto-adopt",
|
||||
"C:\\Program Files\\Python\\python.exe",
|
||||
)
|
||||
helper = os.path.join(project, ".skillopt-sleep", "run.ps1")
|
||||
with open(helper, encoding="utf-8") as handle:
|
||||
content = handle.read()
|
||||
self.assertIn("Set-Location -LiteralPath", content)
|
||||
self.assertIn("''", content)
|
||||
self.assertIn("-File", command)
|
||||
self.assertNotIn("run.cmd", command)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -195,6 +195,42 @@ class TestSkillSearchRoots(unittest.TestCase):
|
||||
for blank in ["", " ", None]:
|
||||
self.assertEqual(skill_search_roots(_Cfg(blank)), [], repr(blank))
|
||||
|
||||
def test_project_native_agent_roots_and_explicit_roots_are_discovered(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
expected = []
|
||||
for relative in (
|
||||
os.path.join(".agents", "skills"),
|
||||
os.path.join(".claude", "skills"),
|
||||
os.path.join(".cursor", "skills"),
|
||||
os.path.join(".devin", "skills"),
|
||||
"custom-skills",
|
||||
):
|
||||
root = os.path.join(tmp, relative)
|
||||
os.makedirs(root)
|
||||
expected.append(os.path.realpath(root))
|
||||
cfg = load_config(
|
||||
invoked_project=tmp,
|
||||
claude_home="",
|
||||
codex_home="",
|
||||
cursor_home="",
|
||||
skill_roots=["custom-skills"],
|
||||
)
|
||||
self.assertEqual(skill_search_roots(cfg), expected)
|
||||
|
||||
def test_native_roots_make_duplicate_agent_definitions_ambiguous(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
for relative in (".agents/skills", ".devin/skills"):
|
||||
_write_skill(os.path.join(tmp, relative), "shared-skill")
|
||||
cfg = load_config(
|
||||
invoked_project=tmp,
|
||||
claude_home="",
|
||||
codex_home="",
|
||||
cursor_home="",
|
||||
)
|
||||
result = resolve_skill("shared-skill", skill_search_roots(cfg))
|
||||
self.assertEqual(result.status, AMBIGUOUS)
|
||||
self.assertEqual(len(result.candidates), 2)
|
||||
|
||||
def test_unreadable_plugin_cache_does_not_break_discovery(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
claude_home = os.path.join(tmp, ".claude")
|
||||
|
||||
@@ -5,14 +5,19 @@ Run: python -m pytest tests/test_sleep_staging_fanout.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from unittest import mock
|
||||
|
||||
from skillopt_sleep.staging import (
|
||||
SkillProposal,
|
||||
StagingError,
|
||||
latest_staging,
|
||||
new_staging_dir,
|
||||
proposal_filename,
|
||||
skill_proposal_rows,
|
||||
write_skill_proposals,
|
||||
@@ -40,6 +45,10 @@ class TestSkillProposalRows(unittest.TestCase):
|
||||
["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"))
|
||||
@@ -83,6 +92,16 @@ class TestSkillProposalRows(unittest.TestCase):
|
||||
skill_proposal_rows([_proposal("alpha", live="/tmp/live/A.md"),
|
||||
_proposal("beta", live="/tmp/live/a.md")])
|
||||
|
||||
def test_unicode_equivalent_staged_names_are_refused(self):
|
||||
# HFS+/APFS commonly normalise filenames. NFC ``café`` and the
|
||||
# decomposed NFD spelling must not produce two manifest rows for one
|
||||
# physical staged file.
|
||||
with self.assertRaises(StagingError):
|
||||
skill_proposal_rows([
|
||||
_proposal("café", live="/tmp/live/cafe-a/SKILL.md"),
|
||||
_proposal("cafe\u0301", live="/tmp/live/cafe-b/SKILL.md"),
|
||||
])
|
||||
|
||||
def test_a_generator_of_proposals_still_writes_every_file(self):
|
||||
# The annotation says Sequence but nothing enforces it. Validation used
|
||||
# to drain a generator, leaving the write loop empty and returning a
|
||||
@@ -130,7 +149,8 @@ class TestSkillProposalRows(unittest.TestCase):
|
||||
|
||||
def test_unsafe_live_paths_are_refused(self):
|
||||
for bad in ["", "relative/SKILL.md", "~/skills/a/SKILL.md",
|
||||
"/tmp/live/../../etc/SKILL.md", "/tmp/live/a/SKILL.txt"]:
|
||||
"/tmp/live/../../etc/SKILL.md", "/tmp/live/a/SKILL.txt",
|
||||
"/tmp/live/a/skill\x00/SKILL.md", "/tmp/live/a\n/SKILL.md"]:
|
||||
with self.assertRaises(StagingError, msg=bad):
|
||||
skill_proposal_rows([_proposal("alpha", live=bad)])
|
||||
|
||||
@@ -147,6 +167,12 @@ class TestWriteSkillProposals(unittest.TestCase):
|
||||
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, []), [])
|
||||
@@ -203,7 +229,52 @@ class TestWriteStagingCompatibility(unittest.TestCase):
|
||||
)
|
||||
manifest = self._manifest(out)
|
||||
self.assertNotIn("skills", manifest)
|
||||
self.assertTrue(manifest["has_skill"])
|
||||
self.assertEqual(manifest["schema"], "skillopt-sleep-staging")
|
||||
self.assertEqual(manifest["schema_version"], 2)
|
||||
self.assertFalse(manifest["has_skill"])
|
||||
self.assertFalse(manifest["has_memory"])
|
||||
self.assertTrue(manifest["has_managed_skill"])
|
||||
self.assertTrue(manifest["has_managed_memory"])
|
||||
|
||||
def test_v020_compatibility_flags_select_no_unpinned_files(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
out = write_staging(
|
||||
tmp, report=_report(), proposed_skill="# skill\n",
|
||||
proposed_memory="# memory\n",
|
||||
live_skill_path=os.path.join(tmp, "live", "SKILL.md"),
|
||||
live_memory_path=os.path.join(tmp, "live", "CLAUDE.md"),
|
||||
report_md="# report\n",
|
||||
)
|
||||
manifest = self._manifest(out)
|
||||
# This is the complete v0.2.0 mutation decision: it trusted only
|
||||
# these top-level booleans, without consulting integrity pins.
|
||||
selected = []
|
||||
if manifest.get("has_skill"):
|
||||
selected.append("proposed_SKILL.md")
|
||||
if manifest.get("has_memory"):
|
||||
selected.append("proposed_CLAUDE.md")
|
||||
self.assertEqual(selected, [])
|
||||
self.assertIn("skill", manifest["legacy"])
|
||||
self.assertIn("memory", manifest["legacy"])
|
||||
|
||||
def test_unknown_manifest_schema_version_is_refused(self):
|
||||
from skillopt_sleep.staging import staged_skills
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
out = write_staging(
|
||||
tmp, report=_report(), proposed_skill=None, 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",
|
||||
skill_proposals=[_proposal("alpha", root=os.path.join(tmp, "live"))],
|
||||
)
|
||||
path = os.path.join(out, "manifest.json")
|
||||
manifest = self._manifest(out)
|
||||
manifest["schema_version"] = 999
|
||||
with open(path, "w", encoding="utf-8") as handle:
|
||||
json.dump(manifest, handle)
|
||||
with self.assertRaisesRegex(StagingError, "unsupported schema"):
|
||||
staged_skills(out)
|
||||
|
||||
def test_fan_out_adds_files_and_manifest_rows(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
@@ -227,6 +298,311 @@ class TestWriteStagingCompatibility(unittest.TestCase):
|
||||
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(),
|
||||
)
|
||||
self.assertEqual(rows[0]["live_sha256"], "")
|
||||
self.assertEqual(
|
||||
rows[0]["live_realpath"],
|
||||
os.path.realpath(os.path.join(live_root, "alpha", "SKILL.md")),
|
||||
)
|
||||
|
||||
def test_two_same_second_nights_get_distinct_reserved_directories(self):
|
||||
with tempfile.TemporaryDirectory() as tmp, mock.patch(
|
||||
"skillopt_sleep.staging._ts_dir", return_value="20260815-010203"
|
||||
):
|
||||
first = write_staging(
|
||||
tmp, report=_report(), proposed_skill="# first\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="# first report\n",
|
||||
)
|
||||
second = write_staging(
|
||||
tmp, report=_report(), proposed_skill="# second\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="# second report\n",
|
||||
)
|
||||
self.assertNotEqual(first, second)
|
||||
self.assertEqual(
|
||||
sorted((os.path.basename(first), os.path.basename(second))),
|
||||
["20260815-010203", "20260815-010203-2"],
|
||||
)
|
||||
with open(os.path.join(first, "report.md"), encoding="utf-8") as f:
|
||||
self.assertEqual(f.read(), "# first report\n")
|
||||
with open(os.path.join(second, "report.md"), encoding="utf-8") as f:
|
||||
self.assertEqual(f.read(), "# second report\n")
|
||||
|
||||
def test_concurrent_staging_reservations_are_unique_and_exist(self):
|
||||
with tempfile.TemporaryDirectory() as tmp, mock.patch(
|
||||
"skillopt_sleep.staging._ts_dir", return_value="20260815-010203"
|
||||
):
|
||||
with ThreadPoolExecutor(max_workers=8) as pool:
|
||||
paths = list(pool.map(lambda _i: new_staging_dir(tmp), range(16)))
|
||||
self.assertEqual(len(set(paths)), len(paths))
|
||||
self.assertTrue(all(os.path.isdir(path) for path in paths))
|
||||
|
||||
def test_concurrent_publications_leave_one_valid_atomic_latest_pointer(self):
|
||||
with tempfile.TemporaryDirectory() as tmp, mock.patch(
|
||||
"skillopt_sleep.staging._ts_dir", return_value="20260815-010203"
|
||||
):
|
||||
def publish(index):
|
||||
return write_staging(
|
||||
tmp, report=_report(), proposed_skill=f"# skill {index}\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=f"# report {index}\n",
|
||||
)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=8) as pool:
|
||||
paths = list(pool.map(publish, range(16)))
|
||||
latest = latest_staging(tmp)
|
||||
self.assertIn(latest, paths)
|
||||
with open(
|
||||
os.path.join(tmp, ".skillopt-sleep", "staging", ".latest"),
|
||||
encoding="utf-8",
|
||||
) as handle:
|
||||
self.assertEqual(handle.read().strip(), os.path.basename(latest))
|
||||
|
||||
def test_latest_ignores_a_symlinked_night(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
real_night = write_staging(
|
||||
tmp, report=_report(), proposed_skill="# real\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="# real report\n",
|
||||
)
|
||||
outside = os.path.join(tmp, "outside-night")
|
||||
os.makedirs(outside)
|
||||
with open(
|
||||
os.path.join(outside, "manifest.json"), "w", encoding="utf-8"
|
||||
) as handle:
|
||||
handle.write("{}")
|
||||
alias = os.path.join(
|
||||
os.path.dirname(real_night), "99991231-235959"
|
||||
)
|
||||
try:
|
||||
os.symlink(outside, alias)
|
||||
except OSError:
|
||||
self.skipTest("symlinks unavailable")
|
||||
|
||||
self.assertEqual(latest_staging(tmp), real_night)
|
||||
|
||||
def test_explicit_symlink_staging_directory_is_refused(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
outside = os.path.join(tmp, "outside-night")
|
||||
os.makedirs(outside)
|
||||
sentinel = os.path.join(outside, "keep.txt")
|
||||
with open(sentinel, "w", encoding="utf-8") as handle:
|
||||
handle.write("untouched\n")
|
||||
alias = os.path.join(tmp, "staging-alias")
|
||||
try:
|
||||
os.symlink(outside, alias)
|
||||
except OSError:
|
||||
self.skipTest("symlinks unavailable")
|
||||
|
||||
with self.assertRaisesRegex(StagingError, "staging directory is unsafe"):
|
||||
write_staging(
|
||||
tmp, report=_report(), proposed_skill="# proposal\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",
|
||||
out_dir=alias,
|
||||
)
|
||||
with open(sentinel, encoding="utf-8") as handle:
|
||||
self.assertEqual(handle.read(), "untouched\n")
|
||||
self.assertEqual(sorted(os.listdir(outside)), ["keep.txt"])
|
||||
|
||||
def test_publication_pointer_orders_equal_mtimes_across_clock_rollback(self):
|
||||
with tempfile.TemporaryDirectory() as tmp, mock.patch(
|
||||
"skillopt_sleep.staging._ts_dir",
|
||||
side_effect=["20261101-015959", "20261101-010001"],
|
||||
):
|
||||
before_rollback = write_staging(
|
||||
tmp, report=_report(), proposed_skill="# before\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="# before rollback\n",
|
||||
)
|
||||
after_rollback = write_staging(
|
||||
tmp, report=_report(), proposed_skill="# after\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="# after rollback\n",
|
||||
)
|
||||
os.utime(
|
||||
os.path.join(before_rollback, "manifest.json"),
|
||||
ns=(1_000_000_000, 1_000_000_000),
|
||||
)
|
||||
os.utime(
|
||||
os.path.join(after_rollback, "manifest.json"),
|
||||
ns=(1_000_000_000, 1_000_000_000),
|
||||
)
|
||||
|
||||
self.assertEqual(latest_staging(tmp), after_rollback)
|
||||
|
||||
def test_tampered_symlink_pointer_is_ignored_for_contained_fallback(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
first = write_staging(
|
||||
tmp, report=_report(), proposed_skill="# first\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="# first\n",
|
||||
)
|
||||
pointer = os.path.join(
|
||||
tmp, ".skillopt-sleep", "staging", ".latest"
|
||||
)
|
||||
outside = os.path.join(tmp, "outside-pointer")
|
||||
with open(outside, "w", encoding="utf-8") as handle:
|
||||
handle.write("../../outside-night\n")
|
||||
os.unlink(pointer)
|
||||
try:
|
||||
os.symlink(outside, pointer)
|
||||
except OSError:
|
||||
self.skipTest("symlinks unavailable")
|
||||
self.assertEqual(latest_staging(tmp), first)
|
||||
|
||||
def test_failed_artifact_write_publishes_no_partial_night(self):
|
||||
from skillopt_sleep import staging as staging_mod
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
real_write = staging_mod._write_atomic
|
||||
|
||||
def fail_second_proposal(path, text, *, create_parents=True):
|
||||
if path.endswith("proposed_SKILL.beta.md"):
|
||||
raise OSError("disk full")
|
||||
return real_write(path, text, create_parents=create_parents)
|
||||
|
||||
with mock.patch.object(
|
||||
staging_mod, "_write_atomic", side_effect=fail_second_proposal
|
||||
), self.assertRaises(OSError):
|
||||
write_staging(
|
||||
tmp, report=_report(), proposed_skill=None,
|
||||
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",
|
||||
skill_proposals=[
|
||||
_proposal("alpha", "# alpha\n", root=os.path.join(tmp, "live")),
|
||||
_proposal("beta", "# beta\n", root=os.path.join(tmp, "live")),
|
||||
],
|
||||
)
|
||||
|
||||
self.assertIsNone(latest_staging(tmp))
|
||||
for root, _dirs, files in os.walk(tmp):
|
||||
self.assertNotIn("manifest.json", files, root)
|
||||
self.assertFalse(
|
||||
any(name.startswith("proposed_SKILL") for name in files),
|
||||
root,
|
||||
)
|
||||
|
||||
def test_artifact_rollback_attempts_every_restore_and_preserves_primary(self):
|
||||
from skillopt_sleep import staging as staging_mod
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
first = os.path.join(tmp, "first.md")
|
||||
second = os.path.join(tmp, "second.md")
|
||||
for path, body in ((first, "old first"), (second, "old second")):
|
||||
with open(path, "w", encoding="utf-8") as handle:
|
||||
handle.write(body)
|
||||
restore_attempts = []
|
||||
real_restore = staging_mod._write_atomic_bytes
|
||||
|
||||
def fail_publish(path, text, *, create_parents=True):
|
||||
if path == second:
|
||||
raise OSError("primary publish failure")
|
||||
|
||||
def restore(path, data, *, create_parents=True, mode=None):
|
||||
restore_attempts.append(path)
|
||||
if path == first:
|
||||
raise OSError("first restore failed")
|
||||
return real_restore(
|
||||
path,
|
||||
data,
|
||||
create_parents=create_parents,
|
||||
mode=mode,
|
||||
)
|
||||
|
||||
with mock.patch.object(
|
||||
staging_mod, "_write_atomic", side_effect=fail_publish
|
||||
), mock.patch.object(
|
||||
staging_mod, "_write_atomic_bytes", side_effect=restore
|
||||
):
|
||||
with self.assertRaises(staging_mod.StagingRecoveryError) as caught:
|
||||
staging_mod._write_artifact_batch([
|
||||
(first, "new first"),
|
||||
(second, "new second"),
|
||||
])
|
||||
self.assertIsInstance(caught.exception.primary, OSError)
|
||||
self.assertIn("primary publish failure", str(caught.exception.primary))
|
||||
self.assertEqual(restore_attempts, [second, first])
|
||||
|
||||
def test_post_commit_artifact_error_rolls_back_the_whole_night(self):
|
||||
from skillopt_sleep import staging as staging_mod
|
||||
|
||||
with tempfile.TemporaryDirectory() as 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.endswith("proposed_SKILL.beta.md"):
|
||||
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"):
|
||||
write_staging(
|
||||
tmp, report=_report(), proposed_skill=None,
|
||||
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",
|
||||
skill_proposals=[
|
||||
_proposal("alpha", "# alpha\n", root=os.path.join(tmp, "live")),
|
||||
_proposal("beta", "# beta\n", root=os.path.join(tmp, "live")),
|
||||
],
|
||||
)
|
||||
|
||||
self.assertIsNone(latest_staging(tmp))
|
||||
for root, _dirs, files in os.walk(tmp):
|
||||
self.assertFalse(files, f"partial staging artifacts remain in {root}")
|
||||
|
||||
def test_exact_cycle_baseline_change_before_staging_is_refused(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
live = os.path.join(tmp, "live", "alpha", "SKILL.md")
|
||||
os.makedirs(os.path.dirname(live), exist_ok=True)
|
||||
with open(live, "w", encoding="utf-8") as handle:
|
||||
handle.write("# alpha baseline v1\n")
|
||||
proposal = SkillProposal(
|
||||
"alpha",
|
||||
"# alpha proposal derived from v1\n",
|
||||
live,
|
||||
live_sha256=hashlib.sha256(b"# alpha baseline v1\n").hexdigest(),
|
||||
live_realpath=os.path.realpath(live),
|
||||
)
|
||||
with open(live, "w", encoding="utf-8") as handle:
|
||||
handle.write("# alpha human v2\n")
|
||||
|
||||
with self.assertRaisesRegex(StagingError, "changed during consolidation"):
|
||||
write_staging(
|
||||
tmp, report=_report(), 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=[proposal],
|
||||
)
|
||||
self.assertIsNone(latest_staging(tmp))
|
||||
|
||||
def test_unsafe_fan_out_writes_no_manifest(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Isolation regressions for SkillOpt-Sleep's in-process default state."""
|
||||
|
||||
from skillopt_sleep.state import SleepState
|
||||
|
||||
|
||||
def test_fresh_states_do_not_share_nested_default_containers(tmp_path):
|
||||
first = SleepState.load(str(tmp_path / "first.json"))
|
||||
second = SleepState.load(str(tmp_path / "second.json"))
|
||||
|
||||
first.set_last_harvest("/project/one", "2026-08-15T00:00:00")
|
||||
first.record_night({"night": 1})
|
||||
first.add_to_archive([{"id": "private-to-first"}])
|
||||
|
||||
assert second.last_harvest_for("/project/one") is None
|
||||
assert second.data["history"] == []
|
||||
assert second.task_archive() == []
|
||||
Reference in New Issue
Block a user