ruvnet-brain 4.3.20 → 4.3.25
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/README.md +5 -5
- package/bin/install.mjs +383 -78
- package/console/app.js +141 -9
- package/console/index.html +51 -24
- package/console/scope.css +137 -0
- package/console/scope.html +144 -0
- package/console/scope.js +209 -0
- package/console/tips.html +1 -0
- package/kb/corpus-release-identity.mjs +239 -0
- package/kb/update-storage-transaction.mjs +20 -3
- package/package.json +9 -2
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/checkpoint.md +61 -0
- package/plugin/hooks/codex-hooks.json +64 -1
- package/plugin/hooks/hook-contracts.json +299 -6
- package/plugin/hooks/hooks.json +81 -1
- package/plugin/mcp/server.mjs +23 -0
- package/plugin/scripts/advocacy-catalog.mjs +245 -0
- package/plugin/scripts/advocacy-route.mjs +460 -0
- package/plugin/scripts/continuation-gate.mjs +25 -2
- package/plugin/scripts/continuation-objective.mjs +7 -1
- package/plugin/scripts/continuity-hook-policy.mjs +190 -15
- package/plugin/scripts/coverage-integrity.mjs +7 -0
- package/plugin/scripts/gates.mjs +113 -10
- package/plugin/scripts/grounding-turn-gate.mjs +167 -0
- package/plugin/scripts/grounding-turn-mark.mjs +91 -0
- package/plugin/scripts/hook-shim.mjs +14 -0
- package/plugin/scripts/host-shell-boundary.mjs +43 -0
- package/plugin/scripts/nightly-scheduler.mjs +37 -4
- package/plugin/scripts/project-progression-checkpoint.mjs +145 -0
- package/plugin/scripts/project-progression-contract.mjs +16 -0
- package/plugin/scripts/project-progression-hook.mjs +3 -0
- package/plugin/scripts/project-progression-producer.mjs +252 -0
- package/plugin/scripts/project-progression-reader.mjs +271 -0
- package/plugin/scripts/project-progression-session-start.mjs +93 -16
- package/plugin/scripts/project-progression-sources.mjs +220 -0
- package/plugin/scripts/project-progression-store.mjs +106 -13
- package/plugin/scripts/ruvnet-gate1-pattern.mjs +29 -0
- package/plugin/scripts/session-snapshot-hook.mjs +115 -7
- package/plugin/scripts/session-start-budget.mjs +59 -0
- package/plugin/scripts/session-start-core.mjs +234 -457
- package/plugin/scripts/session-start-fsutil.mjs +61 -0
- package/plugin/scripts/session-start-health.mjs +64 -0
- package/plugin/scripts/session-start-hook-description.mjs +45 -0
- package/plugin/scripts/session-start-issue-alert.mjs +77 -0
- package/plugin/scripts/session-start-repo-identity.mjs +54 -0
- package/plugin/scripts/session-start-signals.mjs +73 -0
- package/plugin/scripts/session-start-trace.mjs +86 -0
- package/plugin/scripts/session-start-update-plane.mjs +104 -0
- package/plugin/scripts/unprompted-runtime.mjs +32 -2
- package/plugin/scripts/update-apply.mjs +2 -32
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +26 -2
- package/plugin/skills/ruvnet-brain/SKILL.md +67 -2
- package/scripts/adr-072-completion.mjs +1 -1
- package/scripts/agentdb-fleet-doctor.mjs +5 -1
- package/scripts/approved-runtime.mjs +197 -0
- package/scripts/brain-novice-50.mjs +16 -1
- package/scripts/brain-score.mjs +23 -5
- package/scripts/build-bundle.mjs +971 -530
- package/scripts/build-concepts.mjs +36 -116
- package/scripts/console-engine.test.mjs +8 -7
- package/scripts/console-runtime-identity.mjs +4 -0
- package/scripts/corpus-aggregates.mjs +94 -77
- package/scripts/corpus-candidate.mjs +475 -222
- package/scripts/corpus-next-seed.mjs +225 -0
- package/scripts/corpus-promotion.mjs +58 -0
- package/scripts/corpus-reconcile.mjs +411 -105
- package/scripts/doc-currency.mjs +16 -1
- package/scripts/dual-host-deliberation.mjs +25 -2
- package/scripts/dual-host-suggest.mjs +17 -1
- package/scripts/falsify.mjs +13 -3
- package/scripts/gist-receipts.mjs +482 -87
- package/scripts/github-health-watch.mjs +12 -2
- package/scripts/handoff-asset.mjs +34 -0
- package/scripts/hook-retirement-check.mjs +8 -1
- package/scripts/host-registry.mjs +1 -1
- package/scripts/ingest-gists.mjs +74 -101
- package/scripts/job-heartbeat.sh +77 -14
- package/scripts/learning-replay-execution.mjs +10 -4
- package/scripts/nightly-gists.sh +27 -13
- package/scripts/nightly-two-run-proof.mjs +1 -1
- package/scripts/nightly-watchdog.mjs +61 -4
- package/scripts/onboarding-console.mjs +319 -27
- package/scripts/oracle/produce-questions.mjs +293 -0
- package/scripts/oracle/producer-hosts.mjs +235 -0
- package/scripts/oracle/repo-recall.mjs +448 -0
- package/scripts/oracle/retrieval-accuracy.mjs +818 -0
- package/scripts/oracle/source-tree.mjs +165 -0
- package/scripts/oracle/source-units.mjs +391 -0
- package/scripts/oracle/spike-run.mjs +98 -0
- package/scripts/oracle/unit-inventory.mjs +141 -0
- package/scripts/oracle/unit-sampling.mjs +128 -0
- package/scripts/oracle/validate-labels.mjs +250 -0
- package/scripts/private-overlay.mjs +248 -0
- package/scripts/product-integrity-contract.mjs +1 -1
- package/scripts/proxy/claude-proxied.sh +6 -0
- package/scripts/proxy/proxy-revert.sh +5 -0
- package/scripts/proxy/proxy-up.sh +6 -0
- package/scripts/proxy/proxy-verify.mjs +4 -0
- package/scripts/public-inputs.mjs +409 -0
- package/scripts/public-verification-inputs.mjs +112 -26
- package/scripts/public-verification-lane.mjs +1 -1
- package/scripts/published-surface-probe.mjs +34 -4
- package/scripts/qe/card-lane-gate.mjs +16 -1
- package/scripts/qe/session-start-gate.mjs +16 -1
- package/scripts/rebuild-gists-from-receipts.mjs +58 -78
- package/scripts/record-lesson.mjs +4 -1
- package/scripts/rehearse-corpus-pipeline.mjs +994 -0
- package/scripts/release-abort-stale.mjs +5 -1
- package/scripts/release-authority.mjs +104 -12
- package/scripts/release-channel-kind.mjs +86 -0
- package/scripts/release-convergence-watchdog.mjs +7 -2
- package/scripts/release-projection.mjs +177 -72
- package/scripts/release-transaction-provider.mjs +23 -6
- package/scripts/release.mjs +252 -17
- package/scripts/retrieval-canary.mjs +87 -0
- package/scripts/rvf-index-audit.mjs +573 -13
- package/scripts/rvf-wire.mjs +269 -0
- package/scripts/seal-gist-receipt.mjs +65 -0
- package/scripts/selfcheck.mjs +42 -21
- package/scripts/source-coverage.mjs +253 -24
- package/scripts/status-honesty.mjs +25 -0
- package/scripts/sync-census.mjs +0 -0
- package/scripts/sync-version.mjs +2 -0
- package/scripts/trismart.mjs +42 -0
- package/scripts/updater-manifest.mjs +162 -0
- package/scripts/verify-channels.mjs +17 -5
- package/scripts/wired-check.mjs +48 -10
- package/tri-smart-skill/QUICKSTART.md +37 -0
- package/tri-smart-skill/README.md +92 -0
- package/tri-smart-skill/install.cmd +14 -0
- package/tri-smart-skill/install.command +13 -0
- package/tri-smart-skill/install.mjs +51 -0
- package/tri-smart-skill/install.sh +9 -0
- package/tri-smart-skill/tri-smart/SKILL.md +90 -0
- package/tri-smart-skill/tri-smart/evals/evals.json +25 -0
- package/tri-smart-skill/tri-smart/references/protocol.md +25 -0
- package/tri-smart-skill/tri-smart/references/provider-cli.md +18 -0
- package/tri-smart-skill/tri-smart/scripts/review.mjs +154 -0
- package/tri-smart-skill/tri-smart/scripts/setup.mjs +97 -0
- package/tri-smart-skill/tri-smart/scripts/verify-access.mjs +107 -0
- 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];
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// host-shell-boundary.mjs — one definition of the files a running host freezes at boot.
|
|
2
|
+
//
|
|
3
|
+
// Body files are reached through the Stable Spine and can change in place. These paths are the
|
|
4
|
+
// host declarations or boot-loaded surfaces whose change must be reported honestly to a running
|
|
5
|
+
// Claude/Codex session. Both the updater and the installer consume this one list so they cannot
|
|
6
|
+
// disagree about whether a release needs a restart.
|
|
7
|
+
import crypto from 'node:crypto';
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
export const SHELL_PATHS = Object.freeze([
|
|
12
|
+
'hooks/hooks.json',
|
|
13
|
+
'scripts/hook-shim.mjs',
|
|
14
|
+
'scripts/hook-shim-bash.mjs',
|
|
15
|
+
'scripts/development-maintenance.mjs',
|
|
16
|
+
'mcp/server.mjs',
|
|
17
|
+
'.mcp.json',
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
function treeDigest(root, relative) {
|
|
21
|
+
const target = path.join(root, relative);
|
|
22
|
+
let stat;
|
|
23
|
+
try { stat = fs.lstatSync(target); } catch { return '<missing>'; }
|
|
24
|
+
if (stat.isSymbolicLink()) return '<symlink>';
|
|
25
|
+
if (stat.isFile()) return `file:${crypto.createHash('sha256').update(fs.readFileSync(target)).digest('hex')}`;
|
|
26
|
+
if (!stat.isDirectory()) return `<special:${stat.mode}>`;
|
|
27
|
+
const entries = fs.readdirSync(target, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
|
|
28
|
+
return `dir:${entries.map((entry) => `${entry.name}:${treeDigest(target, entry.name)}`).join('|')}`;
|
|
29
|
+
}
|
|
30
|
+
/** Return the boot-loaded paths that differ between two plugin payload roots. */
|
|
31
|
+
export function shellDiff(prevRootAbs, nextRootAbs) {
|
|
32
|
+
if (!prevRootAbs || !nextRootAbs) throw new Error('both plugin roots are required for shell comparison');
|
|
33
|
+
const changed = [];
|
|
34
|
+
for (const rel of SHELL_PATHS) {
|
|
35
|
+
if (treeDigest(prevRootAbs, rel) !== treeDigest(nextRootAbs, rel)) changed.push(rel);
|
|
36
|
+
}
|
|
37
|
+
// skills/ and commands/ are markdown surfaces loaded at host boot. Any file-set or content
|
|
38
|
+
// difference is a boot-surface change, even though they are not executable hook declarations.
|
|
39
|
+
for (const dir of ['skills', 'commands']) {
|
|
40
|
+
if (treeDigest(prevRootAbs, dir) !== treeDigest(nextRootAbs, dir)) changed.push(`${dir}/`);
|
|
41
|
+
}
|
|
42
|
+
return changed;
|
|
43
|
+
}
|
|
@@ -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
|
-
|
|
243
|
-
|
|
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',
|
|
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
|
+
}
|