@cairnkeep/cli 2.3.0 → 2.4.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.
Files changed (125) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/README.md +221 -6
  3. package/bin/cairn +43 -2
  4. package/claude/capability-contract/hooks/capability-command-finish.sh +81 -0
  5. package/claude/capability-contract/hooks/capability-command-start.sh +76 -0
  6. package/claude/hooks/compaction-capture.sh +34 -0
  7. package/claude/hooks/memory-capture.sh +47 -5
  8. package/claude/hooks/memory-wakeup.sh +55 -2
  9. package/docs/building-an-overlay.md +1 -1
  10. package/docs/design/meta-agent.md +439 -0
  11. package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
  12. package/docs/learning/lessons/L01-safe-trial.md +3 -3
  13. package/docs/learning/lessons/L02-installation.md +2 -2
  14. package/docs/learning/lessons/L03-first-project.md +1 -1
  15. package/docs/operating.md +531 -4
  16. package/docs/privacy-and-data-flow.md +417 -0
  17. package/docs/storage.md +252 -0
  18. package/examples/eval/README.md +42 -0
  19. package/examples/eval/adapter.json +12 -0
  20. package/examples/eval/bundled-fake.json +1 -0
  21. package/examples/eval/task-set.json +1 -0
  22. package/mcp-memory-server/dist/artifact-cli.js +184 -0
  23. package/mcp-memory-server/dist/artifact-schema.js +218 -0
  24. package/mcp-memory-server/dist/artifact-store.js +812 -0
  25. package/mcp-memory-server/dist/capability-adapter.js +286 -0
  26. package/mcp-memory-server/dist/capability-cli.js +361 -0
  27. package/mcp-memory-server/dist/capability-config.js +310 -0
  28. package/mcp-memory-server/dist/capability-harness.js +496 -0
  29. package/mcp-memory-server/dist/capability-registry.js +123 -0
  30. package/mcp-memory-server/dist/capability-schema.js +65 -0
  31. package/mcp-memory-server/dist/capability-store.js +431 -0
  32. package/mcp-memory-server/dist/compaction-normalize.js +267 -0
  33. package/mcp-memory-server/dist/eval-cli.js +523 -0
  34. package/mcp-memory-server/dist/eval-plan.js +346 -0
  35. package/mcp-memory-server/dist/eval-process.js +273 -0
  36. package/mcp-memory-server/dist/eval-report.js +609 -0
  37. package/mcp-memory-server/dist/eval-runner.js +827 -0
  38. package/mcp-memory-server/dist/eval-schema.js +368 -0
  39. package/mcp-memory-server/dist/eval-statistics.js +161 -0
  40. package/mcp-memory-server/dist/eval-workspace.js +313 -0
  41. package/mcp-memory-server/dist/failure-signature.js +141 -0
  42. package/mcp-memory-server/dist/index.js +600 -55
  43. package/mcp-memory-server/dist/node-cli.js +84 -0
  44. package/mcp-memory-server/dist/node-schema.js +170 -0
  45. package/mcp-memory-server/dist/node-store.js +455 -0
  46. package/mcp-memory-server/dist/note-cli.js +235 -0
  47. package/mcp-memory-server/dist/note-distiller.js +177 -0
  48. package/mcp-memory-server/dist/note-enrichment.js +93 -0
  49. package/mcp-memory-server/dist/note-schema.js +4 -0
  50. package/mcp-memory-server/dist/note-store.js +985 -0
  51. package/mcp-memory-server/dist/trajectory-cli.js +141 -0
  52. package/mcp-memory-server/dist/trajectory-normalize.js +448 -0
  53. package/mcp-memory-server/dist/trajectory-redaction.js +155 -0
  54. package/mcp-memory-server/dist/trajectory-schema.js +73 -0
  55. package/mcp-memory-server/dist/trajectory-store.js +339 -0
  56. package/mcp-memory-server/package.json +24 -2
  57. package/mcp-memory-server/scripts/fixtures/compaction-claude-code-2.1.219.json +9 -0
  58. package/mcp-memory-server/scripts/fixtures/compaction-claude-code-2.1.220.json +9 -0
  59. package/mcp-memory-server/scripts/fixtures/compaction-opencode-1.17.20-event.json +6 -0
  60. package/mcp-memory-server/scripts/fixtures/compaction-opencode-1.17.20-messages.json +119 -0
  61. package/mcp-memory-server/scripts/fixtures/compaction-unknown-version.json +17 -0
  62. package/opencode/capability-contract/plugins/capability-command.ts +319 -0
  63. package/opencode/command/graphify.md +25 -14
  64. package/opencode/plugins/memory-capture.ts +76 -10
  65. package/opencode/plugins/memory-wakeup.ts +58 -6
  66. package/package.json +9 -4
  67. package/pi/extensions/cairnkeep-trajectory.ts +55 -0
  68. package/schemas/artifact.schema.json +133 -0
  69. package/schemas/capability-callback.schema.json +68 -0
  70. package/schemas/capability-contract.schema.json +126 -0
  71. package/schemas/eval-adapter.schema.json +43 -0
  72. package/schemas/eval-protocol.schema.json +114 -0
  73. package/schemas/eval-report.schema.json +423 -0
  74. package/schemas/eval-task-set.schema.json +114 -0
  75. package/schemas/memory-node.schema.json +90 -0
  76. package/schemas/note.schema.json +112 -0
  77. package/schemas/trajectory-redaction.schema.json +25 -0
  78. package/schemas/trajectory.schema.json +82 -0
  79. package/scripts/bootstrap.sh +4 -0
  80. package/scripts/completion.sh +119 -5
  81. package/scripts/doctor.sh +284 -0
  82. package/scripts/fake-eval-adapter.mjs +120 -0
  83. package/scripts/fixtures/capability-harness-contracts.json +408 -0
  84. package/scripts/install-audit-timer.sh +3 -1
  85. package/scripts/lib/capability-opencode-plugin-harness.mjs +291 -0
  86. package/scripts/lib/trajectory-opencode-plugin-harness.mjs +25 -0
  87. package/scripts/lib/trajectory-pi-extension-harness.mjs +42 -0
  88. package/scripts/memory-wiki-audit.sh +49 -0
  89. package/scripts/smoke-bash32.sh +9 -0
  90. package/scripts/sync-claude-assets.sh +99 -6
  91. package/scripts/sync-opencode-graphify-assets.sh +24 -3
  92. package/scripts/sync-opencode-plugin-assets.sh +31 -5
  93. package/scripts/sync-opencode-security-assets.sh +24 -3
  94. package/scripts/sync-opencode-wiki-assets.sh +24 -3
  95. package/scripts/sync-pi-assets.sh +72 -0
  96. package/scripts/test-audit-timer.sh +98 -0
  97. package/scripts/test-bootstrap-untracked.sh +48 -0
  98. package/scripts/test-cli-dispatch.sh +41 -2
  99. package/scripts/test-compaction-hooks.sh +495 -0
  100. package/scripts/test-completion.sh +77 -0
  101. package/scripts/test-doctor.sh +348 -1
  102. package/scripts/test-eval-cli.sh +175 -0
  103. package/scripts/test-launcher-hooks.sh +602 -3
  104. package/scripts/test-notes-cli.sh +114 -0
  105. package/scripts/test-package-install.sh +251 -0
  106. package/scripts/test-phase16-node-lifecycle.sh +50 -0
  107. package/scripts/test-phase17-artifact-lifecycle.sh +370 -0
  108. package/scripts/test-phase18-capability-lifecycle.sh +504 -0
  109. package/scripts/test-phase18-harness-boundary.sh +1060 -0
  110. package/scripts/test-phase19-eval-lifecycle.sh +1015 -0
  111. package/scripts/test-trajectory-cli.sh +87 -0
  112. package/scripts/test-trajectory-hooks.sh +142 -0
  113. package/scripts/test-uninstall.sh +231 -5
  114. package/scripts/uninstall.sh +37 -5
  115. package/scripts/verify-dependency-security.mjs +121 -0
  116. package/scripts/verify-docs-parity.sh +527 -1
  117. package/scripts/verify-phase18-runtime-evidence.mjs +895 -0
  118. package/scripts/verify-phase19-runtime-evidence.mjs +947 -0
  119. package/templates/capabilities.json.template +7 -0
  120. package/templates/env.example.template +51 -0
  121. package/templates/memory-wiki-audit.service.template +1 -1
  122. package/templates/start-claude.sh.template +36 -0
  123. package/templates/start-opencode.sh.template +47 -5
  124. package/templates/start-pi.sh.template +30 -0
  125. package/templates/trajectory-redaction.json.template +4 -0
@@ -0,0 +1,291 @@
1
+ #!/usr/bin/env node
2
+
3
+ import assert from "node:assert/strict";
4
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { setTimeout as delay } from "node:timers/promises";
6
+ import { pathToFileURL } from "node:url";
7
+
8
+ const [pluginPath, repoPath, fixturePath, scenario = "contract"] = process.argv.slice(2);
9
+ const sessionID = process.env.CAIRN_TEST_SESSION ?? "session-fixture";
10
+
11
+ assert.ok(
12
+ pluginPath && repoPath && fixturePath,
13
+ "usage: capability-opencode-plugin-harness.mjs <plugin.ts> <repo> <fixture.json> [scenario]",
14
+ );
15
+
16
+ const fixture = JSON.parse(readFileSync(fixturePath, "utf8"));
17
+ const contract = fixture?.opencode;
18
+ const pluginSource = readFileSync(pluginPath, "utf8");
19
+ assert.match(pluginSource, /spawn\("node", \[COORDINATOR, operation\]/,
20
+ "OpenCode's Bun host must delegate the coordinator to Node");
21
+ assert.doesNotMatch(pluginSource, /spawn\(process\.execPath/,
22
+ "OpenCode's process.execPath is the harness binary, not Node");
23
+ assert.match(pluginSource, /state\.operations === 0\s*\? Promise\.resolve\(operation\(\)\)/,
24
+ "non-awaited OpenCode terminal callbacks must start coordinator I/O synchronously");
25
+ assert.match(pluginSource, /spawnSync\("node", \[COORDINATOR, operation\]/,
26
+ "OpenCode terminal settlement needs a bounded synchronous Bun-host bridge");
27
+ assert.match(pluginSource, /process\.versions as NodeJS\.ProcessVersions & \{ bun\?: string \}/,
28
+ "the synchronous terminal bridge must stay scoped to OpenCode's Bun host");
29
+ assert.equal(contract?.version, "1.17.20", "fixture must pin OpenCode 1.17.20");
30
+ assert.equal(
31
+ contract?.source?.commit,
32
+ "4473fc3c9055046183990a965d68df3db7ea6f62",
33
+ "fixture must pin the official v1.17.20 source commit",
34
+ );
35
+ assert.equal(contract?.hook?.name, "command.execute.before");
36
+ assert.deepEqual(contract?.dispose, {
37
+ name: "dispose",
38
+ phase: "plugin-shutdown",
39
+ input_fields: [],
40
+ additional_properties: false,
41
+ source_file: "packages/plugin/src/index.ts",
42
+ source_commit: "4473fc3c9055046183990a965d68df3db7ea6f62",
43
+ purpose: "Release plugin-owned resources after the host stops using the plugin instance.",
44
+ });
45
+ assert.deepEqual(contract?.hook?.input?.required_fields, ["command", "sessionID", "arguments"]);
46
+ assert.deepEqual(contract?.hook?.output?.required_fields, ["parts"]);
47
+ assert.deepEqual(Object.keys(contract?.events ?? {}).sort(), [
48
+ "session.deleted",
49
+ "session.error",
50
+ "session.idle",
51
+ "session.status",
52
+ ]);
53
+ assert.equal(contract?.lifecycle?.event_callback_is_awaited, false);
54
+
55
+ function assertExactKeys(value, required, optional = []) {
56
+ assert.ok(value && typeof value === "object" && !Array.isArray(value));
57
+ const keys = Object.keys(value).sort();
58
+ const allowed = [...required, ...optional].sort();
59
+ for (const key of required) assert.ok(keys.includes(key), `missing required field: ${key}`);
60
+ for (const key of keys) assert.ok(allowed.includes(key), `unexpected fixture field: ${key}`);
61
+ }
62
+
63
+ assertExactKeys(
64
+ contract.hook.input.sample,
65
+ contract.hook.input.required_fields,
66
+ contract.hook.input.optional_fields,
67
+ );
68
+ assertExactKeys(
69
+ contract.hook.output.sample,
70
+ contract.hook.output.required_fields,
71
+ contract.hook.output.optional_fields,
72
+ );
73
+ for (const [name, eventContract] of Object.entries(contract.events)) {
74
+ assertExactKeys(
75
+ eventContract.sample,
76
+ eventContract.required_fields,
77
+ eventContract.optional_fields,
78
+ );
79
+ assertExactKeys(
80
+ eventContract.sample.properties,
81
+ eventContract.properties_required_fields,
82
+ eventContract.properties_optional_fields,
83
+ );
84
+ assert.equal(eventContract.sample.type, name);
85
+ if (name === "session.deleted") {
86
+ assertExactKeys(
87
+ eventContract.sample.properties.info,
88
+ eventContract.info_required_fields,
89
+ eventContract.info_optional_fields,
90
+ );
91
+ assertExactKeys(
92
+ eventContract.sample.properties.info.time,
93
+ eventContract.time_required_fields,
94
+ eventContract.time_optional_fields,
95
+ );
96
+ }
97
+ }
98
+
99
+ const module = await import(`${pathToFileURL(pluginPath).href}?test=${Date.now()}`);
100
+ for (const [name, value] of Object.entries(module)) {
101
+ assert.equal(typeof value, "function", `OpenCode treats exported ${name} as a plugin entrypoint`);
102
+ }
103
+ assert.equal(typeof module.CapabilityCommandPlugin, "function");
104
+ assert.deepEqual(module.CapabilityCommandPlugin.contract, {
105
+ version: contract.version,
106
+ sourceCommit: contract.source.commit,
107
+ admissionHook: contract.hook.name,
108
+ terminalEvents: contract.lifecycle,
109
+ });
110
+
111
+ const calls = [];
112
+ const client = {
113
+ session: {
114
+ get: async ({ path }) => ({
115
+ data: {
116
+ id: path.id,
117
+ projectID: "project-fixture",
118
+ directory: repoPath,
119
+ title: "fixture",
120
+ version: contract.version,
121
+ time: { created: 1, updated: 2 },
122
+ },
123
+ }),
124
+ },
125
+ };
126
+ const plugin = await module.CapabilityCommandPlugin({
127
+ client,
128
+ project: {
129
+ id: "project-fixture",
130
+ worktree: scenario === "identity-mismatch" ? `${repoPath}-decoy` : repoPath,
131
+ },
132
+ directory: repoPath,
133
+ worktree: repoPath,
134
+ serverUrl: new URL("http://127.0.0.1:4096"),
135
+ $: undefined,
136
+ experimental_workspace: undefined,
137
+ });
138
+
139
+ assert.ok(plugin && typeof plugin === "object");
140
+ assert.equal(typeof plugin[contract.hook.name], "function");
141
+ assert.equal(typeof plugin.event, "function");
142
+
143
+ async function commandBefore(targetSessionID = sessionID) {
144
+ const input = structuredClone(contract.hook.input.sample);
145
+ const output = structuredClone(contract.hook.output.sample);
146
+ input.sessionID = targetSessionID;
147
+ if (scenario === "malformed-admission") input.unexpected = "rejected";
148
+ try {
149
+ await plugin[contract.hook.name](input, output);
150
+ calls.push({ boundary: contract.hook.name, result: "allowed", parts: output.parts.length });
151
+ } catch (error) {
152
+ calls.push({
153
+ boundary: contract.hook.name,
154
+ result: "blocked",
155
+ error: error instanceof Error ? error.message : String(error),
156
+ });
157
+ }
158
+ }
159
+
160
+ async function deliver(eventName, targetSessionID = sessionID) {
161
+ const event = structuredClone(contract.events[eventName].sample);
162
+ if (eventName === "session.deleted") event.properties.info.id = targetSessionID;
163
+ else event.properties.sessionID = targetSessionID;
164
+ await plugin.event({ event });
165
+ calls.push({ boundary: "event", event: eventName });
166
+ }
167
+
168
+ async function disposePlugin() {
169
+ if (typeof plugin.dispose !== "function") {
170
+ calls.push({ boundary: contract.dispose.name, result: "missing" });
171
+ return false;
172
+ }
173
+ await plugin.dispose();
174
+ calls.push({ boundary: contract.dispose.name, result: "completed" });
175
+ return true;
176
+ }
177
+
178
+ function traceRows() {
179
+ const trace = process.env.CAIRN_BOUNDARY_TRACE;
180
+ if (!trace || !existsSync(trace)) return [];
181
+ const raw = readFileSync(trace, "utf8").trim();
182
+ return raw ? raw.split(/\n/).map(JSON.parse) : [];
183
+ }
184
+
185
+ async function waitForOperation(operation, count) {
186
+ for (let attempt = 0; attempt < 200; attempt += 1) {
187
+ if (traceRows().filter((row) => row.operation === operation).length >= count) return;
188
+ await delay(10);
189
+ }
190
+ throw new Error(`coordinator operation did not arrive: ${operation} #${count}`);
191
+ }
192
+
193
+ function releaseCoordinator() {
194
+ const release = process.env.CAIRN_TEST_RELEASE_FILE;
195
+ assert.ok(release, "CAIRN_TEST_RELEASE_FILE is required for ordering scenarios");
196
+ writeFileSync(release, "release\n");
197
+ }
198
+
199
+ const scenarios = new Set([
200
+ "contract",
201
+ "admission",
202
+ "success",
203
+ "status-success",
204
+ "error",
205
+ "abandonment",
206
+ "duplicate-success",
207
+ "duplicate-error",
208
+ "settled-then-delete",
209
+ "cwd-drift-success",
210
+ "malformed-admission",
211
+ "identity-mismatch",
212
+ "dispose-contract",
213
+ "dispose-unfinished",
214
+ "dispose-multiple",
215
+ "dispose-settled-success",
216
+ "dispose-settled-error",
217
+ "dispose-terminal-race",
218
+ "terminal-next-admission",
219
+ "dispose-master-off",
220
+ ]);
221
+ assert.ok(scenarios.has(scenario), `unsupported scenario: ${scenario}`);
222
+
223
+ if (!new Set(["contract", "dispose-contract", "dispose-multiple"]).has(scenario)) await commandBefore();
224
+ if (scenario === "success") await deliver("session.idle");
225
+ if (scenario === "status-success") await deliver("session.status");
226
+ if (scenario === "error") await deliver("session.error");
227
+ if (scenario === "abandonment") await deliver("session.deleted");
228
+ if (scenario === "duplicate-success") {
229
+ await deliver("session.idle");
230
+ await deliver("session.status");
231
+ await deliver("session.idle");
232
+ }
233
+ if (scenario === "duplicate-error") {
234
+ await deliver("session.error");
235
+ await deliver("session.error");
236
+ }
237
+ if (scenario === "settled-then-delete") {
238
+ await deliver("session.idle");
239
+ await deliver("session.deleted");
240
+ }
241
+ if (scenario === "cwd-drift-success") {
242
+ process.chdir(`${repoPath}-decoy`);
243
+ await deliver("session.idle");
244
+ }
245
+ if (scenario === "dispose-contract") await disposePlugin();
246
+ if (scenario === "dispose-unfinished" || scenario === "dispose-master-off") await disposePlugin();
247
+ if (scenario === "dispose-multiple") {
248
+ await commandBefore(`${sessionID}-one`);
249
+ await commandBefore(`${sessionID}-two`);
250
+ await disposePlugin();
251
+ }
252
+ if (scenario === "dispose-settled-success") {
253
+ await deliver("session.idle");
254
+ await disposePlugin();
255
+ }
256
+ if (scenario === "dispose-settled-error") {
257
+ await deliver("session.error");
258
+ await disposePlugin();
259
+ }
260
+ if (scenario === "dispose-terminal-race") {
261
+ assert.equal(typeof plugin.dispose, "function", "native dispose hook is missing");
262
+ const terminal = deliver("session.idle");
263
+ await waitForOperation("harness-terminal", 1);
264
+ const disposal = plugin.dispose();
265
+ await delay(50);
266
+ releaseCoordinator();
267
+ await terminal;
268
+ await disposal;
269
+ calls.push({ boundary: contract.dispose.name, result: "completed" });
270
+ }
271
+ if (scenario === "terminal-next-admission") {
272
+ const terminal = deliver("session.error");
273
+ await waitForOperation("harness-terminal", 1);
274
+ const nextAdmission = commandBefore();
275
+ await delay(50);
276
+ assert.equal(traceRows().filter((row) => row.operation === "harness-before").length, 1,
277
+ "next same-session admission reached the coordinator before the accepted terminal finished");
278
+ releaseCoordinator();
279
+ await terminal;
280
+ await nextAdmission;
281
+ }
282
+
283
+ process.stdout.write(`${JSON.stringify({
284
+ fixture: {
285
+ version: contract.version,
286
+ commit: contract.source.commit,
287
+ },
288
+ dispose_present: typeof plugin.dispose === "function",
289
+ scenario,
290
+ calls,
291
+ })}\n`);
@@ -0,0 +1,25 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFileSync } from "node:fs";
3
+ import { pathToFileURL } from "node:url";
4
+
5
+ const [pluginPath, repoPath, fixturePath] = process.argv.slice(2);
6
+ assert.ok(pluginPath && repoPath && fixturePath, "usage: harness <plugin.ts> <repo> <fixture.json>");
7
+
8
+ const fixture = JSON.parse(readFileSync(fixturePath, "utf8"));
9
+ const module = await import(`${pathToFileURL(pluginPath).href}?test=${Date.now()}`);
10
+ assert.equal(typeof module.MemoryCapturePlugin, "function");
11
+
12
+ const client = {
13
+ session: {
14
+ get: async () => ({ data: { id: fixture.session.id } }),
15
+ messages: async () => ({ data: fixture.messages }),
16
+ },
17
+ };
18
+ const plugin = await module.MemoryCapturePlugin({ client, directory: repoPath });
19
+ assert.equal(typeof plugin.event, "function");
20
+ await plugin.event({
21
+ event: {
22
+ type: "session.idle",
23
+ properties: { sessionID: fixture.session.id },
24
+ },
25
+ });
@@ -0,0 +1,42 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFileSync } from "node:fs";
3
+ import { pathToFileURL } from "node:url";
4
+
5
+ const [extensionPath, repoPath, fixturePath] = process.argv.slice(2);
6
+ assert.ok(extensionPath && repoPath && fixturePath, "usage: harness <extension.ts> <repo> <fixture.json>");
7
+
8
+ const fixture = JSON.parse(readFileSync(fixturePath, "utf8"));
9
+ const module = await import(`${pathToFileURL(extensionPath).href}?test=${Date.now()}`);
10
+ assert.equal(typeof module.default, "function");
11
+
12
+ let shutdownHandler;
13
+ const pi = {
14
+ on(event, handler) {
15
+ if (event === "session_shutdown") shutdownHandler = handler;
16
+ },
17
+ };
18
+ await module.default(pi);
19
+ assert.equal(typeof shutdownHandler, "function");
20
+
21
+ let sessionReads = 0;
22
+ const sessionManager = {
23
+ getSessionId() {
24
+ sessionReads += 1;
25
+ return fixture.session.id;
26
+ },
27
+ getBranch() {
28
+ sessionReads += 1;
29
+ return fixture.session.entries;
30
+ },
31
+ };
32
+
33
+ await shutdownHandler(
34
+ { type: "session_shutdown", reason: "quit" },
35
+ { cwd: repoPath, sessionManager },
36
+ );
37
+
38
+ if (/^(?:1|true|yes|on)$/i.test(process.env.CAIRN_TRAJECTORY_CAPTURE ?? "")) {
39
+ assert.equal(sessionReads, 2, "enabled adapter must read the session ID and active branch exactly once");
40
+ } else {
41
+ assert.equal(sessionReads, 0, "disabled adapter must not touch the SessionManager");
42
+ }
@@ -6,6 +6,7 @@
6
6
  # 2. open hard contradictions in CONTRADICTIONS.md
7
7
  # 3. orphan source pages not linked from index.md
8
8
  # 4. unreviewed memory candidates staged longer than STALE_DAYS
9
+ # 5. optional closed-trajectory note distillation in a separate process
9
10
  #
10
11
  # Output: a consolidated markdown report on stdout (and optionally to REPORT_PATH).
11
12
  # Designed for a systemd timer or cron. Pure bash/grep — no model round-trip, so
@@ -19,6 +20,8 @@
19
20
  # --report PATH also write the report to this file
20
21
  set -euo pipefail
21
22
 
23
+ CAIRN_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
24
+
22
25
  PARA_ROOT="${HOME}/PARA"
23
26
  STALE_DAYS=30
24
27
  REPORT_PATH=""
@@ -41,6 +44,8 @@ hard_open_total=0
41
44
  orphan_total=0
42
45
  staged_stale_total=0
43
46
  sections=()
47
+ note_distillation_enabled=0
48
+ note_distillation_status=""
44
49
 
45
50
  # Find every project with a wiki.
46
51
  while IFS= read -r wiki_dir; do
@@ -118,6 +123,47 @@ while IFS= read -r wiki_dir; do
118
123
  fi
119
124
  done < <(find "$PARA_ROOT" -maxdepth 4 -type d -name wiki -path '*/.planning/wiki' 2>/dev/null)
120
125
 
126
+ # Keep note taking outside every harness callback. The audit timer starts the
127
+ # same public one-shot command an operator would run. Master-off retains the
128
+ # compatibility flag; master-on consumes the central effective status. A note
129
+ # failure is reported but never hides or changes wiki findings or their exit.
130
+ note_distillation_eligible=0
131
+ case "${CAIRN_CAPABILITY_CONTRACT:-}" in
132
+ 1|true|TRUE|yes|YES|on|ON)
133
+ capability_status="$("$CAIRN_ROOT/bin/cairn" capabilities status --json 2>/dev/null || true)"
134
+ if printf '%s' "$capability_status" | node -e '
135
+ let input = "";
136
+ process.stdin.setEncoding("utf8");
137
+ process.stdin.on("data", (chunk) => { input += chunk; });
138
+ process.stdin.on("end", () => {
139
+ try {
140
+ const status = JSON.parse(input);
141
+ const notes = status.capabilities?.find((row) => row.id === "notes.distill");
142
+ process.exit(notes?.enabled === true ? 0 : 1);
143
+ } catch {
144
+ process.exit(1);
145
+ }
146
+ });
147
+ '; then
148
+ note_distillation_eligible=1
149
+ fi
150
+ ;;
151
+ *)
152
+ case "${CAIRN_NOTE_DISTILLATION:-}" in
153
+ 1|true|TRUE|yes|YES|on|ON) note_distillation_eligible=1 ;;
154
+ esac
155
+ ;;
156
+ esac
157
+
158
+ if (( note_distillation_eligible == 1 )); then
159
+ note_distillation_enabled=1
160
+ if "$CAIRN_ROOT/bin/cairn" notes distill --all-projects --para-root "$PARA_ROOT" --json >/dev/null 2>&1; then
161
+ note_distillation_status="completed"
162
+ else
163
+ note_distillation_status="failed (deterministic wiki findings remain valid)"
164
+ fi
165
+ fi
166
+
121
167
  # Build report once, output to stdout (and REPORT_PATH if set).
122
168
  report_body="$(
123
169
  {
@@ -129,6 +175,9 @@ report_body="$(
129
175
  echo "- Open hard contradictions: $hard_open_total"
130
176
  echo "- Orphan source pages: $orphan_total"
131
177
  echo "- Unreviewed staged candidates: $staged_stale_total"
178
+ if (( note_distillation_enabled == 1 )); then
179
+ echo "- Note distillation: $note_distillation_status"
180
+ fi
132
181
  echo
133
182
  if (( ${#sections[@]} > 0 )); then
134
183
  echo "## Findings by project"
@@ -30,6 +30,7 @@ SCRIPTS=(
30
30
  scripts/sync-claude-assets.sh
31
31
  scripts/sync-opencode-explore-assets.sh
32
32
  scripts/sync-opencode-wiki-assets.sh
33
+ scripts/sync-pi-assets.sh
33
34
  )
34
35
 
35
36
  fails=0
@@ -49,6 +50,14 @@ for s in "${SCRIPTS[@]}"; do
49
50
  fi
50
51
  done
51
52
 
53
+ help_out=$("$RUNTIME" run --rm -v "$ROOT_DIR":/w:ro -w /w "$IMG" bash scripts/doctor.sh --help 2>&1) || true
54
+ if grep -q 'typed metadata.*note transactions' <<<"$help_out"; then
55
+ echo " [PASS] doctor help runs on bash 3.2"
56
+ else
57
+ echo " [FAIL] doctor help did not expose Phase 16 repair scope"
58
+ fails=$((fails + 1))
59
+ fi
60
+
52
61
  if [[ $fails -eq 0 ]]; then
53
62
  echo "smoke-bash32: OK"
54
63
  exit 0
@@ -4,7 +4,7 @@ shopt -s nullglob
4
4
 
5
5
  usage() {
6
6
  cat <<'EOF'
7
- Usage: sync-claude-assets.sh [--check|--apply] [--live-root PATH]
7
+ Usage: sync-claude-assets.sh [--check|--apply] [--capability-overlay] [--live-root PATH]
8
8
 
9
9
  Compare or sync the repo-managed Claude Code assets (commands, agents) and the
10
10
  security/wiki scaffold templates against the live Claude Code config tree.
@@ -12,12 +12,17 @@ security/wiki scaffold templates against the live Claude Code config tree.
12
12
  Options:
13
13
  --check Verify that the live assets match the repo copy (default)
14
14
  --apply Copy the repo-managed assets into the live Claude tree, then verify
15
+ --capability-overlay
16
+ Select guarded commands and native capability hooks only when
17
+ CAIRN_CAPABILITY_CONTRACT is also enabled
15
18
  --live-root PATH Override the live Claude root (default: $CLAUDE_CONFIG_DIR or $HOME/.claude)
16
19
  -h, --help Show this help text
17
20
 
18
21
  Notes:
19
22
  - Claude assets source of truth lives under ./claude/ (commands/, agents/).
20
23
  - Installs claude/commands/*.md -> <live>/commands/ and claude/agents/*.md -> <live>/agents/.
24
+ - --capability-overlay substitutes matching files from claude/capability-contract/
25
+ only with the master switch enabled; all other modes ignore that tree.
21
26
  - Also installs templates/{security,wiki}-*.template -> <live>/templates/ so the
22
27
  /security-audit and /wiki-* commands can scaffold from $HOME/.claude/templates/.
23
28
  - Renders claude/hooks/*.sh -> <live>/hooks/ (substituting @@INFRA_ROOT@@) and
@@ -32,17 +37,34 @@ CLAUDE_SOURCE="$ROOT_DIR/claude"
32
37
  TEMPLATE_SOURCE="$ROOT_DIR/templates"
33
38
  LIVE_ROOT="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
34
39
  MODE="check"
40
+ CAPABILITY_OVERLAY=0
35
41
 
36
42
  while [[ $# -gt 0 ]]; do
37
43
  case "$1" in
38
44
  --check) MODE="check"; shift ;;
39
45
  --apply) MODE="apply"; shift ;;
46
+ --capability-overlay) CAPABILITY_OVERLAY=1; shift ;;
40
47
  --live-root) LIVE_ROOT="$2"; shift 2 ;;
41
48
  -h|--help) usage; exit 0 ;;
42
49
  *) echo "Unknown argument: $1" >&2; usage >&2; exit 2 ;;
43
50
  esac
44
51
  done
45
52
 
53
+ capability_contract_enabled() {
54
+ local value="${CAIRN_CAPABILITY_CONTRACT:-}"
55
+ value="${value#"${value%%[![:space:]]*}"}"
56
+ value="${value%"${value##*[![:space:]]}"}"
57
+ case "$value" in
58
+ 1|[Tt][Rr][Uu][Ee]|[Yy][Ee][Ss]|[Oo][Nn]) return 0 ;;
59
+ *) return 1 ;;
60
+ esac
61
+ }
62
+
63
+ CAPABILITY_OVERLAY_ACTIVE=0
64
+ if [[ "$CAPABILITY_OVERLAY" -eq 1 ]] && capability_contract_enabled; then
65
+ CAPABILITY_OVERLAY_ACTIVE=1
66
+ fi
67
+
46
68
  if [[ ! -d "$CLAUDE_SOURCE" ]]; then
47
69
  echo "No repo-managed Claude assets found at $CLAUDE_SOURCE" >&2
48
70
  exit 1
@@ -54,9 +76,13 @@ DSTS=()
54
76
 
55
77
  # 1. claude/ tree -> <live>/...
56
78
  while IFS= read -r rel; do
57
- SRCS+=("$CLAUDE_SOURCE/$rel")
79
+ src="$CLAUDE_SOURCE/$rel"
80
+ if [[ "$CAPABILITY_OVERLAY_ACTIVE" -eq 1 && -f "$CLAUDE_SOURCE/capability-contract/$rel" ]]; then
81
+ src="$CLAUDE_SOURCE/capability-contract/$rel"
82
+ fi
83
+ SRCS+=("$src")
58
84
  DSTS+=("$LIVE_ROOT/$rel")
59
- done < <(cd "$CLAUDE_SOURCE" && find . -type f -name '*.md' | sed 's|^\./||' | sort)
85
+ done < <(cd "$CLAUDE_SOURCE" && find . -path './capability-contract' -prune -o -type f -name '*.md' -print | sed 's|^\./||' | sort)
60
86
 
61
87
  # 2. security/wiki scaffold templates -> <live>/templates/
62
88
  for tpl in "$TEMPLATE_SOURCE"/security-*.template "$TEMPLATE_SOURCE"/wiki-*.template; do
@@ -71,6 +97,8 @@ if ((${#SRCS[@]} == 0)); then
71
97
  fi
72
98
 
73
99
  status=0
100
+ HOOK_LIVE_DIR="$LIVE_ROOT/hooks"
101
+ SETTINGS_FILE="$LIVE_ROOT/settings.json"
74
102
  for i in "${!SRCS[@]}"; do
75
103
  src="${SRCS[$i]}"
76
104
  dst="${DSTS[$i]}"
@@ -89,12 +117,76 @@ for i in "${!SRCS[@]}"; do
89
117
  fi
90
118
  done
91
119
 
120
+ # Capability hooks are intentionally outside claude/hooks so the legacy loop
121
+ # above cannot discover, render, register, or execute them. They enter an
122
+ # isolated root only through the effective overlay branch.
123
+ if [[ "$CAPABILITY_OVERLAY_ACTIVE" -eq 1 ]]; then
124
+ for hook_src in "$CLAUDE_SOURCE"/capability-contract/hooks/*.sh; do
125
+ [[ -f "$hook_src" ]] || continue
126
+ hook_name="$(basename "$hook_src")"
127
+ hook_dst="$HOOK_LIVE_DIR/$hook_name"
128
+ hook_cmd="bash \"$hook_dst\""
129
+ case "$hook_name" in
130
+ capability-command-start.sh)
131
+ hook_event_specs='UserPromptExpansion:wiki-ingest|wiki-query|wiki-lint|graphify|security-audit'
132
+ ;;
133
+ capability-command-finish.sh)
134
+ hook_event_specs=$'Stop\nStopFailure\nCwdChanged\nSessionEnd'
135
+ ;;
136
+ *)
137
+ continue
138
+ ;;
139
+ esac
140
+
141
+ if [[ "$MODE" == "apply" ]]; then
142
+ mkdir -p "$HOOK_LIVE_DIR"
143
+ sed "s|@@INFRA_ROOT@@|$ROOT_DIR|g" "$hook_src" > "$hook_dst"
144
+ chmod +x "$hook_dst"
145
+ echo "installed: hooks/$hook_name"
146
+ fi
147
+
148
+ while IFS= read -r event_spec; do
149
+ [[ -n "$event_spec" ]] || continue
150
+ event="${event_spec%%:*}"
151
+ matcher="${event_spec#*:}"
152
+ [[ "$matcher" == "$event_spec" ]] && matcher=""
153
+ if [[ "$MODE" == "apply" ]]; then
154
+ node -e '
155
+ const fs = require("fs");
156
+ const [p, cmd, token, event, matcher] = process.argv.slice(1);
157
+ let s = {};
158
+ if (fs.existsSync(p)) {
159
+ try { s = JSON.parse(fs.readFileSync(p, "utf8")); }
160
+ catch (e) { console.error("settings.json parse failed, leaving untouched"); process.exit(1); }
161
+ }
162
+ s.hooks = s.hooks || {};
163
+ s.hooks[event] = Array.isArray(s.hooks[event]) ? s.hooks[event] : [];
164
+ const already = s.hooks[event].some((entry) =>
165
+ Array.isArray(entry.hooks) && entry.hooks.some((h) => typeof h.command === "string" && h.command.includes(token)));
166
+ if (already) { console.log(`ok: ${event} hook already registered`); process.exit(0); }
167
+ if (fs.existsSync(p)) fs.copyFileSync(p, p + ".bak");
168
+ const entry = matcher
169
+ ? { matcher, hooks: [{ type: "command", command: cmd }] }
170
+ : { hooks: [{ type: "command", command: cmd }] };
171
+ s.hooks[event].push(entry);
172
+ fs.writeFileSync(p, JSON.stringify(s, null, 2) + "\n");
173
+ console.log(`registered: ${event} hook${matcher ? " (" + matcher + ")" : ""}`);
174
+ ' "$SETTINGS_FILE" "$hook_cmd" "$hook_dst" "$event" "$matcher" || status=1
175
+ else
176
+ if [[ -f "$hook_dst" ]] && grep -qF "$hook_name" "$SETTINGS_FILE" 2>/dev/null; then
177
+ echo "ok: hooks/$hook_name ($event)"
178
+ else
179
+ echo "DRIFT: hooks/$hook_name ($event)"
180
+ status=1
181
+ fi
182
+ fi
183
+ done <<< "$hook_event_specs"
184
+ done
185
+ fi
186
+
92
187
  # Claude hooks: render @@INFRA_ROOT@@ and register each hook on its correct
93
188
  # event in <live>/settings.json. This is the one place that touches the global
94
189
  # user settings; it backs up first and matches by command so re-runs are no-ops.
95
- HOOK_LIVE_DIR="$LIVE_ROOT/hooks"
96
- SETTINGS_FILE="$LIVE_ROOT/settings.json"
97
-
98
190
  # Map hook filename -> "event[:matcher][@timeout]". matcher only for
99
191
  # PreToolUse-style events; an optional trailing "@timeout" (seconds) threads
100
192
  # an explicit per-hook timeout into the settings.json registration entry so
@@ -108,6 +200,7 @@ hook_event_for() {
108
200
  case "$1" in
109
201
  memory-wakeup.sh) echo "SessionStart" ;;
110
202
  memory-capture.sh) echo "SessionEnd" ;;
203
+ compaction-capture.sh) echo "PostCompact" ;;
111
204
  memory-recall.sh) echo "PreToolUse:Edit|Write|MultiEdit" ;;
112
205
  context-explore-pretask.sh) echo "UserPromptSubmit@25" ;;
113
206
  *) echo "" ;;