ruvnet-brain 4.0.36 → 4.2.2-dev

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 (68) hide show
  1. package/README.md +28 -5
  2. package/bin/install.mjs +405 -23
  3. package/data/model-catalog.json +104 -15
  4. package/package.json +2 -1
  5. package/plugin/.claude-plugin/plugin.json +2 -2
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/commands/brain-console.md +72 -9
  8. package/plugin/commands/configure.md +67 -21
  9. package/plugin/commands/rvcb.md +72 -9
  10. package/plugin/hooks/codex-hooks.json +40 -33
  11. package/plugin/hooks/hook-contracts.json +14 -24
  12. package/plugin/hooks/hooks.json +7 -42
  13. package/plugin/mcp/server.mjs +23 -6
  14. package/plugin/scripts/adr-currency-gate.mjs +150 -0
  15. package/plugin/scripts/capability-registry.mjs +10 -1
  16. package/plugin/scripts/codex-hook-adapter.mjs +121 -19
  17. package/plugin/scripts/codex-hook-wrapper.mjs +61 -4
  18. package/plugin/scripts/continuation-gate.mjs +148 -8
  19. package/plugin/scripts/decision-gate.mjs +428 -0
  20. package/plugin/scripts/decision-outcomes.mjs +231 -0
  21. package/plugin/scripts/degradation-watch.mjs +271 -0
  22. package/plugin/scripts/ground-ruvnet.sh +51 -11
  23. package/plugin/scripts/hijack-ruvnet.sh +11 -3
  24. package/plugin/scripts/hook-registry.mjs +48 -3
  25. package/plugin/scripts/hook-shim.mjs +58 -3
  26. package/plugin/scripts/identifier-preflight.mjs +134 -0
  27. package/plugin/scripts/learn-capture.sh +50 -1
  28. package/plugin/scripts/learn-flush.mjs +5 -5
  29. package/plugin/scripts/lesson-bridge.mjs +343 -0
  30. package/plugin/scripts/lesson-hooks.sh +26 -0
  31. package/plugin/scripts/lesson-promote.mjs +50 -0
  32. package/plugin/scripts/lesson-store.mjs +6 -1
  33. package/plugin/scripts/mcp-readiness.mjs +107 -0
  34. package/plugin/scripts/protect-brain-state.sh +9 -0
  35. package/plugin/scripts/runtime-preferences.mjs +40 -0
  36. package/plugin/scripts/session-snapshot-hook.mjs +15 -6
  37. package/plugin/scripts/spend-guard.mjs +125 -0
  38. package/plugin/scripts/unprompted-runtime.mjs +12 -2
  39. package/plugin/scripts/update-apply.mjs +7 -2
  40. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +20 -5
  41. package/plugin/skills/ruvnet-brain/SKILL.md +3 -3
  42. package/scripts/brain-score.mjs +261 -0
  43. package/scripts/brain-stamp.mjs +5 -1
  44. package/scripts/build-bundle.mjs +38 -4
  45. package/scripts/card-from-source.mjs +114 -0
  46. package/scripts/console-engine.mjs +1 -1
  47. package/scripts/corpus-candidate.mjs +294 -0
  48. package/scripts/corpus-reconcile.mjs +273 -0
  49. package/scripts/corpus-seed-publish.mjs +110 -0
  50. package/scripts/health-repair.mjs +11 -2
  51. package/scripts/ingest-new-repos.mjs +122 -0
  52. package/scripts/ingest-repo.mjs +66 -6
  53. package/scripts/learning-replay-cli.mjs +8 -3
  54. package/scripts/learning-replay-fixture.mjs +25 -6
  55. package/scripts/learning-replay-proof.mjs +38 -0
  56. package/scripts/nightly-wrapper.sh +55 -7
  57. package/scripts/onboarding-console.mjs +21 -1
  58. package/scripts/org-repo-count.mjs +119 -0
  59. package/scripts/rebuild-gists-from-receipts.mjs +246 -0
  60. package/scripts/release-transaction.mjs +30 -2
  61. package/scripts/release.mjs +147 -0
  62. package/scripts/repo-count-detector.mjs +62 -0
  63. package/scripts/restore-local-ingests.mjs +116 -0
  64. package/scripts/rvf-generation.mjs +44 -0
  65. package/scripts/selfcheck.mjs +9 -1
  66. package/scripts/stabilization-receipt.mjs +11 -1
  67. package/scripts/sync-census.mjs +0 -0
  68. package/scripts/sync-commands.mjs +117 -0
@@ -2,13 +2,18 @@
2
2
  import fs from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
- import { spawnSync } from 'node:child_process';
5
+ import { spawn, spawnSync } from 'node:child_process';
6
6
 
7
7
  const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
8
8
  const brainHome = process.env.RUVNET_BRAIN_HOME
9
9
  || path.join(path.dirname(codexHome), '.cache', 'ruvnet-brain');
10
10
  const versions = path.join(brainHome, 'versions');
11
11
  const blockingHooks = new Set([
12
+ // decision-gate composes every refusal policy into ONE verdict and speaks the same exit-2 +
13
+ // stderr contract Codex documents ("PreToolUse hook exited with code 2 but did not write a
14
+ // blocking reason to stderr"). Absent from this set it would be wired and toothless: the exit 2
15
+ // would be swallowed here and every refusal would silently become an allow.
16
+ 'decision-gate',
12
17
  'route-dispatch',
13
18
  'hijack-ruvnet', // ADR-063: opt-in managed-memory refusal; exits 0 at the default
14
19
  'ground-before-write',
@@ -17,10 +22,38 @@ const blockingHooks = new Set([
17
22
  'unprompted-speech',
18
23
  ]);
19
24
 
25
+ /**
26
+ * HOOKS WHOSE WORK OUTLIVES ANY BUDGET THE HOST WILL GRANT, so waiting for them is not an option.
27
+ *
28
+ * MEASURED 2026-08-14 on a live Codex 0.147.0 session — the host prints it itself:
29
+ *
30
+ * warning: clamping SessionEnd hook timeout to 3s in .../hooks.json
31
+ *
32
+ * SessionEnd is HARD-CAPPED at 3 seconds and no registration can ask for more. learn-flush needs
33
+ * 18s: measured with a real 10-entry queue and the real `ruflo`, 18342 / 18347 / 18384 ms, because
34
+ * one `ruflo hooks` call alone costs seconds and it self-bounds at LEARN_FLUSH_DEADLINE_MS.
35
+ *
36
+ * So the arithmetic never closes, and what shipped was the worst version of that: this wrapper
37
+ * budgeted 2250ms, SIGKILLed the flush, and exited 0 with `status === null` — measured end to end,
38
+ * exit 0, 2760ms, ZERO bytes of stdout, ZERO bytes of stderr, and a 10-line queue still 10 lines
39
+ * afterwards. Codex lessons never flushed, on every session, and nothing on any surface said so.
40
+ *
41
+ * The host caps how long it will WAIT, not how long work may take. Detaching honours the cap
42
+ * exactly — the hook returns immediately — while the flush runs to its own deadline and does its
43
+ * own write-back. Raising the number instead would have been a fiction the host silently clamps.
44
+ */
45
+ const DETACHED_HOOKS = new Set(['learn-flush']);
46
+
47
+ /** THE BUDGET IS DERIVED FROM WHAT THE HOOK MEASURABLY COSTS, never from what looks tidy. */
20
48
  function timeoutFor(hookId) {
21
49
  const override = Number(process.env.RUVNET_CODEX_HOOK_TIMEOUT_MS);
22
50
  if (Number.isFinite(override) && override > 0) return override;
23
- if (hookId === 'learn-flush') return 2_250;
51
+ // decision-gate's own internal budget is 4000ms (RUVNET_DECISION_BUDGET_MS) and it is allowed to
52
+ // spend all of it: measured 986–4015ms across ten runs in a project this plugin does not own. A
53
+ // budget at or below the gate's own would make the gate's slowest legitimate refusal indis-
54
+ // tinguishable from a crash; 6000ms covers the gate's cap plus this chain's spawn overhead
55
+ // (measured 773–1145ms end-to-end warm, so ~150–400ms of that is the wrapper/adapter/shim).
56
+ if (hookId === 'decision-gate') return 6_000;
24
57
  if (hookId === 'ground-ruvnet' || hookId === 'unprompted-speech' || hookId === 'continuation-gate') {
25
58
  return 8_500;
26
59
  }
@@ -119,11 +152,35 @@ const adapter = root && path.join(root, 'scripts', 'codex-hook-adapter.mjs');
119
152
  if (!adapter || !fs.existsSync(adapter)) process.exit(0);
120
153
 
121
154
  const hookId = process.argv[2] || '';
155
+
156
+ if (DETACHED_HOOKS.has(hookId)) {
157
+ // stdio ignored on purpose: a detached child outlives this process, so anything it wrote would
158
+ // arrive at a host that has already moved on — and stderr from a hook is what a host renders as a
159
+ // hook error. It reports through its own channels or not at all.
160
+ const child = spawn(process.execPath, [adapter, ...process.argv.slice(2)], {
161
+ detached: true, stdio: ['pipe', 'ignore', 'ignore'], env: process.env,
162
+ });
163
+ child.unref();
164
+ child.stdin.on('error', () => { /* a dead optional child must never surface to the host */ });
165
+ // AWAIT THE FLUSH, don't assume it. `process.exit()` on the line after `end()` truncates the pipe
166
+ // and the detached child reads an empty payload — which is the same silent no-op this whole change
167
+ // exists to remove, reintroduced one layer down. Bounded so a stuck pipe cannot hold the host.
168
+ await new Promise((resolve) => {
169
+ try { child.stdin.end(input, resolve); } catch { resolve(); }
170
+ setTimeout(resolve, 250);
171
+ });
172
+ process.exit(0);
173
+ }
174
+
175
+ const budgetMs = timeoutFor(hookId);
122
176
  const result = spawnSync(process.execPath, [adapter, ...process.argv.slice(2)], {
123
177
  input,
124
178
  encoding: 'utf8',
125
- env: process.env,
126
- timeout: timeoutFor(hookId),
179
+ // The adapter may fan one multi-file patch out into several body runs, and only this process knows
180
+ // when the axe falls. Hand the budget down so it can stop and fail open instead of being killed
181
+ // mid-loop with nothing written — a SIGKILL here is invisible to the host and to the user.
182
+ env: { ...process.env, RUVNET_CODEX_BUDGET_MS: String(budgetMs) },
183
+ timeout: budgetMs,
127
184
  killSignal: 'SIGKILL',
128
185
  });
129
186
 
@@ -70,6 +70,97 @@ function projectKey() {
70
70
  const LEDGER = process.env.RUVNET_WORK_LEDGER
71
71
  || path.join(HOME, '.config', 'ruvnet-brain', 'work-ledgers', `${projectKey()}.json`);
72
72
 
73
+ /**
74
+ * THE SAME PARTITION, APPLIED TO THE DERIVED SOURCES — added 2026-08-14 after this file was caught
75
+ * doing exactly what projectKey() above exists to prevent, one layer down.
76
+ *
77
+ * The ledger has been partitioned per project since day one, for the reason stated above: a
78
+ * commitment made in one repo must never fire in another. Every artifact-derived source added later
79
+ * (issues, red CI, open PRs, security alerts) read a MACHINE-GLOBAL file under ~/.cache/ruvnet-brain
80
+ * with no such partition. So the explicit half was scoped and the derived half was not, and the
81
+ * derived half is the half that is always populated.
82
+ *
83
+ * MEASURED, not supposed. Firing Stop in a fresh git repo whose remote is
84
+ * `someone-else/totally-unrelated`, with an EMPTY ledger:
85
+ * {"hookSpecificOutput":{"hookEventName":"Stop","additionalContext":"You have unfinished work you
86
+ * committed to. … ☐ PR #137 on stuinfla/ruvnet-brain is RED …"}}
87
+ * `additionalContext` at Stop CONTINUES THE TURN, so a stranger's every turn-end was being forced
88
+ * with orders to go fix another repository's pull requests. The forensic trail was already on this
89
+ * machine before anyone looked: ~/.config/ruvnet-brain/work-ledgers/AppealArmor.json holds ZERO
90
+ * items, yet AppealArmor.json.cooldown exists — and that lock is written ONLY on the path that
91
+ * forces. The gate forced a continuation in AppealArmor on 2026-08-13T21:39Z with nothing of
92
+ * AppealArmor's to say. Same for Ruv-Explainer, T, verify-prod and notgit.
93
+ *
94
+ * THE RULE: a derived item may only ever speak about a repository THIS working tree points at.
95
+ * Ownership is read from the git remotes, which is the only durable statement of "which repo is
96
+ * this" available at a Stop boundary — no network, no `gh`, no spawn.
97
+ *
98
+ * FAIL CLOSED ON SCOPE, FAIL OPEN ON BEHAVIOUR. Not a git repo, no remotes, unreadable config →
99
+ * we cannot confirm ownership, so the derived sources contribute NOTHING and the gate exits 0 in
100
+ * silence. It never blocks, never errors, never speaks about a repo it cannot prove is ours. The
101
+ * work LEDGER is untouched by all of this: a real commitment recorded in this project still forces
102
+ * exactly as before, which is the one behaviour that must never be weakened.
103
+ */
104
+ function gitConfigPath(start) {
105
+ let dir = start;
106
+ for (let i = 0; i < 12; i++) {
107
+ const dot = path.join(dir, '.git');
108
+ try {
109
+ const st = fs.statSync(dot);
110
+ if (st.isDirectory()) return path.join(dot, 'config');
111
+ if (st.isFile()) {
112
+ // A linked worktree / submodule: `.git` is a file naming the real gitdir. The remotes live
113
+ // in the COMMON dir (…/.git), not in …/.git/worktrees/<name>, so follow `commondir` when
114
+ // it is present. Getting this wrong would silently return "owns nothing" in a worktree.
115
+ const m = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(dot, 'utf8'));
116
+ if (!m) return null;
117
+ const gitdir = path.resolve(dir, m[1].trim());
118
+ try {
119
+ const common = fs.readFileSync(path.join(gitdir, 'commondir'), 'utf8').trim();
120
+ return path.join(path.resolve(gitdir, common), 'config');
121
+ } catch { return path.join(gitdir, 'config'); }
122
+ }
123
+ } catch { /* no .git here — keep walking up */ }
124
+ const up = path.dirname(dir);
125
+ if (up === dir) return null;
126
+ dir = up;
127
+ }
128
+ return null;
129
+ }
130
+
131
+ /** `https://github.com/Owner/Name.git`, `git@github.com:Owner/Name`, `Owner/Name` → `owner/name`. */
132
+ function slugOf(raw) {
133
+ const s = String(raw ?? '').trim().replace(/\/+$/, '').replace(/\.git$/i, '');
134
+ if (!s) return null;
135
+ const tail = s
136
+ .replace(/^[a-z][a-z0-9+.-]*:\/\/[^/]+\//i, '') // scheme://host/
137
+ .replace(/^[^/@]+@[^:/]+:/, ''); // user@host:
138
+ const parts = tail.split('/').filter(Boolean);
139
+ return parts.length >= 2 ? parts.slice(-2).join('/').toLowerCase() : null;
140
+ }
141
+
142
+ function ownedRepoSlugs() {
143
+ const owned = new Set();
144
+ try {
145
+ const cfg = gitConfigPath(process.cwd());
146
+ if (!cfg) return owned; // no git → owns nothing → derived sources mute
147
+ const text = fs.readFileSync(cfg, 'utf8');
148
+ // Every remote, not just origin: a fork legitimately owns both its origin and its upstream, and
149
+ // the artifacts name the upstream slug.
150
+ for (const m of text.matchAll(/^\s*url\s*=\s*(.+)$/gm)) {
151
+ const slug = slugOf(m[1]);
152
+ if (slug) owned.add(slug);
153
+ }
154
+ } catch { /* unreadable config → owns nothing */ }
155
+ return owned;
156
+ }
157
+ const OWNED_REPOS = ownedRepoSlugs();
158
+ /** True only when `repo` names a repository THIS working tree actually points at. */
159
+ const ownsRepo = (repo) => {
160
+ const slug = slugOf(repo);
161
+ return Boolean(slug) && OWNED_REPOS.has(slug);
162
+ };
163
+
73
164
  const argv = process.argv.slice(2);
74
165
  const arg = (f) => { const i = argv.indexOf(f); return i >= 0 && argv[i + 1] ? argv[i + 1] : null; };
75
166
  const has = (f) => argv.includes(f);
@@ -187,6 +278,7 @@ function artifactOpenWork() {
187
278
  const file = process.env.RUVNET_OPEN_ISSUES_FILE
188
279
  || path.join(HOME, '.cache', 'ruvnet-brain', 'open-issues.json');
189
280
  const status = JSON.parse(fs.readFileSync(file, 'utf8'));
281
+ if (!ownsRepo(status?.repo)) return []; // another repo's backlog is not this project's work
190
282
  const observedAt = Date.parse(status?.at || '');
191
283
  if (!Number.isFinite(observedAt) || nowMs - observedAt > 6 * 3600_000) return [];
192
284
  return (Array.isArray(status.issues) ? status.issues : [])
@@ -221,6 +313,9 @@ function redCiOpenWork() {
221
313
  const status = JSON.parse(fs.readFileSync(file, 'utf8'));
222
314
  const seen = new Set();
223
315
  return Object.values(status || {})
316
+ // Per ENTRY, not per file: this artifact genuinely mixes repositories (measured on this
317
+ // machine — stuinfla/ruvnet-brain and stuinfla/AppealArmor rows sit side by side in it).
318
+ .filter((d) => ownsRepo(d?.repo))
224
319
  .filter((d) => d?.state === 'resolved' && d.conclusion && d.conclusion !== 'success')
225
320
  .filter((d) => {
226
321
  const at = Date.parse(d.checkedAt || '');
@@ -267,6 +362,7 @@ function openPrWork() {
267
362
  const file = process.env.RUVNET_OPEN_ISSUES_FILE
268
363
  || path.join(HOME, '.cache', 'ruvnet-brain', 'open-issues.json');
269
364
  const status = JSON.parse(fs.readFileSync(file, 'utf8'));
365
+ if (!ownsRepo(status?.repo)) return []; // never send a stranger to merge our pull requests
270
366
  const observedAt = Date.parse(status?.at || '');
271
367
  if (!Number.isFinite(observedAt) || nowMs - observedAt > 6 * 3600_000) return [];
272
368
  return (Array.isArray(status.prs) ? status.prs : [])
@@ -306,6 +402,7 @@ function securityAlertWork() {
306
402
  const file = process.env.RUVNET_OPEN_ISSUES_FILE
307
403
  || path.join(HOME, '.cache', 'ruvnet-brain', 'open-issues.json');
308
404
  const status = JSON.parse(fs.readFileSync(file, 'utf8'));
405
+ if (!ownsRepo(status?.repo)) return []; // GitHub emailed the OWNER of that repo, not this one
309
406
  const observedAt = Date.parse(status?.at || '');
310
407
  if (!Number.isFinite(observedAt) || nowMs - observedAt > 6 * 3600_000) return [];
311
408
  const alerts = (Array.isArray(status.securityAlerts) ? status.securityAlerts : [])
@@ -362,12 +459,20 @@ if (!open.length) process.exit(EXIT_ALLOW); // nothing outstanding: silence is
362
459
  const forceable = open.filter((i) => Number.isFinite(Date.parse(i.at)));
363
460
  if (!forceable.length) process.exit(EXIT_ALLOW);
364
461
 
365
- /** Age, only ever used to LABEL an item — never to suppress one. See the note above. */
462
+ /**
463
+ * Age, only ever used to LABEL an item — never to suppress one. See the note above.
464
+ *
465
+ * The VERB is part of the honesty, not decoration. A ledger row was `committed` to — the model
466
+ * agreed to it, and that agreement is what makes stopping a defect. A derived row was `observed`:
467
+ * its `at` is when the watcher last looked at GitHub, and nobody promised anything. Printing
468
+ * "committed 2h ago" against a Dependabot PR states an agreement that was never made.
469
+ */
366
470
  const ageLabel = (i) => {
367
471
  const h = (nowMs - Date.parse(i.at)) / 3_600_000;
368
472
  if (h < 1) return '';
369
- if (h < 24) return ` (committed ${Math.round(h)}h ago)`;
370
- return ` (committed ${Math.round(h / 24)}d ago — still open)`;
473
+ const verb = i.derived ? 'observed' : 'committed';
474
+ if (h < 24) return ` (${verb} ${Math.round(h)}h ago)`;
475
+ return ` (${verb} ${Math.round(h / 24)}d ago — still open)`;
371
476
  };
372
477
 
373
478
  /**
@@ -395,17 +500,49 @@ if (!claimCooldown(nowMs, COOLDOWN_MS)) process.exit(EXIT_ALLOW);
395
500
  * DELIVERY. `additionalContext` in a Stop envelope forces the continuation (same protection as
396
501
  * decision:block). Directive copy — continue, do not look for an exit.
397
502
  */
503
+ /**
504
+ * SAY WHAT THESE ACTUALLY ARE (2026-08-14). One header served both halves and it said "You have
505
+ * unfinished work you committed to" — true of a ledger row, FALSE of every derived one. Nobody
506
+ * committed to Dependabot's PR; a watcher observed it. Under a gate that forces the turn to
507
+ * continue, that sentence does not merely misdescribe the item, it manufactures an obligation and
508
+ * attributes it to the reader, which is the fabrication this whole project exists to refuse.
509
+ *
510
+ * So the two kinds are named separately and never merged into one claim.
511
+ */
512
+ const committed = forceable.filter((i) => !i.derived);
513
+ const observed = forceable.filter((i) => i.derived);
514
+ // Every derived item names its own repo in its text; this is for the header, where the ONE repo
515
+ // this tree points at is the honest thing to say.
516
+ const repoLabel = [...OWNED_REPOS][0] || 'this repository';
517
+
518
+ const header = committed.length && observed.length
519
+ ? [`You have unfinished work you committed to, and ${repoLabel} has open work of its own.`,
520
+ 'Do NOT end the turn — continue now.']
521
+ : committed.length
522
+ ? ['You have unfinished work you committed to. Do NOT end the turn — continue now.']
523
+ : [`${repoLabel} has open work: this is the repository's own current state as last observed —`,
524
+ 'a breached issue, a red build, an unmerged PR or a security alert — NOT something you',
525
+ 'committed to. Do NOT end the turn — continue now.'];
526
+
398
527
  const lines = [
399
- 'You have unfinished work you committed to. Do NOT end the turn — continue now.',
528
+ ...header,
400
529
  'Pick the highest-leverage open item below and make real progress on it this turn. Stop only when',
401
530
  'EVERY item is genuinely done or blocked; if one is blocked, say why in a single line and move to',
402
531
  'the next — never stop on the first obstacle, and never manufacture a reason to go quiet.',
403
532
  '',
404
- // Age is LABELLED, never used to suppress — an item open for days is the one most worth naming.
405
- ...forceable.slice(0, 8).map((i) => ` ☐ ${i.text}${ageLabel(i)}`),
533
+ // Committed first, then observed: the promise outranks the backlog. Age is LABELLED, never used
534
+ // to suppress — an item open for days is the one most worth naming.
535
+ ...[...committed, ...observed].slice(0, 8).map((i) => ` ☐ ${i.text}${ageLabel(i)}`),
406
536
  ...(forceable.length > 8 ? [` … and ${forceable.length - 8} more`] : []),
407
537
  '',
408
- 'Mark each item done as you complete it: node plugin/scripts/continuation-gate.mjs --done "<exact item text>"',
538
+ // Only the ledger has a --done. A derived item clears by DOING the thing (merge it, fix the
539
+ // build, answer the issue, patch the advisory) and the next watcher run stops reporting it —
540
+ // which is the point of deriving it rather than remembering it. Offering --done for one would be
541
+ // offering a way to mark a red build finished without fixing it.
542
+ ...(committed.length
543
+ ? ['Mark each item done as you complete it: node plugin/scripts/continuation-gate.mjs --done "<exact item text>"']
544
+ : ['These clear by being done, not by being marked: merge or fix the PR, get the build green,',
545
+ 'answer the issue, patch the advisory. The next observation stops listing them.']),
409
546
  // THE HONEST EXIT, and it is what makes forcing old items safe.
410
547
  //
411
548
  // Fable's red-team #3 was right that a stale item pressuring every turn "breeds
@@ -417,7 +554,10 @@ const lines = [
417
554
  // So the resolution is neither silence nor endless nagging: keep forcing, and name the honest
418
555
  // disposal out loud. An item that is genuinely dead gets cleared — a deliberate, recorded act —
419
556
  // instead of expiring on a timer nobody sees, or being falsely marked done to stop the noise.
420
- ...(forceable.some((i) => (nowMs - Date.parse(i.at)) > 24 * 3_600_000)
557
+ // COMMITTED items only. A derived item is at most 6h old by construction (the freshness window),
558
+ // and "clear it, that is a legitimate answer" is advice about a promise — you cannot clear a red
559
+ // build by declaring it no longer real.
560
+ ...(committed.some((i) => (nowMs - Date.parse(i.at)) > 24 * 3_600_000)
421
561
  ? ['', 'Some of these are days old. If one is genuinely no longer real, say so and CLEAR it —',
422
562
  'that is a legitimate answer and the right one. What is never acceptable is marking it done',
423
563
  'without doing it, or letting it age quietly out of view.']