ruvnet-brain 4.3.37 → 4.3.38

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 (35) hide show
  1. package/README.md +2 -2
  2. package/bin/install.mjs +32 -9
  3. package/kb/forge-update.mjs +1867 -0
  4. package/package.json +3 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/hooks/hook-contracts.json +5 -5
  8. package/plugin/scripts/capability-claim-evidence.mjs +11 -2
  9. package/plugin/scripts/capacity-aware-parallel-work.mjs +71 -15
  10. package/plugin/scripts/completion-claim-evidence.mjs +262 -0
  11. package/plugin/scripts/continuation-gate.mjs +105 -21
  12. package/plugin/scripts/continuation-objective.mjs +15 -0
  13. package/plugin/scripts/decision-gate.mjs +22 -2
  14. package/plugin/scripts/duplicate-gate.mjs +503 -0
  15. package/plugin/scripts/grounding-turn-evidence.mjs +339 -0
  16. package/plugin/scripts/grounding-turn-gate.mjs +77 -25
  17. package/plugin/scripts/grounding-turn-mark.mjs +61 -14
  18. package/plugin/scripts/hook-input.mjs +15 -0
  19. package/plugin/scripts/host-update.mjs +45 -0
  20. package/plugin/scripts/nightly-scheduler.mjs +34 -0
  21. package/plugin/scripts/session-start-budget.mjs +1 -0
  22. package/plugin/scripts/session-start-core.mjs +15 -2
  23. package/plugin/scripts/session-start-health.mjs +101 -1
  24. package/plugin/scripts/session-start-update-plane.mjs +71 -1
  25. package/scripts/completion-claim-replay.mjs +114 -0
  26. package/scripts/corpus-canary.mjs +399 -0
  27. package/scripts/corpus-dispatch-decision.mjs +2 -2
  28. package/scripts/corpus-promotion.mjs +49 -0
  29. package/scripts/corpus-reconcile.mjs +27 -3
  30. package/scripts/corpus-watchdog.mjs +45 -6
  31. package/scripts/duplicate-gate-replay.mjs +98 -0
  32. package/scripts/grounding-turn-replay.mjs +131 -0
  33. package/scripts/nightly-watchdog.mjs +3 -3
  34. package/scripts/release.mjs +177 -74
  35. package/scripts/wired-check.mjs +2 -0
@@ -36,6 +36,51 @@ if (process.argv.includes('--check')) {
36
36
  }
37
37
 
38
38
  const npx = process.platform === 'win32' ? 'npx.cmd' : 'npx';
39
+
40
+ // --knowledge <attemptFile> <lockFile>: the SessionStart knowledge self-heal worker (launched by
41
+ // session-start-update-plane.mjs under detach.mjs's TTL). Unlike the default mode below it DOES
42
+ // update knowledge: the same argv bin/nightly-refresh.mjs runs, through the same env boundary.
43
+ // Offline (registry unreachable) is recorded as 'offline' so the next session retries in 30 min
44
+ // without reporting a failure. Every other outcome is recorded for the next session to speak.
45
+ const knowledgeAt = process.argv.indexOf('--knowledge');
46
+ if (knowledgeAt >= 0) {
47
+ const fs = await import('node:fs');
48
+ const [attemptFile, lockFile] = process.argv.slice(knowledgeAt + 1);
49
+ const record = (fields) => {
50
+ try {
51
+ let prior = {};
52
+ try { prior = JSON.parse(fs.readFileSync(attemptFile, 'utf8')); } catch { /* first write */ }
53
+ const tmp = `${attemptFile}.tmp-${process.pid}`;
54
+ fs.writeFileSync(tmp, `${JSON.stringify({ ...prior, ...fields, finishedAt: new Date().toISOString() })}\n`);
55
+ fs.renameSync(tmp, attemptFile);
56
+ } catch { /* the next session reports a launch with no outcome as a failure */ }
57
+ };
58
+ try {
59
+ const probe = process.env.RUVNET_AUTO_UPDATE_PROBE_URL || 'https://registry.npmjs.org/ruvnet-brain/latest';
60
+ let online = false;
61
+ try { online = (await fetch(probe, { signal: AbortSignal.timeout(5_000) })).ok; } catch { /* offline */ }
62
+ if (!online) {
63
+ record({ outcome: 'offline', code: null, reason: `registry unreachable (${probe})` });
64
+ } else {
65
+ const run = spawnSync(npx, ['--yes', 'ruvnet-brain@latest', '--update', '--no-nightly-prompt'], {
66
+ env: childEnvironment(), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
67
+ timeout: Number(process.env.RUVNET_KNOWLEDGE_UPDATE_TIMEOUT_MS || 25 * 60_000),
68
+ });
69
+ process.stdout.write(run.stdout || '');
70
+ process.stderr.write(run.stderr || '');
71
+ // eslint-disable-next-line no-control-regex
72
+ const tail = `${run.stderr || ''}\n${run.stdout || ''}`.replace(/\u001b\[[0-9;]*m/g, '')
73
+ .split('\n').map((line) => line.trim()).filter(Boolean);
74
+ const code = run.error ? null : run.status;
75
+ record({ outcome: code === 0 ? 'succeeded' : 'failed', code,
76
+ reason: code === 0 ? '' : String(run.error?.message || tail.find((l) => /✗|error|fail/i.test(l)) || tail.at(-1) || 'no output').slice(0, 200) });
77
+ }
78
+ } finally {
79
+ try { fs.rmSync(lockFile, { force: true }); } catch { /* stale-lock rule reclaims it */ }
80
+ }
81
+ process.exit(0);
82
+ }
83
+
39
84
  const result = spawnSync(npx, [
40
85
  '--yes',
41
86
  'ruvnet-brain@latest',
@@ -272,6 +272,40 @@ export function refreshRunHealth({ brainHome, identity = NIGHTLY_LABEL, now = Da
272
272
  evidence: `Last nightly refresh ${receipt.terminalVerdict} ${ageHours.toFixed(1)}h ago; envelope verified.`, receipt };
273
273
  }
274
274
 
275
+ /**
276
+ * Every refresh receipt (nightly AND manual --update), newest first, reduced to what a user needs
277
+ * to know: the newest run, the newest SUCCESS (envelope-verified, the same validator the proof
278
+ * uses), and how many runs failed since that success. Read-only, no spawn. An unreadable receipt
279
+ * is counted, never skipped silently.
280
+ */
281
+ export function refreshHistory({ brainHome }) {
282
+ let names = [];
283
+ try { names = fs.readdirSync(path.join(brainHome, 'refresh-runs')).filter((n) => n.endsWith('.json')); }
284
+ catch { return { receipts: 0, unreadable: 0, latest: null, lastSuccess: null, failuresSinceSuccess: 0 }; }
285
+ const rows = [];
286
+ let unreadable = 0;
287
+ for (const name of names) {
288
+ try {
289
+ const r = JSON.parse(fs.readFileSync(path.join(brainHome, 'refresh-runs', name), 'utf8'));
290
+ const at = Date.parse(r?.finishedAt || r?.startedAt || '');
291
+ if (r?.kind === 'ruvnet-brain-refresh-run' && Number.isFinite(at)) rows.push({ at, receipt: r });
292
+ else unreadable += 1;
293
+ } catch { unreadable += 1; }
294
+ }
295
+ rows.sort((a, b) => b.at - a.at);
296
+ const successIndex = rows.findIndex(({ receipt }) => validateRefreshReceiptEnvelope(receipt).ok);
297
+ const sinceSuccess = successIndex < 0 ? rows : rows.slice(0, successIndex);
298
+ return { receipts: rows.length, unreadable, latest: rows[0] || null,
299
+ lastSuccess: successIndex < 0 ? null : rows[successIndex],
300
+ failuresSinceSuccess: sinceSuccess.filter(({ receipt }) => receipt.status === 'FAILED').length };
301
+ }
302
+
303
+ /** agentic-kit owns this machine's Brain updates (`ak sync`), so the Brain's own scheduler is off on purpose. */
304
+ export function updateOwnedByAgenticKit(home = os.homedir()) {
305
+ try { return JSON.parse(fs.readFileSync(path.join(home, '.config', 'agentic-kit', 'kit.json'), 'utf8')).ruvnetBrain === true; }
306
+ catch { return false; }
307
+ }
308
+
275
309
  export function nightlyCommand(record) {
276
310
  return `${shellQuote(record.nodePath)} ${shellQuote(record.runnerPath)} --registration ${shellQuote(record.recordPath)}`;
277
311
  }
@@ -42,6 +42,7 @@ export const STAGE_BUDGETS_MS = {
42
42
  'issue-pointer': 1100,
43
43
  'signal-surface': 400, // bounded CI-signal transition poll (see session-start-signals.mjs)
44
44
  'router-nudge': 50, // one fs.existsSync + at-most-one-time write
45
+ 'knowledge-currency': 100, // refresh receipts + registration + SOURCE.json reads; no spawn, no network
45
46
  'stable-spine': 300, // seed-dispatch decision + a single detach launch
46
47
  heartbeat: 300, // update-check dispatch launch
47
48
  'ascii-drift': 300, // optional ascii->svg drift advisory, already spawnSync-timeout bounded
@@ -32,8 +32,8 @@ import {
32
32
  } from './session-start-fsutil.mjs';
33
33
  import { maintainerIssueEntitlement, surfaceIssuePointer } from './session-start-issue-alert.mjs';
34
34
  import { surfaceSignals } from './session-start-signals.mjs';
35
- import { brainState, health, mcpReadiness } from './session-start-health.mjs';
36
- import { stableSpine, heartbeat } from './session-start-update-plane.mjs';
35
+ import { brainState, health, knowledgeCurrency, mcpReadiness, KNOWLEDGE_LINE_PREFIX } from './session-start-health.mjs';
36
+ import { stableSpine, heartbeat, knowledgeAutoUpdate } from './session-start-update-plane.mjs';
37
37
  import { describeLifecycleHooks, readHookContracts } from './session-start-hook-description.mjs';
38
38
  import { createStageTracer } from './session-start-trace.mjs';
39
39
 
@@ -79,6 +79,7 @@ export async function runSessionStart({
79
79
  || s.startsWith('[RuvNet Brain — HEALTH ALARM')
80
80
  || s.startsWith('[RuvNet Brain — INSTALL ALARM')
81
81
  || s.startsWith('[RuvNet Brain — NIGHTLY FAILED')
82
+ || s.startsWith(KNOWLEDGE_LINE_PREFIX)
82
83
  || s.startsWith('[RuvNet Brain — OPEN ISSUES')
83
84
  || /\bopen issue\(s\)/i.test(s)
84
85
  || /^\[RuvNet Brain — external signal/i.test(s)
@@ -206,6 +207,18 @@ export async function runSessionStart({
206
207
  return result;
207
208
  }) || { problem: '', absentByChoice: false };
208
209
 
210
+ // ONE line when the installed knowledge cannot be PROVEN current (old, failing, or UNKNOWN);
211
+ // silent when a refresh or --check proved it inside 48h. Skipped when the brain is off (a choice)
212
+ // or already down (the HEALTH ALARM above owns that).
213
+ // Knowledge self-heal first (a detached, throttled --update when nothing proves currency inside
214
+ // 24h), so the line below already says an update is running.
215
+ tracer.stage('knowledge-currency', () => {
216
+ if (brain.off || state.problem) return;
217
+ knowledgeAutoUpdate({ env, home, now, hookDir, emit });
218
+ const line = knowledgeCurrency({ env, home, now });
219
+ if (line) emit(line);
220
+ });
221
+
209
222
  tracer.stage('misc', () => {
210
223
  const consoleOffered = path.join(stateDir, '.console-offered');
211
224
  const firstSession = !brain.off && !exists(consoleOffered);
@@ -10,7 +10,10 @@
10
10
  // worded for the user — never gated behind the maintainer entitlement file.
11
11
  import fs from 'node:fs';
12
12
  import path from 'node:path';
13
- import { json, exists, mtimeMs } from './session-start-fsutil.mjs';
13
+ import { json, exists, mtimeMs, read } from './session-start-fsutil.mjs';
14
+ import {
15
+ describeFailedRefreshRun, readNightlyRegistration, refreshHistory, updateOwnedByAgenticKit,
16
+ } from './nightly-scheduler.mjs';
14
17
 
15
18
  export const brainState = (env, home) => {
16
19
  const stateDir = env.RUVNET_BRAIN_STATE_DIR || path.join(home, '.config', 'ruvnet-brain');
@@ -49,6 +52,103 @@ export const health = (home, off) => {
49
52
  return { problem: '', absentByChoice };
50
53
  };
51
54
 
55
+ /**
56
+ * KNOWLEDGE CURRENCY — "never silent for 40 days" (owner, 2026-09-30). The installed knowledge base
57
+ * on the owner's Mac was 35 days old with the nightly refresh failing 22/22 times, and the only
58
+ * trace was a "Corpus snapshot ages" footnote inside search output. This returns ONE plain line, or
59
+ * '' when currency is PROVEN. Reuses the existing readers only: refresh receipts
60
+ * (nightly-scheduler.mjs refreshHistory + describeFailedRefreshRun), the scheduler registration
61
+ * (readNightlyRegistration), agentic-kit ownership, SOURCE.json builtUtc, and the heartbeat's
62
+ * recorded --check verdict. Currency is PROVEN only by a successful refresh or a CURRENT verdict
63
+ * inside 48h; anything unreadable stays UNKNOWN in the words, never "current".
64
+ */
65
+ export const KNOWLEDGE_LINE_PREFIX = '[RuvNet Brain — KNOWLEDGE ';
66
+
67
+ // The SessionStart knowledge auto-update's own per-machine state (session-start-update-plane.mjs
68
+ // writes it, host-update.mjs --knowledge records the outcome). Deliberately NOT inside refresh-runs/:
69
+ // kb/lifecycle-evidence-retention.mjs scanRefresh() marks any non-receipt entry there "unsafe" and
70
+ // then refuses to prune, so a sidecar file would silently stop receipt retention.
71
+ export const autoUpdatePaths = (brainHome) => ({
72
+ attemptFile: path.join(brainHome, 'auto-update.json'),
73
+ lockFile: path.join(brainHome, 'auto-update.lock'),
74
+ logFile: path.join(brainHome, '.last-auto-update-knowledge.log'),
75
+ });
76
+ export const AUTO_UPDATE_LOCK_STALE_MS = 35 * 60_000; // detach TTL (30 min) + slack
77
+
78
+ /** The inputs every currency decision reads — one reader, shared by the line and the auto-update. */
79
+ export const knowledgeFacts = ({ env = process.env, home, now = Date.now() } = {}) => {
80
+ const brainHome = env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain');
81
+ const kbDir = env.RUVNET_BRAIN_KB || path.join(brainHome, 'kb');
82
+ const hours = (ms) => (now - ms) / 3_600_000;
83
+ const source = json(path.join(kbDir, 'SOURCE.json'));
84
+ const builtMs = Date.parse(source?.builtUtc || source?.generatedAt || '');
85
+ const history = refreshHistory({ brainHome });
86
+ const check = json(path.join(brainHome, '.last-kb-check-result.json'));
87
+ const checkMs = Date.parse(check?.recordedAt || '');
88
+ const auto = autoUpdatePaths(brainHome);
89
+ const provenWithin = (h) => Boolean((history.lastSuccess && hours(history.lastSuccess.at) <= h)
90
+ || (check?.currencyVerdict === 'CURRENT' && Number.isFinite(checkMs) && hours(checkMs) <= h));
91
+ return { brainHome, kbDir, source, builtMs, history, hours, provenWithin, auto,
92
+ attempt: json(auto.attemptFile),
93
+ lockMs: Date.parse(json(auto.lockFile)?.at || '') || mtimeMs(auto.lockFile) };
94
+ };
95
+
96
+ /** Why the SessionStart knowledge auto-update may NEVER run on this machine ('' = it may). */
97
+ export const autoUpdateOptOut = ({ env = process.env, home, facts }) => {
98
+ const flag = String(env.RUVNET_AUTO_UPDATE || '').toLowerCase();
99
+ if (flag === 'off') return 'RUVNET_AUTO_UPDATE=off';
100
+ if (env.RUVNET_BRAIN_TEST === '1' && flag !== 'on') return 'test mode';
101
+ if (read(path.join(facts.brainHome, '.auto-update-pref')).trim() === 'no') return 'you answered no to background auto-update';
102
+ if (updateOwnedByAgenticKit(home)) return 'agentic-kit owns updates: ak sync';
103
+ if (!exists(path.join(facts.kbDir, 'forge-update.mjs'))) return 'this install predates the self-updater';
104
+ return '';
105
+ };
106
+
107
+ export const knowledgeCurrency = ({ env = process.env, home, now = Date.now(), windowHours = 48 } = {}) => {
108
+ const facts = knowledgeFacts({ env, home, now });
109
+ const { brainHome, builtMs, history, hours, attempt } = facts;
110
+ const day = (ms) => new Date(ms).toISOString().slice(0, 16).replace('T', ' ') + 'Z';
111
+ const age = (ms) => { const h = hours(ms); return h < 48 ? `${Math.round(h)}h ago` : `${Math.round(h / 24)}d ago`; };
112
+ const proven = facts.provenWithin(windowHours);
113
+ const latest = history.latest?.receipt;
114
+ // An automatic update that ended without a refresh receipt of its own (npx could not fetch the
115
+ // package, the lock was held, it was killed at its TTL) is a failure the receipts cannot show.
116
+ const launchedMs = Date.parse(attempt?.launchedAt || '');
117
+ const autoRunning = facts.lockMs > 0 && now - facts.lockMs < AUTO_UPDATE_LOCK_STALE_MS;
118
+ const autoAbandoned = attempt?.outcome === 'launched' && !autoRunning;
119
+ const autoFailed = Number.isFinite(launchedMs) && (attempt.outcome === 'failed' || autoAbandoned)
120
+ && !(history.latest && history.latest.at >= launchedMs);
121
+ const failing = latest?.status === 'FAILED' || autoFailed;
122
+ const ageKnown = Number.isFinite(builtMs);
123
+ if (!failing && proven) return '';
124
+ if (!failing && ageKnown && hours(builtMs) <= windowHours) return '';
125
+ const agentKit = updateOwnedByAgenticKit(home);
126
+ const scheduled = agentKit || readNightlyRegistration({ brainHome }).ok;
127
+ const parts = [ageKnown ? `knowledge base built ${day(builtMs)} (${age(builtMs)})`
128
+ : 'knowledge base age UNKNOWN (SOURCE.json missing or unreadable)'];
129
+ if (autoFailed) {
130
+ const why = autoAbandoned ? 'it never recorded an outcome (killed at its 30-minute limit, or the machine slept)'
131
+ : `exit ${attempt.code ?? 'unknown'}: ${attempt.reason || 'no reason recorded'}`;
132
+ parts.push(`automatic update launched ${age(launchedMs)} FAILED — ${why}`);
133
+ } else if (latest?.status === 'FAILED') {
134
+ const why = describeFailedRefreshRun(latest) || 'failed';
135
+ parts.push(`last refresh (${latest.action || 'unknown'}) FAILED ${age(history.latest.at)}: ${why}`);
136
+ }
137
+ if (autoRunning) parts.push(`an automatic update is running now (started ${age(facts.lockMs)})`);
138
+ parts.push(history.receipts
139
+ ? `${history.failuresSinceSuccess} failed run(s) since the last success (${history.lastSuccess ? day(history.lastSuccess.at) : 'none recorded'})`
140
+ : 'no refresh has ever run on this machine');
141
+ if (!scheduled) parts.push('no nightly refresh is scheduled');
142
+ if (history.unreadable) parts.push(`${history.unreadable} unreadable receipt(s)`);
143
+ const fix = agentKit ? 'ak sync' : scheduled ? 'npx ruvnet-brain@latest --update'
144
+ : 'npx ruvnet-brain@latest --update && npx ruvnet-brain --enable-nightly';
145
+ const optOut = autoUpdateOptOut({ env, home, facts });
146
+ if (optOut) parts.push(`automatic update is off (${optOut})`);
147
+ else if (!autoRunning) parts.push('SessionStart retries the update automatically at most every 6h');
148
+ const head = failing ? 'UPDATE FAILING' : ageKnown ? 'STALE' : 'CURRENCY UNKNOWN';
149
+ return `${KNOWLEDGE_LINE_PREFIX}${head}] ${parts.join('; ')}. Fix: ${fix} (verify: npx ruvnet-brain --doctor).`;
150
+ };
151
+
52
152
  export const mcpReadiness = (env, home) => {
53
153
  const brainHome = env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain');
54
154
  const receipt = json(path.join(brainHome, 'mcp-readiness.json'));
@@ -4,8 +4,78 @@
4
4
  // 500 lines. `announceVersion` was intentionally NOT migrated here: it was dead code (defined, never
5
5
  // called, never tested) and was deleted rather than relocated — moving unused code to a new module
6
6
  // would still be scope creep, just in a different file.
7
+ import fs from 'node:fs';
7
8
  import path from 'node:path';
8
- import { read, write, exists, json, firstVersion, dispatchDetached } from './session-start-fsutil.mjs';
9
+ import { spawn } from 'node:child_process';
10
+ import { read, write, exists, json, firstVersion, dispatchDetached, mtimeMs } from './session-start-fsutil.mjs';
11
+ import { KNOWLEDGE_LINE_PREFIX, AUTO_UPDATE_LOCK_STALE_MS, autoUpdateOptOut, knowledgeFacts } from './session-start-health.mjs';
12
+
13
+ /**
14
+ * KNOWLEDGE SELF-HEAL (owner invariant 2026-09-30: no brain may ever be more than 48h old). The
15
+ * nightly scheduler is opt-in and can silently never register, so this does not depend on it: when
16
+ * a session starts and nothing proves the knowledge current inside 24h, launch ONE detached, bounded
17
+ * `npx ruvnet-brain@latest --update --no-nightly-prompt` (the exact argv bin/nightly-refresh.mjs
18
+ * runs) through the existing host-update.mjs credential boundary and detach.mjs TTL supervisor.
19
+ * Per machine (all state under brainHome), at most one launch per 6h (30 min after an offline
20
+ * probe), never while a refresh lock or another launch is live, never blocks (spawn + unref, no
21
+ * wait). The outcome is recorded by the worker and spoken by the NEXT session's knowledge line.
22
+ */
23
+ export const AUTO_UPDATE_POLICY = Object.freeze({ staleHours: 24, retryHours: 6, offlineRetryMinutes: 30, ttlSec: 1800 });
24
+
25
+ const writeJsonAtomic = (file, value) => {
26
+ const tmp = `${file}.tmp-${process.pid}`;
27
+ try { fs.writeFileSync(tmp, `${JSON.stringify(value)}\n`); fs.renameSync(tmp, file); return true; }
28
+ catch { try { fs.rmSync(tmp, { force: true }); } catch { /* ignore */ } return false; }
29
+ };
30
+
31
+ export const knowledgeAutoUpdate = ({ env, home, now, hookDir, emit = () => {}, spawnFn = spawn }) => {
32
+ const facts = knowledgeFacts({ env, home, now });
33
+ const { attemptFile, lockFile, logFile } = facts.auto;
34
+ const attempt = facts.attempt;
35
+ const hoursAgo = (iso) => (now - Date.parse(iso || '')) / 3_600_000;
36
+ if (attempt?.outcome === 'succeeded' && !attempt.reported) {
37
+ const built = facts.builtMs;
38
+ const tag = facts.source?.releaseTag ? `corpus ${facts.source.releaseTag}` : 'the latest corpus';
39
+ emit(`${KNOWLEDGE_LINE_PREFIX}UPDATED] automatic update finished ${Math.round(hoursAgo(attempt.finishedAt))}h ago: ${tag}, `
40
+ + `knowledge base built ${Number.isFinite(built) ? `${Math.round(facts.hours(built))}h ago` : 'at an UNKNOWN time'}.`);
41
+ writeJsonAtomic(attemptFile, { ...attempt, reported: true });
42
+ }
43
+ const optOut = autoUpdateOptOut({ env, home, facts });
44
+ if (optOut) return { launched: false, why: optOut };
45
+ const fresh = facts.provenWithin(AUTO_UPDATE_POLICY.staleHours)
46
+ || (Number.isFinite(facts.builtMs) && facts.hours(facts.builtMs) <= AUTO_UPDATE_POLICY.staleHours);
47
+ if (fresh) return { launched: false, why: 'fresh' };
48
+ // A nightly or manual --update holds this lock (kb/refresh-run.mjs refreshLockPath, duplicated here
49
+ // because the plugin payload cannot import kb/); --update would refuse anyway, this saves the launch.
50
+ if (exists(path.join(path.dirname(facts.kbDir), `.${path.basename(facts.kbDir)}.refresh-run.lock`))) {
51
+ return { launched: false, why: 'refresh running' };
52
+ }
53
+ const since = hoursAgo(attempt?.launchedAt);
54
+ const retryHours = attempt?.outcome === 'offline' ? AUTO_UPDATE_POLICY.offlineRetryMinutes / 60 : AUTO_UPDATE_POLICY.retryHours;
55
+ if (since >= 0 && since < retryHours) return { launched: false, why: 'throttled' };
56
+ // Cross-process: exactly one session wins the O_EXCL create; a lock older than the TTL is stale.
57
+ const claim = () => { try { fs.writeFileSync(lockFile, `${JSON.stringify({ pid: process.pid, at: new Date(now).toISOString() })}\n`, { flag: 'wx' }); return true; } catch { return false; } };
58
+ if (!claim()) {
59
+ const lockMs = Date.parse(json(lockFile)?.at || '') || mtimeMs(lockFile);
60
+ if (!(lockMs > 0 && now - lockMs > AUTO_UPDATE_LOCK_STALE_MS)) return { launched: false, why: 'locked' };
61
+ try { fs.rmSync(lockFile, { force: true }); } catch { /* raced */ }
62
+ if (!claim()) return { launched: false, why: 'locked' };
63
+ }
64
+ writeJsonAtomic(attemptFile, { schemaVersion: 1, launchedAt: new Date(now).toISOString(), outcome: 'launched' });
65
+ try {
66
+ const child = spawnFn(process.execPath, [path.join(hookDir, 'detach.mjs'), String(AUTO_UPDATE_POLICY.ttlSec), logFile,
67
+ process.execPath, path.join(hookDir, 'host-update.mjs'), '--knowledge', attemptFile, lockFile],
68
+ { detached: true, stdio: 'ignore', env, windowsHide: true });
69
+ child.on?.('error', () => {});
70
+ child.unref?.();
71
+ } catch (error) {
72
+ writeJsonAtomic(attemptFile, { schemaVersion: 1, launchedAt: new Date(now).toISOString(), outcome: 'failed',
73
+ code: null, reason: `could not launch: ${error.message}`, finishedAt: new Date(now).toISOString() });
74
+ try { fs.rmSync(lockFile, { force: true }); } catch { /* ignore */ }
75
+ return { launched: false, why: 'launch failed' };
76
+ }
77
+ return { launched: true, why: 'stale' };
78
+ };
9
79
 
10
80
  export const compareVersions = (a, b) => {
11
81
  const left = String(a).split(/[.-]/).map((part) => (/^\d+$/.test(part) ? Number(part) : part));
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * completion-claim-replay.mjs — measure the completion-claim gate and promise capture against REAL
4
+ * Claude Code transcripts, read-only (ADR-074 acceptance #5: "Measure Stop latency and false-positive
5
+ * rate").
6
+ *
7
+ * It replays every turn of every top-level `*.jsonl` under the transcript root through the SAME pure
8
+ * functions continuation-gate.mjs calls at Stop (plugin/scripts/completion-claim-evidence.mjs), and
9
+ * reports how often the gate would ARM (a completion claim is present), BLOCK (FAIL verdict) and
10
+ * capture a PROMISE. It never writes into the transcript root; `--out` writes a JSONL sample of
11
+ * blocks and promises for a human to label true/false by eye.
12
+ *
13
+ * node scripts/completion-claim-replay.mjs [--root ~/.claude/projects] [--limit 40] [--out file.jsonl]
14
+ *
15
+ * Turn = records from one genuine user message to the next; the turn's final assistant text stands
16
+ * in for the Stop payload's `last_assistant_message`. That approximation is disclosed in the report.
17
+ */
18
+ import fs from 'node:fs';
19
+ import os from 'node:os';
20
+ import path from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { auditCompletionClaims, claudeTurnEvents, extractCommitments } from '../plugin/scripts/completion-claim-evidence.mjs';
23
+
24
+ const argv = process.argv.slice(2);
25
+ const opt = (flag, fallback) => { const i = argv.indexOf(flag); return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback; };
26
+
27
+ // The transcript walkers below are exported for scripts/grounding-turn-replay.mjs (one turn model,
28
+ // two gates); main() runs only when this file is the entrypoint.
29
+ const textOf = (c) => (typeof c === 'string' ? c : Array.isArray(c)
30
+ ? c.filter((x) => x?.type === 'text' && typeof x.text === 'string').map((x) => x.text).join('\n') : '');
31
+
32
+ export function transcripts(root, limit) {
33
+ const files = [];
34
+ for (const dir of fs.readdirSync(root)) {
35
+ const full = path.join(root, dir);
36
+ try {
37
+ for (const name of fs.readdirSync(full)) {
38
+ if (!name.endsWith('.jsonl')) continue;
39
+ const file = path.join(full, name);
40
+ const st = fs.statSync(file);
41
+ if (st.isFile() && st.size > 20_000) files.push({ file, mtime: st.mtimeMs });
42
+ }
43
+ } catch { /* not a directory */ }
44
+ }
45
+ return files.sort((a, b) => b.mtime - a.mtime).slice(0, limit).map((f) => f.file);
46
+ }
47
+
48
+ export function turnsOf(lines) {
49
+ const starts = [];
50
+ lines.forEach((l, i) => {
51
+ try {
52
+ const o = JSON.parse(l);
53
+ const c = o?.message?.content;
54
+ const toolResult = Array.isArray(c) && c.some((x) => x?.type === 'tool_result');
55
+ if (o?.type === 'user' && !o.isSidechain && !o.isMeta && !toolResult && textOf(c).trim()) starts.push(i);
56
+ } catch { /* torn */ }
57
+ });
58
+ return starts.map((s, k) => lines.slice(s, starts[k + 1] ?? lines.length));
59
+ }
60
+
61
+ export function finalText(turnLines) {
62
+ for (let i = turnLines.length - 1; i >= 0; i -= 1) {
63
+ try {
64
+ const o = JSON.parse(turnLines[i]);
65
+ if (o?.type === 'assistant' && !o.isSidechain) { const t = textOf(o.message?.content).trim(); if (t) return t; }
66
+ } catch { /* torn */ }
67
+ }
68
+ return '';
69
+ }
70
+
71
+ function main() {
72
+ const root = path.resolve(opt('--root', path.join(os.homedir(), '.claude', 'projects')));
73
+ const limit = Number(opt('--limit', '40'));
74
+ const out = opt('--out', null);
75
+ const report = { root, transcripts: 0, turns: 0, armed: 0, blocked: 0, passed: 0, promises: 0, turnsWithPromise: 0,
76
+ reasons: {}, auditMs: [] };
77
+ const samples = [];
78
+ for (const file of transcripts(root, limit)) {
79
+ report.transcripts += 1;
80
+ const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
81
+ for (const turnLines of turnsOf(lines)) {
82
+ const message = finalText(turnLines);
83
+ if (!message) continue;
84
+ report.turns += 1;
85
+ const t0 = performance.now();
86
+ const audit = auditCompletionClaims(message, { turn: claudeTurnEvents(turnLines) });
87
+ report.auditMs.push(performance.now() - t0);
88
+ const promises = extractCommitments(message);
89
+ if (promises.length) { report.turnsWithPromise += 1; report.promises += promises.length; }
90
+ if (audit.verdict !== 'NONE') report.armed += 1;
91
+ if (audit.verdict === 'PASS') report.passed += 1;
92
+ if (audit.verdict === 'FAIL') {
93
+ report.blocked += 1;
94
+ for (const p of audit.problems) { const k = p.replace(/\(.*\)/, '').trim(); report.reasons[k] = (report.reasons[k] || 0) + 1; }
95
+ }
96
+ if (audit.verdict === 'FAIL' || promises.length) {
97
+ samples.push({ file: path.basename(file), verdict: audit.verdict, claims: audit.claims.map((c) => c.text),
98
+ problems: audit.problems, promises: promises.map((p) => p.text), tail: message.slice(-700) });
99
+ }
100
+ }
101
+ }
102
+ const ms = report.auditMs.sort((a, b) => a - b);
103
+ const summary = { ...report, auditMs: undefined,
104
+ armRate: report.turns ? +(report.armed / report.turns).toFixed(3) : 0,
105
+ blockRate: report.turns ? +(report.blocked / report.turns).toFixed(3) : 0,
106
+ promiseTurnRate: report.turns ? +(report.turnsWithPromise / report.turns).toFixed(3) : 0,
107
+ auditP50Ms: +(ms[Math.floor(ms.length / 2)] || 0).toFixed(2), auditP99Ms: +(ms[Math.floor(ms.length * 0.99)] || 0).toFixed(2),
108
+ approximation: 'final assistant text of each turn stands in for last_assistant_message' };
109
+ if (out) fs.writeFileSync(out, samples.map((s) => JSON.stringify(s)).join('\n') + '\n');
110
+ console.log(JSON.stringify(summary, null, 2));
111
+ }
112
+
113
+ const isMain = (() => { try { return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } })();
114
+ if (isMain) main();