ruvnet-brain 4.3.21 → 4.3.26

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 (143) hide show
  1. package/README.md +5 -5
  2. package/bin/install.mjs +275 -60
  3. package/console/app.js +189 -9
  4. package/console/index.html +70 -24
  5. package/console/scope.css +137 -0
  6. package/console/scope.html +144 -0
  7. package/console/scope.js +209 -0
  8. package/console/style.css +26 -0
  9. package/console/tips.html +1 -0
  10. package/kb/corpus-release-identity.mjs +239 -0
  11. package/kb/update-storage-transaction.mjs +20 -3
  12. package/package.json +9 -2
  13. package/plugin/.claude-plugin/plugin.json +2 -2
  14. package/plugin/.codex-plugin/plugin.json +1 -1
  15. package/plugin/commands/checkpoint.md +61 -0
  16. package/plugin/hooks/codex-hooks.json +64 -1
  17. package/plugin/hooks/hook-contracts.json +299 -6
  18. package/plugin/hooks/hooks.json +81 -1
  19. package/plugin/mcp/server.mjs +23 -0
  20. package/plugin/scripts/advocacy-catalog.mjs +245 -0
  21. package/plugin/scripts/advocacy-route.mjs +460 -0
  22. package/plugin/scripts/continuation-gate.mjs +25 -2
  23. package/plugin/scripts/continuation-objective.mjs +7 -1
  24. package/plugin/scripts/continuity-hook-policy.mjs +190 -15
  25. package/plugin/scripts/coverage-integrity.mjs +7 -0
  26. package/plugin/scripts/gates.mjs +113 -10
  27. package/plugin/scripts/grounding-turn-gate.mjs +167 -0
  28. package/plugin/scripts/grounding-turn-mark.mjs +91 -0
  29. package/plugin/scripts/hook-shim.mjs +14 -0
  30. package/plugin/scripts/nightly-scheduler.mjs +37 -4
  31. package/plugin/scripts/project-progression-checkpoint.mjs +145 -0
  32. package/plugin/scripts/project-progression-contract.mjs +16 -0
  33. package/plugin/scripts/project-progression-hook.mjs +3 -0
  34. package/plugin/scripts/project-progression-producer.mjs +252 -0
  35. package/plugin/scripts/project-progression-reader.mjs +271 -0
  36. package/plugin/scripts/project-progression-session-start.mjs +93 -16
  37. package/plugin/scripts/project-progression-sources.mjs +220 -0
  38. package/plugin/scripts/project-progression-store.mjs +106 -13
  39. package/plugin/scripts/ruvnet-gate1-pattern.mjs +29 -0
  40. package/plugin/scripts/session-snapshot-hook.mjs +115 -7
  41. package/plugin/scripts/session-start-budget.mjs +59 -0
  42. package/plugin/scripts/session-start-core.mjs +234 -457
  43. package/plugin/scripts/session-start-fsutil.mjs +61 -0
  44. package/plugin/scripts/session-start-health.mjs +64 -0
  45. package/plugin/scripts/session-start-hook-description.mjs +45 -0
  46. package/plugin/scripts/session-start-issue-alert.mjs +77 -0
  47. package/plugin/scripts/session-start-repo-identity.mjs +54 -0
  48. package/plugin/scripts/session-start-signals.mjs +73 -0
  49. package/plugin/scripts/session-start-trace.mjs +86 -0
  50. package/plugin/scripts/session-start-update-plane.mjs +104 -0
  51. package/plugin/scripts/unprompted-runtime.mjs +32 -2
  52. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +26 -2
  53. package/plugin/skills/ruvnet-brain/SKILL.md +67 -2
  54. package/scripts/adr-072-completion.mjs +1 -1
  55. package/scripts/agentdb-fleet-doctor.mjs +5 -1
  56. package/scripts/approved-runtime.mjs +197 -0
  57. package/scripts/brain-novice-50.mjs +16 -1
  58. package/scripts/brain-score.mjs +23 -5
  59. package/scripts/build-bundle.mjs +971 -530
  60. package/scripts/build-concepts.mjs +36 -116
  61. package/scripts/console-engine.test.mjs +8 -7
  62. package/scripts/console-runtime-identity.mjs +4 -0
  63. package/scripts/corpus-aggregates.mjs +94 -77
  64. package/scripts/corpus-candidate.mjs +475 -222
  65. package/scripts/corpus-next-seed.mjs +225 -0
  66. package/scripts/corpus-promotion.mjs +58 -0
  67. package/scripts/corpus-reconcile.mjs +411 -105
  68. package/scripts/doc-currency.mjs +16 -1
  69. package/scripts/dual-host-deliberation.mjs +25 -2
  70. package/scripts/dual-host-suggest.mjs +17 -1
  71. package/scripts/falsify.mjs +13 -3
  72. package/scripts/gist-receipts.mjs +482 -87
  73. package/scripts/github-health-watch.mjs +12 -2
  74. package/scripts/handoff-asset.mjs +34 -0
  75. package/scripts/hook-retirement-check.mjs +8 -1
  76. package/scripts/host-registry.mjs +1 -1
  77. package/scripts/ingest-gists.mjs +74 -101
  78. package/scripts/job-heartbeat.sh +77 -14
  79. package/scripts/learning-replay-execution.mjs +10 -4
  80. package/scripts/nightly-gists.sh +27 -13
  81. package/scripts/nightly-two-run-proof.mjs +1 -1
  82. package/scripts/nightly-watchdog.mjs +61 -4
  83. package/scripts/onboarding-console.mjs +364 -28
  84. package/scripts/oracle/produce-questions.mjs +293 -0
  85. package/scripts/oracle/producer-hosts.mjs +235 -0
  86. package/scripts/oracle/repo-recall.mjs +448 -0
  87. package/scripts/oracle/retrieval-accuracy.mjs +818 -0
  88. package/scripts/oracle/source-tree.mjs +165 -0
  89. package/scripts/oracle/source-units.mjs +391 -0
  90. package/scripts/oracle/spike-run.mjs +98 -0
  91. package/scripts/oracle/unit-inventory.mjs +141 -0
  92. package/scripts/oracle/unit-sampling.mjs +128 -0
  93. package/scripts/oracle/validate-labels.mjs +250 -0
  94. package/scripts/private-overlay.mjs +248 -0
  95. package/scripts/product-integrity-contract.mjs +1 -1
  96. package/scripts/proxy/claude-proxied.sh +6 -0
  97. package/scripts/proxy/proxy-revert.sh +5 -0
  98. package/scripts/proxy/proxy-up.sh +6 -0
  99. package/scripts/proxy/proxy-verify.mjs +4 -0
  100. package/scripts/public-inputs.mjs +409 -0
  101. package/scripts/public-verification-inputs.mjs +112 -26
  102. package/scripts/public-verification-lane.mjs +1 -1
  103. package/scripts/published-surface-probe.mjs +34 -4
  104. package/scripts/qe/card-lane-gate.mjs +16 -1
  105. package/scripts/qe/session-start-gate.mjs +16 -1
  106. package/scripts/rebuild-gists-from-receipts.mjs +58 -78
  107. package/scripts/record-lesson.mjs +4 -1
  108. package/scripts/rehearse-corpus-pipeline.mjs +994 -0
  109. package/scripts/release-abort-stale.mjs +5 -1
  110. package/scripts/release-authority.mjs +104 -12
  111. package/scripts/release-channel-kind.mjs +86 -0
  112. package/scripts/release-convergence-watchdog.mjs +7 -2
  113. package/scripts/release-projection.mjs +177 -72
  114. package/scripts/release-transaction-provider.mjs +47 -10
  115. package/scripts/release-transaction.mjs +40 -11
  116. package/scripts/release.mjs +252 -17
  117. package/scripts/retrieval-canary.mjs +87 -0
  118. package/scripts/rvf-index-audit.mjs +573 -13
  119. package/scripts/rvf-wire.mjs +269 -0
  120. package/scripts/seal-gist-receipt.mjs +65 -0
  121. package/scripts/selfcheck.mjs +42 -21
  122. package/scripts/source-coverage.mjs +253 -24
  123. package/scripts/status-honesty.mjs +25 -0
  124. package/scripts/sync-census.mjs +0 -0
  125. package/scripts/sync-version.mjs +2 -0
  126. package/scripts/trismart.mjs +42 -0
  127. package/scripts/updater-manifest.mjs +162 -0
  128. package/scripts/verify-channels.mjs +17 -5
  129. package/scripts/wired-check.mjs +48 -10
  130. package/tri-smart-skill/QUICKSTART.md +37 -0
  131. package/tri-smart-skill/README.md +92 -0
  132. package/tri-smart-skill/install.cmd +14 -0
  133. package/tri-smart-skill/install.command +13 -0
  134. package/tri-smart-skill/install.mjs +51 -0
  135. package/tri-smart-skill/install.sh +9 -0
  136. package/tri-smart-skill/tri-smart/SKILL.md +90 -0
  137. package/tri-smart-skill/tri-smart/evals/evals.json +25 -0
  138. package/tri-smart-skill/tri-smart/references/protocol.md +25 -0
  139. package/tri-smart-skill/tri-smart/references/provider-cli.md +18 -0
  140. package/tri-smart-skill/tri-smart/scripts/review.mjs +154 -0
  141. package/tri-smart-skill/tri-smart/scripts/setup.mjs +97 -0
  142. package/tri-smart-skill/tri-smart/scripts/verify-access.mjs +107 -0
  143. package/scripts/corpus-seed-publish.mjs +0 -110
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * grounding-turn-mark.mjs — UserPromptSubmit half of the "answered without searching" gate.
4
+ *
5
+ * THE GAP THIS CLOSES (measured, not assumed — see grounding-turn-gate.mjs's header for the full
6
+ * account). ground-ruvnet.sh's Gate 1 tells the model, in prose, "you MUST call search_ruvnet
7
+ * before asserting" whenever the prompt touches the rUv stack — but a prompt is advisory (rUv's own
8
+ * ADR-G007: "prompts are advisory. Agents can and do ignore them"), and nothing checked whether the
9
+ * model actually did it before the turn ended. This file is step one of making that check possible:
10
+ * it records ONLY the fact that Gate 1 fired for this turn, so the Stop-time gate
11
+ * (grounding-turn-gate.mjs) has something to check against — Stop's own payload carries no prompt
12
+ * text (confirmed against every Stop-consuming file in this repo; continuation-gate.mjs's `Stop`
13
+ * input has session_id/turn_id/stop_hook_active/last_assistant_message and nothing resembling the
14
+ * user's prompt).
15
+ *
16
+ * THE REGEX IS NOT REDEFINED HERE. It is imported from ruvnet-gate1-pattern.mjs, which is the one
17
+ * JS copy of ground-ruvnet.sh's Gate 1 ERE, proven byte-identical to it by
18
+ * tests/unit/ruvnet-gate1-pattern.test.mjs. ground-ruvnet.sh itself is UNTOUCHED by this file — it
19
+ * is a hot, heavily-tuned, every-prompt hook, and this feature does not need to change it.
20
+ *
21
+ * WHAT THIS WRITES: a marker file under ~/.cache/ruvnet-brain/grounding-turn/<session_id>, whose
22
+ * MTIME is the signal (same idiom grounding-stamp.sh and ground-before-write.sh already use for
23
+ * their own 24h stamps — this reuses that mtime-comparison convention rather than inventing a new
24
+ * one). Its CONTENT is a JSON blob for a human reading the cache, but the Stop-time gate only ever
25
+ * trusts the mtime.
26
+ *
27
+ * CONTRACT: PostToolUse-shaped hooks in this repo are advisory; this one is too — it can never
28
+ * block a prompt. It exits 0 unconditionally and writes nothing to stdout Claude/Codex would act
29
+ * on (UserPromptSubmit's silence contract). A write failure (unwritable cache dir, race, etc.) is
30
+ * swallowed: a marker that fails to write means the Stop gate later sees nothing and stays silent,
31
+ * which is fail-open in the correct direction — never a false block from a plumbing failure.
32
+ */
33
+ import fs from 'node:fs';
34
+ import os from 'node:os';
35
+ import path from 'node:path';
36
+ import { fileURLToPath } from 'node:url';
37
+ import { readStdinBounded } from './hook-input.mjs';
38
+ import { ruvnetGate1Matches } from './ruvnet-gate1-pattern.mjs';
39
+
40
+ const HOME = os.homedir();
41
+ export const MARKER_DIR = process.env.RUVNET_GROUNDING_TURN_DIR
42
+ || path.join(HOME, '.cache', 'ruvnet-brain', 'grounding-turn');
43
+
44
+ /** Filesystem-safe key for a session id — mirrors continuation-gate.mjs's own `replace(':', '-')`
45
+ * idiom, generalised: a session id is host-supplied and must never be trusted as a bare path
46
+ * segment. */
47
+ export function markerPathFor(sessionId, dir = MARKER_DIR) {
48
+ const safe = String(sessionId ?? '').replace(/[^a-zA-Z0-9_-]/g, '-').slice(0, 200);
49
+ return safe ? path.join(dir, `${safe}.json`) : null;
50
+ }
51
+
52
+ /** Exported for the unit test: pure decision, no I/O. */
53
+ export function shouldMark(hookInput) {
54
+ if (!hookInput || hookInput.hook_event_name !== 'UserPromptSubmit') return false;
55
+ if (!hookInput.session_id) return false;
56
+ const text = String(hookInput.prompt ?? hookInput.user_prompt ?? hookInput.input ?? '');
57
+ return ruvnetGate1Matches(text);
58
+ }
59
+
60
+ async function main() {
61
+ let hookInput;
62
+ try {
63
+ const raw = (await readStdinBounded()).toString('utf8');
64
+ hookInput = JSON.parse(raw || '{}');
65
+ } catch { process.exit(0); }
66
+
67
+ if (!shouldMark(hookInput)) process.exit(0);
68
+
69
+ const file = markerPathFor(hookInput.session_id);
70
+ if (!file) process.exit(0);
71
+ try {
72
+ fs.mkdirSync(path.dirname(file), { recursive: true });
73
+ fs.writeFileSync(file, JSON.stringify({
74
+ at: new Date().toISOString(),
75
+ sessionId: hookInput.session_id,
76
+ turnId: hookInput.turn_id || hookInput.prompt_id || null,
77
+ }) + '\n');
78
+ } catch { /* fail-open: no marker means the Stop gate stays silent, never a false block */ }
79
+ process.exit(0);
80
+ }
81
+
82
+ /** Never runs main() merely because a test (or anything else) imported this file for its pure
83
+ * helpers — same guard decision-gate.mjs uses, for the same reason (entrypoint-guard-safety). */
84
+ function isMain() {
85
+ try {
86
+ if (!process.argv[1]) return false;
87
+ return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
88
+ } catch { return false; }
89
+ }
90
+
91
+ if (isMain()) main();
@@ -154,6 +154,20 @@ const TABLE = {
154
154
  // 'partial' (work-ledger retained, grounding-debt bytes suppressed). Declaring 'partial' today
155
155
  // would declare a split that does not exist, which is the ceremony ADR-055 §4 refuses by name.
156
156
  'continuation-gate': { file: 'continuation-gate.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'run' },
157
+ // THE "ANSWERED WITHOUT SEARCHING" PAIR (2026-09-12). ground-ruvnet's Gate 1 is a prompt-level
158
+ // directive ("call search_ruvnet before asserting"), and a directive is advisory — nothing
159
+ // checked whether the model actually complied before the turn ended. grounding-turn-mark records
160
+ // Gate 1 firing at UserPromptSubmit (Stop's own payload carries no prompt text); grounding-turn-
161
+ // gate reads that marker at Stop and forces continuation if no search_ruvnet call was recorded in
162
+ // between (reusing grounding-stamp.sh's existing stamp evidence — see that file's own header).
163
+ // Both are mode:'advisory' for the same reason continuation-gate is: a UserPromptSubmit hook's
164
+ // silence contract has nothing to block, and Stop's own forcing mechanism is the stdout envelope,
165
+ // not the exit code. offBehavior:'silence' on both — ADR-054's own discriminator names GROUNDING
166
+ // as one of the three jobs 'silence' exists for, matching ground-ruvnet's classification exactly;
167
+ // with the brain off there is no search_ruvnet to call, so forcing this would be hostile, not
168
+ // enforcement (the same reasoning ground-before-write.sh's own BRAIN_OFF check already applies).
169
+ 'grounding-turn-mark': { file: 'grounding-turn-mark.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'silence' },
170
+ 'grounding-turn-gate': { file: 'grounding-turn-gate.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'silence' },
157
171
  };
158
172
 
159
173
  const hookId = process.argv[2];
@@ -85,6 +85,27 @@ export function validateRefreshReceiptEnvelope(receipt) {
85
85
  return { ok: true };
86
86
  }
87
87
 
88
+ /**
89
+ * An ABORTED run is not a malformed receipt. kb/refresh-run.mjs appends a phase as it completes and
90
+ * stops at the first required failure, so every aborted run's ledger is a strict prefix of its
91
+ * declared order — by design. `validateRefreshReceiptEnvelope` rightly rejects that shape (the
92
+ * two-run PROOF needs a full, successful ledger), but the health READER must not report "invalid:
93
+ * phase ledger differs from its declared contract" for a run that failed early for a stated reason
94
+ * and then throw the reason away (measured 2026-09-12: two FAILED receipts, cause "unresolved
95
+ * rollback state exists…", surfaced to nobody). Returns the failure sentence, or null when the
96
+ * receipt is not a FAILED prefix-shaped run — then the envelope validator speaks.
97
+ */
98
+ export function describeFailedRefreshRun(receipt, { reasonLimit = 200 } = {}) {
99
+ if (receipt?.status !== 'FAILED' || !Array.isArray(receipt.requiredPhaseOrder) || !Array.isArray(receipt.phases)) return null;
100
+ const declared = receipt.requiredPhaseOrder;
101
+ const ledger = receipt.phases.map((entry) => entry?.phase);
102
+ if (ledger.length > declared.length || ledger.some((phase, index) => phase !== declared[index])) return null;
103
+ if (ledger.length === 0) return `failed before its first phase (${receipt.terminalVerdict || 'unknown'})`;
104
+ const failing = [...receipt.phases].reverse().find((entry) => entry?.status !== 'PASS') || receipt.phases[receipt.phases.length - 1];
105
+ const reason = String(failing.evidence?.updateResult?.reason ?? failing.evidence?.reason ?? '').split('\n')[0].trim();
106
+ return reason ? `failed at ${failing.phase}: ${reason.slice(0, reasonLimit)}` : `failed at ${failing.phase}`;
107
+ }
108
+
88
109
  function sameExecutable(left, right) {
89
110
  try { return fs.realpathSync(left) === fs.realpathSync(right); }
90
111
  catch { return path.resolve(left || '') === path.resolve(right || ''); }
@@ -233,14 +254,22 @@ export function refreshRunHealth({ brainHome, identity = NIGHTLY_LABEL, now = Da
233
254
  ? `Nightly refresh ${receipt.runId} has a dead exact owner.`
234
255
  : `Nightly refresh ${receipt.runId} owner cannot be established exactly.`, receipt };
235
256
  }
257
+ // A FAILED run whose ledger is a prefix of its contract failed for a reason the receipt carries.
258
+ // Say where and why; "invalid" is reserved for receipts that are genuinely out of contract.
259
+ const failure = describeFailedRefreshRun(receipt);
260
+ if (failure) {
261
+ return { state: 'failed', ageHours, receipt,
262
+ evidence: `Nightly refresh ${receipt.runId} ${failure}${/[.!?]$/.test(failure) ? '' : '.'}` };
263
+ }
236
264
  const envelope = validateRefreshReceiptEnvelope(receipt);
237
265
  if (!envelope.ok) return { state: 'failed', ageHours,
238
266
  evidence: `Nightly refresh ${receipt.runId} is invalid: ${envelope.why}.`, receipt };
239
267
  if (ageHours > maxAgeHours) {
240
268
  return { state: 'stale', ageHours, evidence: `Last verified nightly refresh is ${ageHours.toFixed(1)}h old.`, receipt };
241
269
  }
242
- return { state: receipt.status === 'SUCCEEDED' ? 'ok' : 'failed', ageHours,
243
- evidence: `Last nightly refresh ${receipt.terminalVerdict} successfully ${ageHours.toFixed(1)}h ago.`, receipt };
270
+ // envelope.ok already implies status SUCCEEDED and terminalVerdict applied|noop.
271
+ return { state: 'ok', ageHours,
272
+ evidence: `Last nightly refresh ${receipt.terminalVerdict} ${ageHours.toFixed(1)}h ago; envelope verified.`, receipt };
244
273
  }
245
274
 
246
275
  export function nightlyCommand(record) {
@@ -477,10 +506,14 @@ export function schedulerStatus({ platform = process.platform, env = process.env
477
506
  const loaded = !listed.error && listed.status === 0;
478
507
  if (!loaded) return finish({ state: 'degraded', evidence: 'LaunchAgent plist exists but job is not loaded', artifact, registration });
479
508
  const exit = String(listed.stdout || '').match(/last exit code\s*=\s*(-?\d+)/i);
509
+ // `lastExitCode` lets a projector tell "loaded, verified, FIRED and failed" from the degraded
510
+ // states that genuinely cannot fire (not loaded, command drift) — the watchdog was collapsing
511
+ // both into MISSING (2026-09-12).
480
512
  if (exit && Number(exit[1]) !== 0) {
481
- return finish({ state: 'degraded', evidence: `LaunchAgent is loaded and runner digest verified, but last exited ${exit[1]}`, artifact, registration });
513
+ return finish({ state: 'degraded', lastExitCode: Number(exit[1]),
514
+ evidence: `LaunchAgent is loaded and runner digest verified, but last exited ${exit[1]}`, artifact, registration });
482
515
  }
483
- return finish({ state: 'on', evidence: exit
516
+ return finish({ state: 'on', lastExitCode: exit ? Number(exit[1]) : null, evidence: exit
484
517
  ? 'LaunchAgent is loaded, runner digest verified, and last exited cleanly'
485
518
  : 'LaunchAgent is loaded and runner digest verified; no completed run is recorded yet', artifact, registration });
486
519
  }
@@ -0,0 +1,145 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * project-progression-checkpoint.mjs — the EXPLICIT capture boundary behind /ruvnet-brain:checkpoint.
4
+ *
5
+ * The automatic boundaries (Stop, PreCompact, SessionEnd) capture what can be READ from the machine:
6
+ * git, the work ledger, the owner's note, a bounded transcript reference. They cannot read what only
7
+ * the model knows — the acceptance contract it is working to, the decision it just made and why, the
8
+ * exact next action. This entry point lets the model hand that over, once, deliberately.
9
+ *
10
+ * IT IS NOT A SECOND WRITER. Everything still goes through the producer and
11
+ * captureProjectTransition, so the snapshot is validated, redacted, fsynced to the outbox, written
12
+ * by `ruflo memory store`, and read back by its exact key before this prints a receipt. The model's
13
+ * `--json` contributes FIELDS; it never contributes a write path.
14
+ *
15
+ * The supplied fields are merged OVER the produced ones, and each supplied field is recorded in
16
+ * `provenance` as `model-checkpoint` with `authoritative: false`. A model asserting its own goal is
17
+ * a useful record and is not the same kind of fact as a line the user wrote in their ledger, and the
18
+ * snapshot must not lose that distinction on the way in.
19
+ *
20
+ * Usage:
21
+ * node project-progression-checkpoint.mjs --json '<completeProjectState fragment>' [--session <id>]
22
+ * node project-progression-checkpoint.mjs --json-file <path> [--project-dir <dir>]
23
+ */
24
+ import fs from 'node:fs';
25
+ import path from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
27
+ import { captureProjectTransition } from './project-progression-hook.mjs';
28
+ import { buildProjectProgression } from './project-progression-producer.mjs';
29
+ import { ProjectProgressionStore } from './project-progression-store.mjs';
30
+ import { resolveProjectStore } from './project-store-resolver.mjs';
31
+ import { projectDirectory } from './project-identity.mjs';
32
+
33
+ /** Fields a checkpoint may contribute. Anything else is ignored rather than silently stored. */
34
+ export const CHECKPOINT_FIELDS = Object.freeze([
35
+ 'currentGoal', 'acceptanceContract', 'nextAction', 'activeStep',
36
+ 'decisions', 'blockers', 'failures', 'completed', 'inProgress', 'plan',
37
+ 'changedFiles', 'commands', 'proofArtifacts', 'untested',
38
+ ]);
39
+
40
+ const ARRAY_FIELDS = new Set([
41
+ 'decisions', 'blockers', 'failures', 'completed', 'inProgress', 'plan',
42
+ 'changedFiles', 'commands', 'proofArtifacts', 'untested',
43
+ ]);
44
+
45
+ export function parseArgs(argv) {
46
+ const out = {};
47
+ for (let index = 0; index < argv.length; index += 1) {
48
+ const flag = argv[index];
49
+ if (!flag.startsWith('--')) continue;
50
+ out[flag.slice(2)] = argv[index + 1]?.startsWith('--') ? true : argv[index + 1];
51
+ }
52
+ return out;
53
+ }
54
+
55
+ export function readCheckpointState({ json, 'json-file': jsonFile } = {}) {
56
+ let text = json;
57
+ if (typeof jsonFile === 'string' && jsonFile) text = fs.readFileSync(jsonFile, 'utf8');
58
+ if (typeof text !== 'string' || !text.trim()) throw new Error('a checkpoint needs --json or --json-file');
59
+ let parsed;
60
+ try { parsed = JSON.parse(text); } catch { throw new Error('checkpoint state is not JSON'); }
61
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('checkpoint state must be an object');
62
+ const state = {};
63
+ for (const field of CHECKPOINT_FIELDS) {
64
+ if (!Object.prototype.hasOwnProperty.call(parsed, field)) continue;
65
+ if (ARRAY_FIELDS.has(field) && !Array.isArray(parsed[field])) throw new Error(`checkpoint ${field} must be an array`);
66
+ state[field] = parsed[field];
67
+ }
68
+ if (Object.keys(state).length === 0) {
69
+ throw new Error(`a checkpoint must set at least one of: ${CHECKPOINT_FIELDS.join(', ')}`);
70
+ }
71
+ return state;
72
+ }
73
+
74
+ export function runCheckpoint({
75
+ projectDir = projectDirectory(),
76
+ state,
77
+ host = process.env.RUVNET_HOOK_HOST || 'claude',
78
+ sessionId = process.env.CLAUDE_SESSION_ID || `checkpoint-${Date.now()}`,
79
+ produce = buildProjectProgression,
80
+ capture = captureProjectTransition,
81
+ storeFactory,
82
+ } = {}) {
83
+ const resolution = resolveProjectStore({ projectDir });
84
+ if (!fs.existsSync(path.dirname(resolution.canonicalAgentDbPath))) {
85
+ throw new Error(`this project has not adopted the canonical store (${resolution.canonicalAgentDbPath})`);
86
+ }
87
+ const payload = { session_id: sessionId, hook_event_name: 'checkpoint' };
88
+ const produced = produce({ resolution, payload, host, trigger: 'checkpoint' });
89
+
90
+ // Commit any durable-but-uncommitted snapshot first, so an explicit checkpoint also settles the
91
+ // debt SessionStart is forbidden from settling. Same ordering as the automatic boundaries.
92
+ const store = (storeFactory ?? ((options) => new ProjectProgressionStore(options)))({
93
+ projectDir, requestedStorePath: resolution.canonicalAgentDbPath,
94
+ });
95
+ let replayed = 0;
96
+ try { replayed = store.replay().length; } catch { /* the checkpoint itself is still worth writing */ }
97
+
98
+ const provenance = { ...produced.projectProgression.completeProjectState.provenance };
99
+ for (const field of Object.keys(state)) provenance[field] = { source: 'model-checkpoint', authoritative: false };
100
+
101
+ const result = capture({
102
+ host,
103
+ projectDir,
104
+ storeFactory,
105
+ payload: {
106
+ ...payload,
107
+ projectProgression: {
108
+ ...produced.projectProgression,
109
+ completeProjectState: {
110
+ ...produced.projectProgression.completeProjectState,
111
+ ...state,
112
+ provenance,
113
+ },
114
+ },
115
+ },
116
+ });
117
+ return { receipt: result.receipt, replayed, provenance, sequence: result.snapshot.sequence };
118
+ }
119
+
120
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
121
+ const args = parseArgs(process.argv.slice(2));
122
+ try {
123
+ const outcome = runCheckpoint({
124
+ state: readCheckpointState(args),
125
+ ...(typeof args['project-dir'] === 'string' ? { projectDir: args['project-dir'] } : {}),
126
+ ...(typeof args.session === 'string' ? { sessionId: args.session } : {}),
127
+ });
128
+ // THE RECEIPT IS THE POINT. It names the exact key, and it says the row was read BACK by that
129
+ // key and matched — which is the only evidence that distinguishes a checkpoint from a claim.
130
+ console.log(JSON.stringify({
131
+ checkpoint: 'stored',
132
+ eventKey: outcome.receipt.eventKey,
133
+ sequence: outcome.sequence,
134
+ payloadDigest: outcome.receipt.payloadDigest,
135
+ readbackDigest: outcome.receipt.readbackDigest,
136
+ readbackVerified: outcome.receipt.readbackDigest === outcome.receipt.payloadDigest,
137
+ alreadyStored: outcome.receipt.alreadyStored,
138
+ replayedPending: outcome.replayed,
139
+ committedAt: outcome.receipt.committedAt,
140
+ }, null, 2));
141
+ } catch (error) {
142
+ console.error(`[checkpoint] ${error.message}`);
143
+ process.exitCode = 1;
144
+ }
145
+ }
@@ -328,6 +328,22 @@ function mergeHeads(heads) {
328
328
  }
329
329
  }
330
330
 
331
+ // CARRY THE FIELDS THIS MERGE DOES NOT KNOW ABOUT (provenance, evidence, anything a later producer
332
+ // adds). The merge used to build `state` from its two hard-coded field lists alone, so every extra
333
+ // key silently VANISHED the moment a project had two heads — and provenance is exactly the kind of
334
+ // field whose disappearance turns "we guessed this from a transcript" into an unmarked fact.
335
+ const knownFields = new Set([...STATE_ARRAY_FIELDS, ...STATE_VALUE_FIELDS, 'journalHeads', 'sourceIdentity']);
336
+ const extraFields = [...new Set(heads.flatMap((head) => Object.keys(head.completeProjectState)))]
337
+ .filter((field) => !knownFields.has(field)).sort();
338
+ for (const field of extraFields) {
339
+ const values = uniqueSorted(heads.map((head) => head.completeProjectState[field] ?? null));
340
+ if (values.length === 1) state[field] = values[0];
341
+ else {
342
+ state[field] = null;
343
+ conflicts.push(conflict(field, heads, (head) => head.completeProjectState[field] ?? null));
344
+ }
345
+ }
346
+
331
347
  const sources = uniqueSorted(heads.map((head) => head.sourceIdentity));
332
348
  if (sources.length === 1) state.sourceIdentity = sources[0];
333
349
  else {
@@ -14,6 +14,9 @@ const CAPTURE_TRIGGERS = new Set([
14
14
  'SubagentStop',
15
15
  'PreCompact',
16
16
  'SessionEnd',
17
+ // The EXPLICIT boundary: /ruvnet-brain:checkpoint. It routes through this same function on
18
+ // purpose — a second writer for "the model wrote its own state" would be a second writer.
19
+ 'checkpoint',
17
20
  ]);
18
21
  const PLUGIN_JSON = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '.claude-plugin', 'plugin.json');
19
22
 
@@ -0,0 +1,252 @@
1
+ /**
2
+ * project-progression-producer.mjs — the thing that was missing.
3
+ *
4
+ * `captureProjectTransition` has always been able to store a complete project snapshot. Nothing ever
5
+ * BUILT one: it was only reachable when a host payload already carried a `projectProgression`
6
+ * extension, and no host emits one. So the North Star's "append-only full project snapshots" had a
7
+ * verified writer, a verified reader, and no producer — which is why the canonical store held zero
8
+ * rows in the project-progression namespace while 378 hand-written `project-state-current` notes
9
+ * accumulated beside it. This module is the producer.
10
+ *
11
+ * EVERY FIELD CARRIES PROVENANCE, and the order of authority is fixed:
12
+ *
13
+ * ledger the user's own work ledger — they wrote it, so it wins
14
+ * owner-note the newest `project-state-current%` note — the owner's own narrative
15
+ * prior-head the previous snapshot: sequence, parent links, carried acceptance
16
+ * git branch / HEAD / the three tree digests — mechanical, never inferred
17
+ * transcript-derived a bounded single-sentence reduction, used ONLY where nothing above spoke
18
+ *
19
+ * Anything derived from the transcript or from repository contents is marked
20
+ * `authoritative: false`. A later session reading this snapshot can then tell the difference between
21
+ * "the user said this" and "we guessed this from the tail of a conversation" — a distinction that
22
+ * disappears the moment provenance is dropped, and whose disappearance is how an inferred goal ends
23
+ * up being obeyed as an instruction.
24
+ *
25
+ * THE PRIVACY BOUNDARY. The user's prompt and the assistant's reply are never persisted. The
26
+ * transcript contributes a REFERENCE (path, byte span, record count, sha256 of the exact bytes read)
27
+ * and at most two derived sentences of DERIVED_TEXT_LIMIT each. redactProgression then runs over the
28
+ * whole snapshot inside createProgressionSnapshot, so secret-shaped material cannot survive even
29
+ * within those bounds.
30
+ */
31
+ import crypto from 'node:crypto';
32
+ import path from 'node:path';
33
+ import { digestCanonical, redactProgression, restoreProjectProgression } from './project-progression-contract.mjs';
34
+ import { readOwnerNote, readSourceIdentity, readTranscriptReference, readWorkLedger } from './project-progression-sources.mjs';
35
+ import { withProgressionReader } from './project-progression-reader.mjs';
36
+
37
+ const PROGRESSION_NAMESPACE = 'project-progression';
38
+ const OWNER_NOTE_NAMESPACES = Object.freeze(['default']);
39
+
40
+ /**
41
+ * `none` is not a filler value, it is the honest answer to "where did this come from?" when the
42
+ * answer is "nowhere — no source had one". Labelling an empty goal `git` (as the first version did,
43
+ * simply because git was the last branch in the chain) claims a provenance the field does not have,
44
+ * and provenance that can be wrong is worse than no provenance at all.
45
+ */
46
+ export const PROVENANCE_SOURCES = Object.freeze([
47
+ 'ledger', 'owner-note', 'prior-head', 'git', 'transcript-derived', 'model-checkpoint', 'none',
48
+ ]);
49
+
50
+ const AUTHORITATIVE = Object.freeze({
51
+ ledger: true,
52
+ 'owner-note': true,
53
+ 'prior-head': true,
54
+ git: true,
55
+ 'transcript-derived': false,
56
+ 'model-checkpoint': false,
57
+ none: false,
58
+ });
59
+
60
+ function marker(source) {
61
+ if (!PROVENANCE_SOURCES.includes(source)) throw new Error(`unknown provenance source: ${source}`);
62
+ return { source, authoritative: AUTHORITATIVE[source] };
63
+ }
64
+
65
+ /**
66
+ * Read the owner's `project-state-current%` notes from the canonical store WITHOUT a CLI spawn.
67
+ * The owner's convention writes them into the project's own namespace and into `default`; both are
68
+ * checked, because which one a given session used depends on whether `-n` was passed.
69
+ */
70
+ function ownerNoteRows(canonicalAgentDbPath, projectNamespace) {
71
+ const namespaces = [...new Set([projectNamespace, ...OWNER_NOTE_NAMESPACES].filter(Boolean))];
72
+ const result = withProgressionReader(canonicalAgentDbPath, (reader) => {
73
+ const rows = [];
74
+ for (const namespace of namespaces) {
75
+ for (const key of reader.listKeys(namespace)) {
76
+ if (!key.startsWith('project-state-current')) continue;
77
+ const content = reader.readContent(namespace, key);
78
+ if (typeof content === 'string') rows.push({ key, namespace, content });
79
+ }
80
+ }
81
+ return rows;
82
+ });
83
+ return result.ok ? result.value : [];
84
+ }
85
+
86
+ /** The committed heads, computed the same way a restore computes them. Empty store → no heads. */
87
+ function committedHeads(canonicalAgentDbPath, projectIdentity) {
88
+ const result = withProgressionReader(canonicalAgentDbPath, (reader) => {
89
+ const snapshots = [];
90
+ for (const key of reader.listKeys(PROGRESSION_NAMESPACE)) {
91
+ const content = reader.readContent(PROGRESSION_NAMESPACE, key);
92
+ if (typeof content !== 'string') continue;
93
+ try { snapshots.push(JSON.parse(content)); } catch { /* a malformed row is the restore's problem */ }
94
+ }
95
+ return snapshots;
96
+ });
97
+ if (!result.ok) return { heads: [], readPath: `unavailable (${result.reason})` };
98
+ const restored = restoreProjectProgression(result.value, { expectedProjectIdentity: projectIdentity });
99
+ const byKey = new Map(result.value.map((snapshot) => [snapshot?.eventKey, snapshot]));
100
+ return { heads: restored.heads.map((key) => byKey.get(key)).filter(Boolean), readPath: 'node:sqlite' };
101
+ }
102
+
103
+ function uniqueStrings(values) {
104
+ return [...new Set(values.filter((value) => typeof value === 'string' && value.trim()))];
105
+ }
106
+
107
+ /**
108
+ * Build the `projectProgression` extension for one host payload.
109
+ *
110
+ * @returns {{ projectProgression: object, provenance: object, skipped?: { reason: string } }}
111
+ * `skipped` is set when this capture would add nothing: a snapshot whose complete project state
112
+ * and source identity are byte-identical to the previous head is a no-op, and writing it would let
113
+ * three capture boundaries per session grow memory.db without bound for sessions that changed
114
+ * nothing. The caller does not write when `skipped` is present.
115
+ */
116
+ export function buildProjectProgression({
117
+ resolution,
118
+ payload = {},
119
+ host = 'claude',
120
+ env = process.env,
121
+ now = () => new Date().toISOString(),
122
+ trigger = payload.hook_event_name,
123
+ } = {}) {
124
+ if (!resolution || typeof resolution !== 'object') throw new TypeError('resolution must be a project store resolution');
125
+ const source = readSourceIdentity({ checkoutRoot: resolution.checkoutRoot, kind: resolution.kind });
126
+ const ledger = readWorkLedger({ projectId: resolution.projectIdentity.id, env });
127
+ const note = readOwnerNote(() => ownerNoteRows(resolution.canonicalAgentDbPath, path.basename(resolution.projectRoot)));
128
+ const transcript = readTranscriptReference(payload.transcript_path, { host });
129
+ const { heads } = committedHeads(resolution.canonicalAgentDbPath, resolution.projectIdentity);
130
+
131
+ const priorSequence = heads.reduce((highest, head) => Math.max(highest, head.sequence ?? 0), 0);
132
+ const priorState = heads.length === 1 ? heads[0].completeProjectState : null;
133
+
134
+ const provenance = {};
135
+ const record = (field, sourceName) => { provenance[field] = marker(sourceName); };
136
+
137
+ // GOAL — the ledger's oldest open item is what the user actually committed to; the owner note and
138
+ // the prior head come next; the transcript is the last resort and is never authoritative.
139
+ let currentGoal = ledger.open[0] ?? null;
140
+ if (currentGoal) record('currentGoal', 'ledger');
141
+ else if (typeof priorState?.currentGoal === 'string' && priorState.currentGoal) {
142
+ currentGoal = priorState.currentGoal;
143
+ record('currentGoal', 'prior-head');
144
+ } else if (transcript.derivedGoal) {
145
+ currentGoal = transcript.derivedGoal;
146
+ record('currentGoal', 'transcript-derived');
147
+ } else if (note?.excerpt) {
148
+ currentGoal = note.excerpt.split('\n')[0].slice(0, 240);
149
+ record('currentGoal', 'owner-note');
150
+ } else record('currentGoal', 'none');
151
+
152
+ // NEXT ACTION — the next open ledger item, else the assistant's own last stated step (derived).
153
+ let nextAction = ledger.open[1] ?? ledger.open[0] ?? null;
154
+ if (nextAction) record('nextAction', 'ledger');
155
+ else if (transcript.derivedNextAction) {
156
+ nextAction = transcript.derivedNextAction;
157
+ record('nextAction', 'transcript-derived');
158
+ } else if (typeof priorState?.nextAction === 'string' && priorState.nextAction) {
159
+ nextAction = priorState.nextAction;
160
+ record('nextAction', 'prior-head');
161
+ } else record('nextAction', 'none');
162
+
163
+ const decisions = [];
164
+ if (ledger.objective && typeof ledger.objective.text === 'string' && ledger.objective.text) {
165
+ decisions.push({ text: ledger.objective.text, state: ledger.objective.state ?? null, source: 'ledger' });
166
+ record('decisions', 'ledger');
167
+ } else if (note?.excerpt) {
168
+ decisions.push({ text: note.excerpt, note: note.key, truncated: note.truncated, source: 'owner-note' });
169
+ record('decisions', 'owner-note');
170
+ } else record('decisions', 'prior-head');
171
+
172
+ record('plan', ledger.present ? 'ledger' : 'prior-head');
173
+ record('completed', ledger.present ? 'ledger' : 'prior-head');
174
+ record('inProgress', ledger.present ? 'ledger' : 'prior-head');
175
+ record('changedFiles', 'git');
176
+ record('sourceIdentity', 'git');
177
+
178
+ const completeProjectState = {
179
+ currentGoal,
180
+ nextAction,
181
+ acceptanceContract: priorState?.acceptanceContract ?? null,
182
+ activeProcess: 'ProjectContinuity',
183
+ activeStep: trigger ?? 'unknown',
184
+ plan: ledger.open.map((text) => ({ id: text.slice(0, 64), status: 'open', source: 'ledger' })),
185
+ completed: uniqueStrings(ledger.done),
186
+ inProgress: uniqueStrings(ledger.open),
187
+ blockers: [],
188
+ failures: [],
189
+ decisions,
190
+ // The three digests already identify the tree exactly; enumerating paths here would duplicate
191
+ // that and, for an untracked file, would put a filename we were never asked to keep into a row.
192
+ changedFiles: [],
193
+ commands: [],
194
+ proofArtifacts: [],
195
+ untested: [],
196
+ resumeConflicts: [],
197
+ provenance,
198
+ evidence: {
199
+ workLedger: { file: ledger.file, present: ledger.present, open: ledger.open.length, done: ledger.done.length },
200
+ ownerNote: note ? { key: note.key, excerptSha256: note.excerptSha256, truncated: note.truncated } : null,
201
+ transcript: transcript.reference ?? { skipped: transcript.skipped },
202
+ sourceCapture: { headStable: source.headStable, headAfter: source.headAfter ?? source.identity.head, kind: source.kind },
203
+ },
204
+ };
205
+
206
+ // REDACT HERE, NOT ONLY AT THE STORE.
207
+ //
208
+ // createProgressionSnapshot already redacts, so the STORED row was always safe. What was not safe
209
+ // was everything between: this function's return value is passed through a hook payload, appears
210
+ // in a receipt, and is exactly the kind of object a diagnostic line prints. A secret that is
211
+ // scrubbed on the way into the database but readable on the way there has not been protected, it
212
+ // has been moved. Redacting at the point of derivation makes the store's own pass a no-op second
213
+ // check rather than the only one (redactProgression is idempotent, so running it twice is free).
214
+ const { value: redactedProgression } = redactProgression({
215
+ canonicalAgentDbPath: resolution.canonicalAgentDbPath,
216
+ sourceIdentity: source.identity,
217
+ sequence: priorSequence + 1,
218
+ occurredAt: now(),
219
+ parentEventKeys: heads.map((head) => head.eventKey),
220
+ dedupId: `${host}:${payload.session_id ?? 'unknown-session'}:${trigger ?? 'unknown'}:${priorSequence + 1}`,
221
+ completeProjectState,
222
+ });
223
+ const projectProgression = redactedProgression;
224
+
225
+ // RETENTION (ADR-073). Three capture boundaries per session times every session is unbounded
226
+ // growth unless a capture that changes nothing writes nothing. Compare what a snapshot MEANS —
227
+ // the project state and the tree it describes — while deliberately ignoring the fields that always
228
+ // differ (sequence, timestamp, dedup id, the trigger that happens to be firing, and the evidence
229
+ // block's own timestamps), because comparing those would make every capture look novel.
230
+ const meaning = digestCanonical({
231
+ state: { ...projectProgression.completeProjectState, activeStep: null, evidence: null },
232
+ source: projectProgression.sourceIdentity,
233
+ });
234
+ const priorMeaning = heads.length === 1 ? digestCanonical({
235
+ state: { ...priorState, activeStep: null, evidence: null },
236
+ source: heads[0].sourceIdentity,
237
+ }) : null;
238
+
239
+ return {
240
+ projectProgression,
241
+ provenance,
242
+ meaningDigest: meaning,
243
+ ...(priorMeaning === meaning
244
+ ? { skipped: { reason: 'no-op capture: project state and source identity are identical to the current head' } }
245
+ : {}),
246
+ };
247
+ }
248
+
249
+ /** Stable identifier for one produced snapshot, for logs and receipts. */
250
+ export function producedDigest(produced) {
251
+ return crypto.createHash('sha256').update(JSON.stringify(produced.projectProgression)).digest('hex');
252
+ }