@cairnkeep/cli 2.7.0 → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -1
- package/README.md +23 -0
- package/bin/cairn +6 -0
- package/docs/design/meta-agent.md +39 -35
- package/docs/learning/CURRICULUM-MAP.md +9 -4
- package/docs/learning/FEATURE-GUIDE.md +6 -4
- package/docs/learning/README.md +4 -2
- package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
- package/docs/learning/lessons/L01-safe-trial.md +3 -3
- package/docs/learning/lessons/L02-installation.md +2 -2
- package/docs/learning/lessons/L03-first-project.md +1 -1
- package/docs/learning/lessons/L17-agent-boundary.md +12 -8
- package/docs/learning/lessons/L18-local-code-graph.md +97 -0
- package/docs/learning/lessons/L19-validated-skill-improvement.md +87 -0
- package/docs/learning/tracks/evidence-and-evaluation.md +5 -3
- package/docs/learning/tracks/practitioner.md +5 -4
- package/docs/operating.md +23 -0
- package/docs/privacy-and-data-flow.md +42 -1
- package/docs/skill-improvement.md +124 -0
- package/docs/storage.md +25 -0
- package/examples/eval/bundled-fake.json +1 -1
- package/mcp-memory-server/dist/eval-plan.js +22 -5
- package/mcp-memory-server/dist/eval-process.js +31 -0
- package/mcp-memory-server/dist/eval-runner.js +10 -4
- package/mcp-memory-server/dist/eval-schema.js +1 -1
- package/mcp-memory-server/dist/eval-workspace.js +50 -1
- package/mcp-memory-server/dist/skill-cli.js +236 -0
- package/mcp-memory-server/dist/skill-evaluation.js +244 -0
- package/mcp-memory-server/dist/skill-schema.js +164 -0
- package/mcp-memory-server/dist/skill-store.js +583 -0
- package/mcp-memory-server/package.json +2 -1
- package/package.json +1 -1
- package/schemas/eval-report.schema.json +1 -1
- package/schemas/skill-adapter.schema.json +39 -0
- package/schemas/skill-proposal-protocol.schema.json +97 -0
- package/scripts/completion.sh +41 -3
- package/scripts/test-completion.sh +11 -0
- package/scripts/test-learning-docs.sh +11 -2
- package/scripts/test-package-install.sh +18 -0
- package/scripts/test-skill-cli.sh +30 -0
- package/templates/graph-policy.md.template +3 -1
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# L19 - Validated skill improvement
|
|
2
|
+
|
|
3
|
+
**Status:** Brief
|
|
4
|
+
**Track:** Evidence and Evaluation
|
|
5
|
+
**Planned time:** 50 minutes
|
|
6
|
+
**Introduced in:** Cairnkeep 2.8.0
|
|
7
|
+
|
|
8
|
+
## Outcome
|
|
9
|
+
|
|
10
|
+
Turn repeated failure and demonstrated resolution evidence into one reviewed
|
|
11
|
+
skill proposal, measure it on separate exploration and confirmation tasks,
|
|
12
|
+
apply only the exact eligible digest, and prove rollback without risking a real
|
|
13
|
+
project.
|
|
14
|
+
|
|
15
|
+
## Planned lesson
|
|
16
|
+
|
|
17
|
+
- Why harvest requires failures from at least two sessions and at least one
|
|
18
|
+
recorded resolution.
|
|
19
|
+
- How `harvest`, `show`, and `review --approve` keep evidence local until the
|
|
20
|
+
learner consents to proposal-adapter egress.
|
|
21
|
+
- How the strict proposal adapter receives a bounded request, a private process
|
|
22
|
+
environment, and an edit budget for one existing `SKILL.md`.
|
|
23
|
+
- Why proposal generation and evaluation never modify the live target.
|
|
24
|
+
- How separate committed sets with at least two disjoint task IDs and
|
|
25
|
+
definitions each, one immutable source revision, fresh Git worktrees, exact
|
|
26
|
+
adapter digests, and an independent verifier protect the gate.
|
|
27
|
+
- Why improvements count distinct tasks, any regressed task rejects the
|
|
28
|
+
candidate, and small passing fixtures are not universal quality proof.
|
|
29
|
+
- How full proposal-digest confirmation, backup-first atomic replacement, and
|
|
30
|
+
concurrent-edit-safe rollback limit application authority.
|
|
31
|
+
|
|
32
|
+
## Hands-on lab
|
|
33
|
+
|
|
34
|
+
Use a future disposable `course-09-skill` checkpoint containing synthetic
|
|
35
|
+
hindsight evidence, a small `SKILL.md`, deterministic proposal and evaluation
|
|
36
|
+
adapters, and separate committed task sets. The lab will run:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
cairn skill harvest --project . --json
|
|
40
|
+
cairn skill show --kind candidate --id CANDIDATE_ID
|
|
41
|
+
cairn skill review --candidate CANDIDATE_ID --approve --json
|
|
42
|
+
cairn skill propose --candidate CANDIDATE_ID --target skills/demo/SKILL.md \
|
|
43
|
+
--adapter fixtures/proposal-adapter.json --json
|
|
44
|
+
|
|
45
|
+
export CAIRN_EVAL=1
|
|
46
|
+
cairn skill evaluate --proposal PROPOSAL_ID \
|
|
47
|
+
--exploration-task-set eval/exploration.json \
|
|
48
|
+
--confirmation-task-set eval/confirmation.json \
|
|
49
|
+
--adapter eval/adapter.json --repetitions 2 --yes --json
|
|
50
|
+
|
|
51
|
+
cairn skill apply --proposal PROPOSAL_ID --evaluation EVALUATION_ID \
|
|
52
|
+
--confirm FULL_PROPOSAL_DIGEST --json
|
|
53
|
+
cairn skill rollback --application APPLICATION_ID --confirm --json
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The lesson remains a Brief until that checkpoint is public and every command is
|
|
57
|
+
rehearsed against the release package.
|
|
58
|
+
|
|
59
|
+
## Acceptance criteria
|
|
60
|
+
|
|
61
|
+
- No candidate exists for repeated failure without resolution evidence.
|
|
62
|
+
- Proposal execution cannot inherit unapproved credentials or isolation-owned
|
|
63
|
+
environment variables.
|
|
64
|
+
- The live `SKILL.md` is byte-identical before and after propose/evaluate.
|
|
65
|
+
- Overlapping task IDs, changed adapters, unknown verifier results, and any
|
|
66
|
+
regression fail closed.
|
|
67
|
+
- Apply rejects a wrong digest or changed target.
|
|
68
|
+
- Rollback restores the original bytes and refuses to overwrite a later edit.
|
|
69
|
+
- The learner can explain why an eligible small task set supports one local
|
|
70
|
+
decision but not a universal performance claim.
|
|
71
|
+
|
|
72
|
+
## Privacy and trust boundary
|
|
73
|
+
|
|
74
|
+
Hindsight excerpts and skill content remain local until candidate approval.
|
|
75
|
+
Approval permits those exact fields to enter the explicitly configured proposal
|
|
76
|
+
adapter. Evaluation adapters receive isolated worktrees with baseline or
|
|
77
|
+
candidate skill bytes, not the hindsight evidence. Candidate, proposal,
|
|
78
|
+
evaluation, application, backup, and report artifacts remain sensitive local
|
|
79
|
+
project state under `.agentfs/`.
|
|
80
|
+
|
|
81
|
+
## Source material
|
|
82
|
+
|
|
83
|
+
- [Validated skill improvement](../../skill-improvement.md)
|
|
84
|
+
- [Evaluation and ablation](L16-evaluation.md)
|
|
85
|
+
- [The agent boundary](L17-agent-boundary.md)
|
|
86
|
+
- [Storage](../../storage.md#validated-skill-storage)
|
|
87
|
+
- [Privacy and data flow](../../privacy-and-data-flow.md#validated-skill-improvement-flow)
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**Audience:** maintainers and advanced practitioners evaluating optional layers
|
|
4
4
|
**Prerequisite:** Practitioner track
|
|
5
|
-
**Total time:** about
|
|
5
|
+
**Total time:** about 4 hours
|
|
6
6
|
**Infrastructure:** local fixtures first; a live harness is operator-provided
|
|
7
7
|
|
|
8
8
|
1. [L13 - Session evidence and hindsight](../lessons/L13-session-evidence.md)
|
|
@@ -10,11 +10,13 @@
|
|
|
10
10
|
3. [L15 - Capability governance](../lessons/L15-capability-governance.md)
|
|
11
11
|
4. [L16 - Evaluation and ablation](../lessons/L16-evaluation.md)
|
|
12
12
|
5. [L17 - The agent boundary](../lessons/L17-agent-boundary.md)
|
|
13
|
+
6. [L19 - Validated skill improvement](../lessons/L19-validated-skill-improvement.md)
|
|
13
14
|
|
|
14
15
|
The track is complete when the learner can explain each consent boundary,
|
|
15
16
|
inspect and manage every local evidence store, compare baseline and ablation
|
|
16
|
-
reports without making unsupported claims,
|
|
17
|
-
|
|
17
|
+
reports without making unsupported claims, apply or roll back an exact eligible
|
|
18
|
+
skill proposal, and distinguish that narrow runtime from the design-only
|
|
19
|
+
general meta-agent contract.
|
|
18
20
|
|
|
19
21
|
Hands-on spine: `course-05-evidence` through `course-07-evaluation` in the
|
|
20
22
|
public [course labs](https://github.com/cairnkeep/cairnkeep-course-labs).
|
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
|
|
7
7
|
1. [L04 - Memory fundamentals](../lessons/L04-memory-fundamentals.md)
|
|
8
8
|
2. [L05 - The daily workflow](../lessons/L05-daily-workflow.md)
|
|
9
|
-
3. [
|
|
10
|
-
4. [
|
|
11
|
-
5. [
|
|
12
|
-
6. [
|
|
9
|
+
3. [L18 - Local code graph](../lessons/L18-local-code-graph.md)
|
|
10
|
+
4. [L06 - Repository review and security](../lessons/L06-repository-quality.md)
|
|
11
|
+
5. [L07 - Privacy and storage](../lessons/L07-privacy-and-storage.md)
|
|
12
|
+
6. [L08 - Multiple machines](../lessons/L08-multiple-machines.md)
|
|
13
|
+
7. [L13 - Session evidence and hindsight](../lessons/L13-session-evidence.md)
|
|
13
14
|
|
|
14
15
|
The track is complete when the learner can decide what should become memory,
|
|
15
16
|
review derived knowledge, run governed repository checks, locate the store, and
|
package/docs/operating.md
CHANGED
|
@@ -218,6 +218,13 @@ do not perform Graphify's optional semantic document extraction. Prefer exact
|
|
|
218
218
|
function, class, or file names; broad natural-language graph queries are less
|
|
219
219
|
precise. Cairnkeep does not install or update Graphify automatically.
|
|
220
220
|
|
|
221
|
+
Graphify also maintains an incremental `graphify-out/` work directory at the
|
|
222
|
+
repository root. It is separate from Cairnkeep's published
|
|
223
|
+
`.planning/graphs/` view and can contain the same sensitive structural data.
|
|
224
|
+
Keep both locations local. Before the first build in a Git repository, add
|
|
225
|
+
`/graphify-out/` to the clone-local exclude file or the repository's ignore
|
|
226
|
+
policy, and verify with `git status --short` after every build.
|
|
227
|
+
|
|
221
228
|
## Setup order (Kimi Code)
|
|
222
229
|
|
|
223
230
|
Kimi Code can use the Cairnkeep memory MCP, generated project launcher, and one
|
|
@@ -897,6 +904,22 @@ See [storage](storage.md#evaluation-report-and-note-snapshot-storage) for local
|
|
|
897
904
|
retention/removal and [privacy](privacy-and-data-flow.md#evaluation-adapter-and-report-flow)
|
|
898
905
|
for the exact request, observation, environment, and persistence boundaries.
|
|
899
906
|
|
|
907
|
+
### Validated skill improvement (opt-in)
|
|
908
|
+
|
|
909
|
+
After hindsight-note distillation has recorded the same failure family in at
|
|
910
|
+
least two sessions, `cairn skill harvest` creates a local candidate under
|
|
911
|
+
`.agentfs/skills/`. Review its exact evidence with `show`, approve it explicitly,
|
|
912
|
+
then use a private proposal adapter to generate a bounded edit list for one
|
|
913
|
+
existing skill file. Proposal and evaluation do not modify the live target.
|
|
914
|
+
|
|
915
|
+
Evaluation reuses the default-off evaluation coordinator and therefore requires
|
|
916
|
+
`CAIRN_EVAL=1`, `--yes`, an explicit harness adapter, and two disjoint committed
|
|
917
|
+
task sets. Only a complete, no-regression improvement in exploration opens the
|
|
918
|
+
confirmation set. Only the same result on confirmation makes the proposal
|
|
919
|
+
eligible for exact-digest application. See
|
|
920
|
+
[Validated skill improvement](skill-improvement.md) for the command sequence,
|
|
921
|
+
adapter contract, task constraints, and rollback procedure.
|
|
922
|
+
|
|
900
923
|
### Structured session trajectories (opt-in)
|
|
901
924
|
|
|
902
925
|
Trajectory capture is disabled by default. To enable it for a launched Claude
|
|
@@ -16,7 +16,7 @@ request unless the corresponding endpoint and credential are configured.
|
|
|
16
16
|
| `domain_knowledge_sync` | Files selected by the sync configuration | `ANYTHINGLLM_BASE_URL` |
|
|
17
17
|
| `route_check` | A health request, with no memory or prompt content | `CAIRN_ROUTE_ENDPOINT` |
|
|
18
18
|
| `context_explore` | Repository path and query are passed to the configured local executable | `CAIRN_EXPLORE_BINARY`; any further data flow is controlled by that tool |
|
|
19
|
-
| Optional Graphify workflow | The repository path, exact query/symbol names, and local
|
|
19
|
+
| Optional Graphify workflow | The repository path, exact query/symbol names, root `graphify-out/` work directory, and local `.planning/graphs/` publication are passed to or produced by the operator-installed `graphify` executable | Local Graphify subprocess with a minimal environment and no provider credentials; managed build uses code-only `update`, never semantic document extraction |
|
|
20
20
|
| Remote HTTP memory | MCP requests and responses, including memory content | The explicitly registered Cairnkeep HTTP server |
|
|
21
21
|
| Opt-in trajectory capture | None | Local `<project>/.agentfs/trajectory.db` only; no model or HTTP path exists |
|
|
22
22
|
| Opt-in capability callback records | None | Payload-free final metadata in local `<project>/.agentfs/trajectory.db`; HTTP transport is always skipped |
|
|
@@ -175,6 +175,47 @@ the full committed population and note-eligible subset with paired counts and
|
|
|
175
175
|
seeded uncertainty; they do not convert framework output into a causal,
|
|
176
176
|
significance, quality, or efficiency claim.
|
|
177
177
|
|
|
178
|
+
## Validated skill improvement flow
|
|
179
|
+
|
|
180
|
+
Harvest and review are local-only operations over project hindsight notes.
|
|
181
|
+
Before approval, no proposal subprocess is started and no evidence crosses a
|
|
182
|
+
process boundary. After explicit approval, the configured proposal adapter
|
|
183
|
+
receives exactly one bounded JSON request containing the approved candidate,
|
|
184
|
+
candidate digest, selected target path, current target content and digest, and
|
|
185
|
+
edit budget.
|
|
186
|
+
|
|
187
|
+
The proposal adapter receives an isolated private HOME and TMPDIR, a minimal
|
|
188
|
+
PATH and locale, and only environment variables named in its strict
|
|
189
|
+
`environment_allowlist`. It does not inherit the rest of the parent
|
|
190
|
+
environment. HOME, PATH, temporary and XDG roots, and Node runtime injection
|
|
191
|
+
variables are isolation-controlled and cannot be allowlisted. Its stdout must
|
|
192
|
+
be one strict bounded response containing status
|
|
193
|
+
and bounded edits; arbitrary stderr is live operator diagnostics and is not
|
|
194
|
+
stored. Cairnkeep records the adapter executable digest and rejects a change
|
|
195
|
+
during the invocation. The adapter may perform network access according to the
|
|
196
|
+
executable and host policy, but Cairnkeep provides no endpoint, credential, or
|
|
197
|
+
network default. Operators must treat approval as consent to send those exact
|
|
198
|
+
evidence and target fields to that adapter.
|
|
199
|
+
|
|
200
|
+
The reduced environment is not an operating-system sandbox. The explicit
|
|
201
|
+
adapter executable retains the invoking user's filesystem and network
|
|
202
|
+
authority, so operators must trust it or wrap it in a separately enforced host
|
|
203
|
+
sandbox.
|
|
204
|
+
|
|
205
|
+
Evaluation sends neither candidate evidence nor proposal rationales to the
|
|
206
|
+
harness adapter. It overlays baseline or candidate skill bytes into separate
|
|
207
|
+
fresh worktrees and otherwise uses the existing evaluation request contract.
|
|
208
|
+
Exploration and confirmation use disjoint task IDs and definitions. Confirmation
|
|
209
|
+
is not opened unless exploration passes, and its results cannot be supplied
|
|
210
|
+
back to proposal generation by this workflow. Both stages bind one evaluation
|
|
211
|
+
adapter executable digest and reject a change during either stage.
|
|
212
|
+
|
|
213
|
+
Apply and rollback invoke no model and make no network request. They operate on
|
|
214
|
+
one contained regular target and private local artifacts, require exact
|
|
215
|
+
digests, reject symlinks, and fail closed on concurrent target changes. See
|
|
216
|
+
[storage](storage.md#validated-skill-storage) for retained bytes and
|
|
217
|
+
[Validated skill improvement](skill-improvement.md) for the operator sequence.
|
|
218
|
+
|
|
178
219
|
## Compaction and artifact flows
|
|
179
220
|
|
|
180
221
|
Both `CAIRN_COMPACTION_CAPTURE` and `CAIRN_ARTIFACT_STORE` are default-off and
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# Validated skill improvement
|
|
2
|
+
|
|
3
|
+
`cairn skill` turns recurring project hindsight into a proposed change to one
|
|
4
|
+
existing skill file, then requires independent evidence before that change can
|
|
5
|
+
be applied. It is an operator-controlled lifecycle, not an autonomous rewrite
|
|
6
|
+
loop.
|
|
7
|
+
|
|
8
|
+
## Safety contract
|
|
9
|
+
|
|
10
|
+
- Harvest reads only project hindsight notes and requires failures from at
|
|
11
|
+
least two distinct sessions by default.
|
|
12
|
+
- Evidence remains local until `review --approve` records explicit approval.
|
|
13
|
+
- Proposal generation invokes only the configured executable, sends one strict
|
|
14
|
+
bounded JSON request, exposes only allowlisted environment variables, and
|
|
15
|
+
accepts a bounded edit list rather than an arbitrary rewritten tree.
|
|
16
|
+
- Proposal and evaluation artifacts bind the adapter executable digest;
|
|
17
|
+
evaluation rechecks it after exploration and confirmation.
|
|
18
|
+
- Proposal and evaluation use candidate content without modifying the live
|
|
19
|
+
target.
|
|
20
|
+
- Evaluation requires repository-root tasks in two different committed task
|
|
21
|
+
sets with at least two tasks each. Task IDs and exact task definitions cannot
|
|
22
|
+
overlap, and both sets must bind the same immutable source revision.
|
|
23
|
+
- Confirmation runs only after exploration passes. Confirmation results never
|
|
24
|
+
return to proposal generation.
|
|
25
|
+
- Apply requires an eligible exact evaluation, an unchanged target, and the
|
|
26
|
+
full proposal digest. It creates a private backup before atomic replacement.
|
|
27
|
+
- Rollback refuses to overwrite a target changed after application.
|
|
28
|
+
|
|
29
|
+
## Prepare a target and proposal adapter
|
|
30
|
+
|
|
31
|
+
The target is one existing project-relative regular file named `SKILL.md`, for example
|
|
32
|
+
`.claude/skills/generated-client-review/SKILL.md`. It cannot be a symlink and
|
|
33
|
+
is limited to 256 KiB.
|
|
34
|
+
|
|
35
|
+
The proposal adapter configuration is strict JSON:
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
{
|
|
39
|
+
"schema_version": 1,
|
|
40
|
+
"id": "local-skill-proposer",
|
|
41
|
+
"command": {
|
|
42
|
+
"program": "/absolute/path/to/proposal-adapter",
|
|
43
|
+
"args": []
|
|
44
|
+
},
|
|
45
|
+
"environment_allowlist": ["PROPOSAL_PROVIDER_TOKEN"],
|
|
46
|
+
"limits": {
|
|
47
|
+
"elapsed_ms": 120000,
|
|
48
|
+
"stdout_bytes": 1048576
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The executable receives the approved candidate, its digest, current target
|
|
54
|
+
content and digest, and edit budget on stdin. It returns `add`, `replace`, or
|
|
55
|
+
`delete` edits with exact anchors and rationales. The executable path must be
|
|
56
|
+
absolute, executable, and not a symlink. Cairnkeep does not provide a model,
|
|
57
|
+
endpoint, credential, or network default. The machine-readable contracts are
|
|
58
|
+
[`schemas/skill-adapter.schema.json`](../schemas/skill-adapter.schema.json) and
|
|
59
|
+
[`schemas/skill-proposal-protocol.schema.json`](../schemas/skill-proposal-protocol.schema.json).
|
|
60
|
+
Process-isolation variables such as `HOME`, `PATH`, temporary directories,
|
|
61
|
+
XDG roots, and Node runtime injection settings cannot be allowlisted.
|
|
62
|
+
This environment boundary is not an operating-system sandbox: the configured
|
|
63
|
+
program runs with the invoking user's filesystem and network authority. Use
|
|
64
|
+
only a trusted adapter or add a separate host sandbox.
|
|
65
|
+
|
|
66
|
+
## Run the lifecycle
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
# 1. Find recurring hindsight evidence. This performs no model call.
|
|
70
|
+
cairn skill harvest --project . --json
|
|
71
|
+
cairn skill show --kind candidate --id CANDIDATE_ID
|
|
72
|
+
|
|
73
|
+
# 2. Approve the exact evidence that may cross the adapter boundary.
|
|
74
|
+
cairn skill review --candidate CANDIDATE_ID --approve --json
|
|
75
|
+
|
|
76
|
+
# 3. Generate a bounded proposal. The live target remains unchanged.
|
|
77
|
+
cairn skill propose --candidate CANDIDATE_ID \
|
|
78
|
+
--target .claude/skills/generated-client-review/SKILL.md \
|
|
79
|
+
--adapter ./private/proposal-adapter.json --edit-budget 4 --json
|
|
80
|
+
|
|
81
|
+
# 4. Measure baseline versus candidate in fresh isolated worktrees.
|
|
82
|
+
export CAIRN_EVAL=1
|
|
83
|
+
cairn skill evaluate --proposal PROPOSAL_ID \
|
|
84
|
+
--exploration-task-set eval/skill-exploration.json \
|
|
85
|
+
--confirmation-task-set eval/skill-confirmation.json \
|
|
86
|
+
--adapter eval/harness-adapter.json \
|
|
87
|
+
--repetitions 2 --minimum-improvement 1 --yes --json
|
|
88
|
+
|
|
89
|
+
# 5. Inspect artifacts and apply only the exact eligible proposal.
|
|
90
|
+
cairn skill show --kind proposal --id PROPOSAL_ID
|
|
91
|
+
cairn skill show --kind evaluation --id EVALUATION_ID
|
|
92
|
+
cairn skill apply --proposal PROPOSAL_ID --evaluation EVALUATION_ID \
|
|
93
|
+
--confirm FULL_PROPOSAL_DIGEST --json
|
|
94
|
+
|
|
95
|
+
# 6. Revert if later evidence warrants it.
|
|
96
|
+
cairn skill rollback --application APPLICATION_ID --confirm --json
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`propose --json` includes `proposal_digest`, which is the value required by
|
|
100
|
+
`apply --confirm`. `list` shows every artifact class; use `--kind candidate`,
|
|
101
|
+
`proposal`, `evaluation`, or `application` to narrow it.
|
|
102
|
+
|
|
103
|
+
## Evaluation semantics
|
|
104
|
+
|
|
105
|
+
Each task runs twice in each arm. The baseline arm overlays the exact unchanged
|
|
106
|
+
target content; the treatment arm overlays the exact candidate content. Every
|
|
107
|
+
observation must complete, return the expected capability digest, and receive a
|
|
108
|
+
known result from the independent verifier. A task with any candidate-only pass
|
|
109
|
+
counts once as improved; a task with any baseline-only pass counts once as
|
|
110
|
+
regressed and rejects the candidate.
|
|
111
|
+
|
|
112
|
+
Exploration must have no unknown pairs, no regression, and at least
|
|
113
|
+
`--minimum-improvement` improvements. Otherwise evaluation stops without
|
|
114
|
+
opening the confirmation set. Confirmation must independently satisfy the same
|
|
115
|
+
gate. This is deliberately strict and suited to small, high-value task sets;
|
|
116
|
+
it is not a statistical proof that a skill is universally better.
|
|
117
|
+
|
|
118
|
+
## What this does not do
|
|
119
|
+
|
|
120
|
+
It does not scrape native harness transcripts, schedule periodic self-edits,
|
|
121
|
+
invent task sets, approve evidence, choose an inference provider, or publish a
|
|
122
|
+
changed skill. These remain explicit operator responsibilities. Start with a
|
|
123
|
+
small target and representative tasks; expand only when the stored evaluation
|
|
124
|
+
artifacts make the decision auditable.
|
package/docs/storage.md
CHANGED
|
@@ -213,6 +213,31 @@ the process stores databases below `/data`, normally backed by the
|
|
|
213
213
|
removing the volume removes the databases. See [Containers](containers.md) for
|
|
214
214
|
the exact paths and backup boundary.
|
|
215
215
|
|
|
216
|
+
## Validated skill storage
|
|
217
|
+
|
|
218
|
+
The skill-improvement lifecycle stores private JSON artifacts below
|
|
219
|
+
`<project>/.agentfs/skills/`: `candidates/`, `proposals/`, `evaluations/`, and
|
|
220
|
+
`applications/`. Pre-application target backups live in `backups/`; isolated
|
|
221
|
+
proposal-adapter homes are created temporarily in `adapter-tmp/` and removed
|
|
222
|
+
after every invocation. Directories are mode `0700`, JSON and backup files are
|
|
223
|
+
mode `0600`, artifacts are bounded to 2 MiB, and candidate target content is
|
|
224
|
+
bounded to 256 KiB.
|
|
225
|
+
|
|
226
|
+
Candidate and proposal artifacts can contain hindsight excerpts and full skill
|
|
227
|
+
content, so the entire directory is sensitive. Evaluation reports and
|
|
228
|
+
experiment-owned snapshots remain in `.agentfs/eval/experiments/` under the
|
|
229
|
+
existing evaluation retention rules. No skill artifact is written to global or
|
|
230
|
+
remote memory, and remote MCP configuration does not redirect this project
|
|
231
|
+
state.
|
|
232
|
+
|
|
233
|
+
`apply` writes the original target to a unique private backup before atomically
|
|
234
|
+
replacing the target. If replacement, digest verification, or ledger storage
|
|
235
|
+
fails, Cairnkeep restores the original and removes the incomplete backup.
|
|
236
|
+
`rollback` restores only when both the current target and backup match the
|
|
237
|
+
application ledger, preventing an unrelated later edit from being overwritten.
|
|
238
|
+
Default uninstall retains `.agentfs/`; explicit purge uses the existing
|
|
239
|
+
backup-first project boundary.
|
|
240
|
+
|
|
216
241
|
## Remote HTTP mode
|
|
217
242
|
|
|
218
243
|
Remote storage is explicit. An operator starts Cairnkeep in HTTP mode on a
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"identifier":"cairn-offline-fake-v1","package_version":"2.
|
|
1
|
+
{"identifier":"cairn-offline-fake-v1","package_version":"2.8.0","schema_version":1,"task_set_digest":"13e95379f35839593047ec34521a65a8fa3ab92bd3993bca9dc8bf5c4a9ff36f"}
|
|
@@ -186,7 +186,7 @@ function validateBundledBinding(taskSetPath, taskSet, taskSetBytes) {
|
|
|
186
186
|
binding_path: realpathSync(bindingPath),
|
|
187
187
|
};
|
|
188
188
|
}
|
|
189
|
-
function validateArms(arms) {
|
|
189
|
+
function validateArms(arms, experimentKind) {
|
|
190
190
|
if (arms.length < 1 || arms.length > 2)
|
|
191
191
|
throw new Error("Evaluation requires one or two arms.");
|
|
192
192
|
const seen = new Set();
|
|
@@ -201,8 +201,12 @@ function validateArms(arms) {
|
|
|
201
201
|
}
|
|
202
202
|
if (arm.id === "baseline" && arm.disabled_capability !== null)
|
|
203
203
|
throw new Error("Baseline cannot disable a capability.");
|
|
204
|
-
if (arm.id === "treatment" && arm.disabled_capability === null)
|
|
205
|
-
throw new Error("
|
|
204
|
+
if (arm.id === "treatment" && experimentKind === "ablation" && arm.disabled_capability === null) {
|
|
205
|
+
throw new Error("Ablation treatment must disable exactly one capability.");
|
|
206
|
+
}
|
|
207
|
+
if (experimentKind === "skill_candidate" && arm.disabled_capability !== null) {
|
|
208
|
+
throw new Error("Skill-candidate arms cannot disable capabilities.");
|
|
209
|
+
}
|
|
206
210
|
return { id: arm.id, disabled_capability: arm.disabled_capability };
|
|
207
211
|
});
|
|
208
212
|
}
|
|
@@ -214,7 +218,10 @@ export function isEvalEnabled(value = process.env.CAIRN_EVAL) {
|
|
|
214
218
|
}
|
|
215
219
|
export function buildEvalSchedule(options) {
|
|
216
220
|
const taskSet = evalTaskSetSchema.parse(options.taskSet);
|
|
217
|
-
const
|
|
221
|
+
const inferredKind = options.arms.length === 1 ? "two_pass" : options.arms.some(({ disabled_capability }) => disabled_capability !== null)
|
|
222
|
+
? "ablation"
|
|
223
|
+
: "skill_candidate";
|
|
224
|
+
const arms = validateArms(options.arms, inferredKind);
|
|
218
225
|
if (!Number.isSafeInteger(options.repetitions) || options.repetitions < 1 || options.repetitions > MAX_REPETITIONS) {
|
|
219
226
|
throw new Error(`Evaluation repetitions must be an integer from 1 to ${MAX_REPETITIONS}.`);
|
|
220
227
|
}
|
|
@@ -282,7 +289,15 @@ export function validateEvalInputs(options) {
|
|
|
282
289
|
}
|
|
283
290
|
const repetitions = options.repetitions ?? 1;
|
|
284
291
|
const seed = options.seed ?? "cairn-eval-v1";
|
|
285
|
-
const
|
|
292
|
+
const suppliedArms = options.arms ?? [{ id: "baseline", disabled_capability: null }];
|
|
293
|
+
const experimentKind = options.experimentKind
|
|
294
|
+
?? (suppliedArms.length === 1 ? "two_pass" : "ablation");
|
|
295
|
+
if (experimentKind === "two_pass" && suppliedArms.length !== 1)
|
|
296
|
+
throw new Error("Two-pass evaluation requires one arm.");
|
|
297
|
+
if ((experimentKind === "ablation" || experimentKind === "skill_candidate") && suppliedArms.length !== 2) {
|
|
298
|
+
throw new Error(`${experimentKind} evaluation requires two arms.`);
|
|
299
|
+
}
|
|
300
|
+
const arms = validateArms(suppliedArms, experimentKind);
|
|
286
301
|
const passes = ["run1", "run2"];
|
|
287
302
|
const schedule = buildEvalSchedule({ taskSet, arms, repetitions, passes, seed });
|
|
288
303
|
const adapterBase = dirname(adapterInput.path);
|
|
@@ -304,6 +319,7 @@ export function validateEvalInputs(options) {
|
|
|
304
319
|
repetitions,
|
|
305
320
|
seed,
|
|
306
321
|
arms,
|
|
322
|
+
experiment_kind: experimentKind,
|
|
307
323
|
passes,
|
|
308
324
|
concurrency: 1,
|
|
309
325
|
task_set_commit: taskSetCommit,
|
|
@@ -320,6 +336,7 @@ export function validateEvalInputs(options) {
|
|
|
320
336
|
repetitions,
|
|
321
337
|
seed,
|
|
322
338
|
arms,
|
|
339
|
+
experiment_kind: experimentKind,
|
|
323
340
|
passes: ["run1", "run2"],
|
|
324
341
|
concurrency: 1,
|
|
325
342
|
invocation_count: schedule.invocation_count,
|
|
@@ -246,6 +246,37 @@ function hasTrailingJsonDocument(value) {
|
|
|
246
246
|
}
|
|
247
247
|
return false;
|
|
248
248
|
}
|
|
249
|
+
export async function runBoundedJsonProcess(options) {
|
|
250
|
+
const request = options.request_schema.safeParse(options.request);
|
|
251
|
+
if (!request.success)
|
|
252
|
+
throw new EvalProcessError("invalid_request");
|
|
253
|
+
const commandResult = await runCommandInternal({
|
|
254
|
+
command: options.command,
|
|
255
|
+
cwd: options.cwd,
|
|
256
|
+
env: options.env,
|
|
257
|
+
timeout_ms: options.timeout_ms,
|
|
258
|
+
max_stdout_bytes: options.max_stdout_bytes,
|
|
259
|
+
kill_grace_ms: options.kill_grace_ms,
|
|
260
|
+
signal: options.signal,
|
|
261
|
+
stdout_mode: "raw",
|
|
262
|
+
stdin_document: Buffer.from(JSON.stringify(request.data), "utf8"),
|
|
263
|
+
});
|
|
264
|
+
if (commandResult.exit_code !== 0 || commandResult.signal !== null || commandResult.cleanup !== "closed") {
|
|
265
|
+
throw new EvalProcessError("adapter_error", commandResult);
|
|
266
|
+
}
|
|
267
|
+
let parsed;
|
|
268
|
+
try {
|
|
269
|
+
parsed = JSON.parse(commandResult.stdout ?? "");
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
throw new EvalProcessError(hasTrailingJsonDocument(commandResult.stdout ?? "") ? "multiple_json" : "invalid_json", commandResult);
|
|
273
|
+
}
|
|
274
|
+
const result = options.result_schema.safeParse(parsed);
|
|
275
|
+
if (!result.success)
|
|
276
|
+
throw new EvalProcessError("invalid_result", commandResult);
|
|
277
|
+
const { stdout: _stdout, ...processResult } = commandResult;
|
|
278
|
+
return { ...processResult, result: result.data };
|
|
279
|
+
}
|
|
249
280
|
export async function runBoundedJsonAdapter(options) {
|
|
250
281
|
const request = evalAdapterRequestSchema.safeParse(options.request);
|
|
251
282
|
if (!request.success)
|
|
@@ -9,7 +9,7 @@ import { CAPABILITY_IDS, capabilityStatusSchema, } from "./capability-schema.js"
|
|
|
9
9
|
import { canonicalDigest, evalReportSchema, evalReportRuntimeContractDigest, } from "./eval-schema.js";
|
|
10
10
|
import { EvalProcessError, runBoundedCommand, runBoundedJsonAdapter, } from "./eval-process.js";
|
|
11
11
|
import { checkpointEvalReport, createEvalReportStore, } from "./eval-report.js";
|
|
12
|
-
import { cleanupEvalWorkspace, createEvalWorkspace, runTaskPreparation, runTaskVerifier, } from "./eval-workspace.js";
|
|
12
|
+
import { applyEvalWorkspaceOverlay, cleanupEvalWorkspace, createEvalWorkspace, runTaskPreparation, runTaskVerifier, } from "./eval-workspace.js";
|
|
13
13
|
const moduleDirectory = dirname(fileURLToPath(import.meta.url));
|
|
14
14
|
const publishedReportSchemaPath = resolve(moduleDirectory, "..", "..", "schemas", "eval-report.schema.json");
|
|
15
15
|
let reportSchemaDigestsCache;
|
|
@@ -557,6 +557,8 @@ export async function runEvalObservation(options) {
|
|
|
557
557
|
observation.missing_reasons.push("preparation_failed");
|
|
558
558
|
}
|
|
559
559
|
else {
|
|
560
|
+
if (options.workspace_overlay)
|
|
561
|
+
await applyEvalWorkspaceOverlay(workspace, options.workspace_overlay);
|
|
560
562
|
const request = {
|
|
561
563
|
schema_version: 1,
|
|
562
564
|
experiment_id: options.report.experiment_id,
|
|
@@ -698,7 +700,7 @@ export async function runEvalObservation(options) {
|
|
|
698
700
|
await checkpoint(options.report_store, options.report);
|
|
699
701
|
return { observation, snapshot: distilledSnapshot, cancelled: observation.terminal_state === "cancelled" };
|
|
700
702
|
}
|
|
701
|
-
function initialReport(plan, experimentId) {
|
|
703
|
+
function initialReport(plan, experimentId, fallbackExperimentKind) {
|
|
702
704
|
const now = new Date().toISOString();
|
|
703
705
|
const sourceCommit = plan.task_set_commit ?? (plan.source.kind === "git" ? plan.source.revision : plan.task_set_digest);
|
|
704
706
|
const missingness = { count: 0, reasons: [] };
|
|
@@ -706,7 +708,7 @@ function initialReport(plan, experimentId) {
|
|
|
706
708
|
schema_version: 1,
|
|
707
709
|
experiment_id: experimentId,
|
|
708
710
|
status: "partial",
|
|
709
|
-
experiment_kind: plan.
|
|
711
|
+
experiment_kind: plan.experiment_kind ?? fallbackExperimentKind,
|
|
710
712
|
task_set_digest: plan.task_set_digest,
|
|
711
713
|
adapter_config_digest: plan.adapter_config_digest,
|
|
712
714
|
source_revision: plan.source.kind === "git" ? plan.source.revision : plan.task_set_digest,
|
|
@@ -750,7 +752,7 @@ async function runExperiment(options, capabilityArms) {
|
|
|
750
752
|
});
|
|
751
753
|
if (reportStore.experiment_id !== experimentId)
|
|
752
754
|
throw new Error("report_experiment_mismatch");
|
|
753
|
-
const report = initialReport(options.plan, experimentId);
|
|
755
|
+
const report = initialReport(options.plan, experimentId, capabilityArms ? "ablation" : "two_pass");
|
|
754
756
|
const snapshotsByTask = new Map();
|
|
755
757
|
const notesByTask = new Map();
|
|
756
758
|
const snapshots = [];
|
|
@@ -782,6 +784,7 @@ async function runExperiment(options, capabilityArms) {
|
|
|
782
784
|
distill_command: options.distill_command,
|
|
783
785
|
distill_timeout_ms: options.distill_timeout_ms,
|
|
784
786
|
capability_runtime: capabilityRuntime,
|
|
787
|
+
workspace_overlay: options.workspace_overlays?.[row.arm],
|
|
785
788
|
});
|
|
786
789
|
executed.push(row.observation_id);
|
|
787
790
|
if (result.snapshot) {
|
|
@@ -813,6 +816,9 @@ export async function runTwoPassExperiment(options) {
|
|
|
813
816
|
return runExperiment(options);
|
|
814
817
|
}
|
|
815
818
|
export async function runCapabilityAblation(options) {
|
|
819
|
+
if (options.plan.experiment_kind !== undefined && options.plan.experiment_kind !== "ablation") {
|
|
820
|
+
throw new Error("ablation_experiment_kind_required");
|
|
821
|
+
}
|
|
816
822
|
const arms = buildAblationArms(options.disabled_capability);
|
|
817
823
|
const intended = arms.map(({ id, disabled_capability }) => ({ id, disabled_capability }));
|
|
818
824
|
if (canonicalDigest(options.plan.arms) !== canonicalDigest(intended))
|
|
@@ -13,7 +13,7 @@ export const EVAL_OBSERVATION_TERMINAL_STATES = [
|
|
|
13
13
|
"invalid_result",
|
|
14
14
|
];
|
|
15
15
|
export const EVAL_PASS_STATES = ["passed", "failed", "unknown"];
|
|
16
|
-
export const EVAL_EXPERIMENT_KINDS = ["two_pass", "ablation"];
|
|
16
|
+
export const EVAL_EXPERIMENT_KINDS = ["two_pass", "ablation", "skill_candidate"];
|
|
17
17
|
const MAX_TEXT_BYTES = 1024 * 1024;
|
|
18
18
|
const MAX_COMMAND_ARGS = 256;
|
|
19
19
|
const MAX_TASKS = 10_000;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { constants, existsSync } from "node:fs";
|
|
2
3
|
import { chmod, lstat, mkdir, mkdtemp, open, readFile, realpath, writeFile, } from "node:fs/promises";
|
|
3
4
|
import { tmpdir } from "node:os";
|
|
@@ -155,7 +156,7 @@ export async function createEvalWorkspace(options) {
|
|
|
155
156
|
};
|
|
156
157
|
try {
|
|
157
158
|
if (options.plan.source.kind === "git") {
|
|
158
|
-
const added = await gitCommand(options.plan.source.repository_root, ["worktree", "add", "--detach", workspace.source_path, options.plan.source.revision], "exit-only");
|
|
159
|
+
const added = await gitCommand(options.plan.source.repository_root, ["worktree", "add", "--quiet", "--detach", workspace.source_path, options.plan.source.revision], "exit-only");
|
|
159
160
|
requireSuccessfulCommand(added, "workspace_add_failed");
|
|
160
161
|
workspace.worktree_registered = true;
|
|
161
162
|
await verifyGitWorkspace(workspace);
|
|
@@ -192,6 +193,54 @@ export async function runTaskPreparation(workspace, options = {}) {
|
|
|
192
193
|
signal: options.signal,
|
|
193
194
|
});
|
|
194
195
|
}
|
|
196
|
+
export async function applyEvalWorkspaceOverlay(workspace, overlay) {
|
|
197
|
+
const segments = overlay.relative_path.split("/");
|
|
198
|
+
if (overlay.relative_path.startsWith("/") || overlay.relative_path.endsWith("/")
|
|
199
|
+
|| overlay.relative_path.includes("\\") || segments.some((segment) => segment === "" || segment === "." || segment === "..")
|
|
200
|
+
|| /[\u0000-\u001f\u007f]/.test(overlay.relative_path)) {
|
|
201
|
+
throw new Error("workspace_overlay_path_invalid");
|
|
202
|
+
}
|
|
203
|
+
const target = resolve(workspace.source_path, overlay.relative_path);
|
|
204
|
+
if (!isContained(workspace.source_path, target) || target === workspace.source_path) {
|
|
205
|
+
throw new Error("workspace_overlay_path_escape");
|
|
206
|
+
}
|
|
207
|
+
let cursor = workspace.source_path;
|
|
208
|
+
for (const segment of segments.slice(0, -1)) {
|
|
209
|
+
cursor = join(cursor, segment);
|
|
210
|
+
try {
|
|
211
|
+
const info = await lstat(cursor);
|
|
212
|
+
if (info.isSymbolicLink() || !info.isDirectory())
|
|
213
|
+
throw new Error("workspace_overlay_parent_unsafe");
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
if (error.code !== "ENOENT")
|
|
217
|
+
throw error;
|
|
218
|
+
await mkdir(cursor, { mode: 0o700 });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
const info = await lstat(target);
|
|
223
|
+
if (info.isSymbolicLink() || !info.isFile())
|
|
224
|
+
throw new Error("workspace_overlay_target_unsafe");
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
if (error.code !== "ENOENT")
|
|
228
|
+
throw error;
|
|
229
|
+
}
|
|
230
|
+
const parent = await realpath(dirname(target));
|
|
231
|
+
const source = await realpath(workspace.source_path);
|
|
232
|
+
if (!isContained(source, parent))
|
|
233
|
+
throw new Error("workspace_overlay_parent_escape");
|
|
234
|
+
const bytes = Buffer.from(overlay.content, "utf8");
|
|
235
|
+
const digest = createHash("sha256").update(bytes).digest("hex");
|
|
236
|
+
if (digest !== overlay.digest)
|
|
237
|
+
throw new Error("workspace_overlay_digest_mismatch");
|
|
238
|
+
await writeFile(target, bytes, { mode: 0o600 });
|
|
239
|
+
await chmod(target, 0o600);
|
|
240
|
+
const storedDigest = createHash("sha256").update(await readFile(target)).digest("hex");
|
|
241
|
+
if (storedDigest !== overlay.digest)
|
|
242
|
+
throw new Error("workspace_overlay_write_mismatch");
|
|
243
|
+
}
|
|
195
244
|
function verifierReason(code) {
|
|
196
245
|
return `verifier_${code}`;
|
|
197
246
|
}
|