create-claude-cabinet 0.52.0 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/lib/cli.js +2 -2
  2. package/package.json +1 -1
  3. package/templates/CLAUDE.md +164 -20
  4. package/templates/cabinet/watchtower-contracts.md +202 -10
  5. package/templates/mux/bin/mux +5 -0
  6. package/templates/mux/config/mux-server.py +7 -5
  7. package/templates/scripts/__tests__/category-expiry.test.mjs +130 -0
  8. package/templates/scripts/__tests__/detector-registry.test.mjs +41 -7
  9. package/templates/scripts/__tests__/extraction-classification.test.mjs +87 -0
  10. package/templates/scripts/__tests__/friction-actionability.test.mjs +180 -0
  11. package/templates/scripts/__tests__/memory-budget-debt.test.mjs +264 -0
  12. package/templates/scripts/__tests__/ring2-pattern-gate.test.mjs +315 -0
  13. package/templates/scripts/__tests__/ring2-roster-review.test.mjs +75 -16
  14. package/templates/scripts/__tests__/ring3-completion-filter.test.mjs +167 -105
  15. package/templates/scripts/__tests__/roster-ambient-render.test.mjs +132 -0
  16. package/templates/scripts/__tests__/routine-dispatch.test.mjs +29 -1
  17. package/templates/scripts/__tests__/routine-moment-expiry.test.mjs +192 -0
  18. package/templates/scripts/skill-validator.sh +7 -4
  19. package/templates/scripts/watchtower-lib.mjs +58 -5
  20. package/templates/scripts/watchtower-queue.mjs +54 -7
  21. package/templates/scripts/watchtower-ring1.mjs +74 -0
  22. package/templates/scripts/watchtower-ring2.mjs +442 -81
  23. package/templates/scripts/watchtower-ring3-close.mjs +271 -82
  24. package/templates/scripts/watchtower-routines.mjs +107 -1
  25. package/templates/skills/cabinet-anthropic-insider/SKILL.md +35 -2
  26. package/templates/skills/cabinet-anti-confirmation/SKILL.md +24 -0
  27. package/templates/skills/cabinet-cc-health/SKILL.md +22 -0
  28. package/templates/skills/cabinet-data-integrity/SKILL.md +19 -0
  29. package/templates/skills/cabinet-debugger/SKILL.md +36 -0
  30. package/templates/skills/cabinet-deployment/SKILL.md +22 -0
  31. package/templates/skills/cabinet-elegance/SKILL.md +12 -0
  32. package/templates/skills/cabinet-goal-alignment/SKILL.md +11 -0
  33. package/templates/skills/cabinet-historian/SKILL.md +33 -0
  34. package/templates/skills/cabinet-organized-mind/SKILL.md +11 -0
  35. package/templates/skills/cabinet-process-therapist/SKILL.md +132 -6
  36. package/templates/skills/cabinet-qa/SKILL.md +63 -0
  37. package/templates/skills/cabinet-roster-check/SKILL.md +12 -0
  38. package/templates/skills/cabinet-workflow-cop/SKILL.md +60 -0
@@ -241,7 +241,35 @@ test('pending item blocks refiring; stale pending item is superseded and redispa
241
241
  assert.equal(second.fired.length, 1);
242
242
  assert.notEqual(second.fired[0].item_id, firstId);
243
243
  assert.equal(q.getItem(firstId).status, 'superseded');
244
- assert.match(q.getItem(firstId).resolution_notes, /newer firing/);
244
+ // PRECEDENCE (act:ea23b3a5): for a time-of-day routine the moment sweep runs
245
+ // at the top of the tick and reaches the stale item first, so it — not the
246
+ // next-firing path — is what supersedes. The stale_after_hours path still
247
+ // owns interval / path-nonempty / session-close triggers, which have no
248
+ // moment. Same outcome (superseded, refiled), different and more honest
249
+ // reason: the item died because its day ended, not because a newer firing
250
+ // happened to come along.
251
+ assert.match(q.getItem(firstId).resolution_notes, /moment window passed/);
252
+ q.resolveItem(second.fired[0].item_id, { resolution: 'ran', resolution_type: 'acted-on' });
253
+ });
254
+
255
+ test('the stale-after-hours supersede still owns INTERVAL routines (no moment to expire)', () => {
256
+ const rt = routine({ name: 'interval-digest', trigger: { type: 'interval', minutes: 60 } });
257
+ const t1 = at('2026-06-20T07:00:00');
258
+ const first = r.runRoutinePass({ config: configWith([rt]), event: tick, filedBy: 'ring1', now: t1 });
259
+ assert.equal(first.fired.length, 1);
260
+ const firstId = first.fired[0].item_id;
261
+
262
+ const t2 = at('2026-06-22T07:00:00');
263
+ const fp = join(root, 'queue', 'items', `${firstId}.json`);
264
+ const pendingItem = JSON.parse(readFileSync(fp, 'utf8'));
265
+ pendingItem.filed_at = new Date(t2.getTime() - 25 * 3_600_000).toISOString();
266
+ writeFileSync(fp, JSON.stringify(pendingItem, null, 2));
267
+
268
+ const second = r.runRoutinePass({ config: configWith([rt]), event: tick, filedBy: 'ring1', now: t2 });
269
+ assert.equal(second.fired.length, 1);
270
+ assert.equal(q.getItem(firstId).status, 'superseded');
271
+ assert.match(q.getItem(firstId).resolution_notes, /newer firing/,
272
+ 'an interval routine has no moment — the unchanged stale path supersedes it');
245
273
  q.resolveItem(second.fired[0].item_id, { resolution: 'ran', resolution_type: 'acted-on' });
246
274
  });
247
275
 
@@ -0,0 +1,192 @@
1
+ // Routine moment expiry (act:ea23b3a5).
2
+ //
3
+ // A time-of-day routine only self-superseded at its NEXT firing — 24 hours
4
+ // later for a daily one — so a morning briefing nobody ran held a queue slot
5
+ // and a lit desk badge all day. Six such firings were dismissed in the
6
+ // 2026-07-26 triage.
7
+ //
8
+ // The window is END OF THE FILING DAY, and that is a measured choice. Across
9
+ // six weeks the portfolio's one time-of-day routine (flow/morning-briefing at
10
+ // 08:00) had ten real pickups: eight inside four hours and two after, at 4.4h
11
+ // and 8.6h. A four-hour window would have destroyed two genuine pickups to
12
+ // clear one stale item — negative expected value. End-of-day preserves all ten.
13
+ //
14
+ // Hermetic: every queue dependency is injected.
15
+
16
+ import { test } from 'node:test';
17
+ import assert from 'node:assert/strict';
18
+ import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+ import { tmpdir } from 'node:os';
21
+
22
+ const root = mkdtempSync(join(tmpdir(), 'routine-moment-'));
23
+ process.env.WATCHTOWER_DIR = root;
24
+ mkdirSync(join(root, 'queue', 'items'), { recursive: true });
25
+ process.env.WATCHTOWER_MUX_BIN = join(root, 'no-such-mux');
26
+
27
+ const r = await import('../watchtower-routines.mjs');
28
+
29
+ test.after(() => rmSync(root, { recursive: true, force: true }));
30
+
31
+ function item(id, triggerType, filed_at, extra = {}) {
32
+ return {
33
+ id, status: 'pending', category: 'routine', project: 'p', filed_at,
34
+ evidence: { routine_key: `p/${id}`, trigger: { type: triggerType } },
35
+ ...extra,
36
+ };
37
+ }
38
+
39
+ function harness(items) {
40
+ const superseded = [];
41
+ return {
42
+ superseded,
43
+ deps: {
44
+ listPending: () => items,
45
+ supersedeItem: (id, { reason }) => { superseded.push({ id, reason }); return { id }; },
46
+ },
47
+ };
48
+ }
49
+
50
+ const at = (s) => new Date(s);
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // The window
54
+ // ---------------------------------------------------------------------------
55
+
56
+ test('a time-of-day item filed YESTERDAY is swept', () => {
57
+ const h = harness([item('dec-a', 'time-of-day', '2026-07-25T08:00:00')]);
58
+ const out = r.sweepExpiredMoments({ now: at('2026-07-26T09:00:00'), deps: h.deps });
59
+ assert.deepEqual(out.swept, ['dec-a']);
60
+ assert.match(h.superseded[0].reason, /moment window passed/);
61
+ assert.match(h.superseded[0].reason, /2026-07-25/);
62
+ });
63
+
64
+ test('the SAME day is never swept, however late — 4.4h and 8.6h pickups are real', () => {
65
+ const filed = '2026-07-26T08:00:00';
66
+ for (const hour of ['12:24', '16:36', '23:00', '23:59']) {
67
+ const h = harness([item('dec-b', 'time-of-day', filed)]);
68
+ const out = r.sweepExpiredMoments({ now: at(`2026-07-26T${hour}:00`), deps: h.deps });
69
+ assert.deepEqual(out.swept, [], `${hour} is still the routine's own day`);
70
+ }
71
+ });
72
+
73
+ test('one minute past midnight sweeps it', () => {
74
+ const h = harness([item('dec-c', 'time-of-day', '2026-07-26T08:00:00')]);
75
+ const out = r.sweepExpiredMoments({ now: at('2026-07-27T00:01:00'), deps: h.deps });
76
+ assert.deepEqual(out.swept, ['dec-c']);
77
+ });
78
+
79
+ test('a 23:30 routine gets its full day — the window needs no midnight special case', () => {
80
+ // The window is derived from the item's filing DAY, not from clock arithmetic
81
+ // on the declared time, so a late-evening routine cannot wrap.
82
+ const h = harness([item('dec-d', 'time-of-day', '2026-07-26T23:30:00')]);
83
+ assert.deepEqual(
84
+ r.sweepExpiredMoments({ now: at('2026-07-26T23:45:00'), deps: h.deps }).swept, []);
85
+ assert.deepEqual(
86
+ r.sweepExpiredMoments({ now: at('2026-07-27T00:05:00'), deps: h.deps }).swept, ['dec-d']);
87
+ });
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Scope
91
+ // ---------------------------------------------------------------------------
92
+
93
+ test('interval, path-nonempty, and session-close routines are never swept', () => {
94
+ const old = '2026-07-01T08:00:00';
95
+ for (const type of ['interval', 'path-nonempty', 'session-close']) {
96
+ const h = harness([item('dec-x', type, old)]);
97
+ assert.deepEqual(
98
+ r.sweepExpiredMoments({ now: at('2026-07-26T09:00:00'), deps: h.deps }).swept, [],
99
+ `${type} has no moment — stale_after_hours still owns it`);
100
+ }
101
+ });
102
+
103
+ test('an item with no persisted trigger is left alone', () => {
104
+ const h = harness([{ id: 'dec-y', status: 'pending', category: 'routine', filed_at: '2026-07-01T08:00:00', evidence: {} }]);
105
+ assert.deepEqual(r.sweepExpiredMoments({ now: at('2026-07-26T09:00:00'), deps: h.deps }).swept, []);
106
+ });
107
+
108
+ test('the trigger is read from the ITEM, so a de-registered routine still classifies', () => {
109
+ // dispatchRoutine persists evidence.trigger at filing. A config lookup would
110
+ // find nothing for a routine the operator has since removed and leave the
111
+ // item pending forever.
112
+ const h = harness([item('dec-z', 'time-of-day', '2026-07-20T08:00:00')]);
113
+ const out = r.sweepExpiredMoments({ now: at('2026-07-26T09:00:00'), deps: h.deps });
114
+ assert.deepEqual(out.swept, ['dec-z'], 'no config was consulted at all');
115
+ });
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Failure direction
119
+ // ---------------------------------------------------------------------------
120
+
121
+ test('an unparseable filed_at abstains loudly instead of sweeping', () => {
122
+ const h = harness([item('dec-bad', 'time-of-day', 'not-a-date')]);
123
+ const out = r.sweepExpiredMoments({ now: at('2026-07-26T09:00:00'), deps: h.deps });
124
+ assert.deepEqual(out.swept, []);
125
+ assert.equal(out.skipped, 1);
126
+ assert.equal(h.superseded.length, 0,
127
+ 'the existing stale path treats the same input as stale — one mechanism abstaining loudly beats two disagreeing silently');
128
+ });
129
+
130
+ test('a throwing supersede does not abort the rest of the sweep', () => {
131
+ const items = [
132
+ item('dec-1', 'time-of-day', '2026-07-20T08:00:00'),
133
+ item('dec-2', 'time-of-day', '2026-07-20T08:00:00'),
134
+ ];
135
+ let calls = 0;
136
+ const out = r.sweepExpiredMoments({
137
+ now: at('2026-07-26T09:00:00'),
138
+ deps: {
139
+ listPending: () => items,
140
+ supersedeItem: (id) => { calls++; if (id === 'dec-1') throw new Error('locked'); return { id }; },
141
+ },
142
+ });
143
+ assert.equal(calls, 2);
144
+ assert.deepEqual(out.swept, ['dec-2']);
145
+ });
146
+
147
+ test('a throwing listPending returns an empty sweep rather than propagating', () => {
148
+ const out = r.sweepExpiredMoments({
149
+ now: at('2026-07-26T09:00:00'),
150
+ deps: { listPending: () => { throw new Error('queue unreadable'); } },
151
+ });
152
+ assert.deepEqual(out, { swept: [], skipped: 0 });
153
+ });
154
+
155
+ test('endOfFilingDay returns null for garbage and a day-end for a real date', () => {
156
+ assert.equal(r.endOfFilingDay('nonsense'), null);
157
+ assert.equal(r.endOfFilingDay(undefined), null);
158
+ const d = r.endOfFilingDay('2026-07-26T08:00:00');
159
+ assert.equal(d.getHours(), 23);
160
+ assert.equal(d.getMinutes(), 59);
161
+ });
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // Pass scoping — the blast-radius rule
165
+ // ---------------------------------------------------------------------------
166
+
167
+ test('a session-close event sweeps NOTHING; only Ring 1 ticks sweep', () => {
168
+ // runRoutinePass is called by Ring 3 with a single-project session-close
169
+ // event. An unscoped sweep there would have every session close in any
170
+ // project superseding every OTHER project's routine items — and during a
171
+ // --reprocess-failed drain, one portfolio sweep per replayed session.
172
+ const stale = [item('dec-other-project', 'time-of-day', '2026-07-01T08:00:00')];
173
+ const h = harness(stale);
174
+ r.runRoutinePass({
175
+ config: { projects: {} },
176
+ event: { type: 'session-close', project: 'someone-else' },
177
+ filedBy: 'ring3-close',
178
+ now: at('2026-07-26T09:00:00'),
179
+ deps: h.deps,
180
+ });
181
+ assert.deepEqual(h.superseded, []);
182
+
183
+ const h2 = harness(stale);
184
+ r.runRoutinePass({
185
+ config: { projects: {} },
186
+ event: { type: 'tick' },
187
+ filedBy: 'ring1',
188
+ now: at('2026-07-26T09:00:00'),
189
+ deps: h2.deps,
190
+ });
191
+ assert.deepEqual(h2.superseded.map((s) => s.id), ['dec-other-project']);
192
+ });
@@ -72,9 +72,9 @@ field_value() {
72
72
  printf '%s\n' "$fm" | awk -v f="$field" '
73
73
  $0 ~ "^" f ":" {
74
74
  sub("^" f ": *", "")
75
- # If the value is a block scalar indicator, return empty so the
76
- # caller falls through to field_block_scalar.
77
- if ($0 == ">" || $0 == "|" || $0 == ">-" || $0 == "|-") { print ""; exit }
75
+ # If the value is a block scalar indicator (any of > | >- |- >+ |+),
76
+ # return empty so the caller falls through to field_block_scalar.
77
+ if ($0 ~ /^[>|][-+]?$/) { print ""; exit }
78
78
  gsub(/^["'\'']|["'\'']$/, "")
79
79
  print
80
80
  exit
@@ -91,7 +91,10 @@ field_block_scalar() {
91
91
  $0 ~ "^" f ":" {
92
92
  line = $0
93
93
  sub("^" f ": *", "", line)
94
- if (line == ">" || line == "|") {
94
+ # Any block-scalar indicator (> | >- |- >+ |+) starts a block; the
95
+ # old bare-marker test returned ">-" itself as the field value, so
96
+ # every folded description counted 0 sentences (feedback 2026-07-26).
97
+ if (line ~ /^[>|][-+]?$/) {
95
98
  in_block = 1
96
99
  next
97
100
  }
@@ -104,8 +104,20 @@ export const INBOX_DETECTOR_REGISTRY = {
104
104
  'completion-review': {
105
105
  reconciler: 'autoReconcileCompletionReviews (ring1): resolves when the referenced '
106
106
  + 'action (plan_fid || evidence.fid) is verifiably closed in its OWN project db; '
107
- + 'fid-not-found keeps (flow vestigial-db + misattribution protection); filing '
108
- + 'exclusion: ring3 Phase 2c create-vs-complete guard (act:9eebbac4)',
107
+ + 'fid-not-found keeps (flow vestigial-db + misattribution protection). Filing '
108
+ + 'exclusions (act:ea23b3a5, all at ring3 Phase 2c): (a) confidence \'high\' is '
109
+ + 'SUPPRESSED — measured anti-predictive over all 223 items ever filed (high 30% '
110
+ + 'precision, medium+low 60%); (b) act:9eebbac4\'s create-vs-complete day-window '
111
+ + 'filter is DELETED — it exempted \'high\' from the born-this-session skip, so it '
112
+ + 'suppressed the best cohort (same-day + medium/low, 74%) and passed the worst, '
113
+ + 'and the category filed 0 true positives in the 23 items after it shipped; (c) '
114
+ + 'per-fid dedup across pending + resolved/dismissed/EXPIRED/SUPERSEDED. Category '
115
+ + 'expiry is 14d (expiryDaysFor), which is why the last two statuses are '
116
+ + 'load-bearing: the expiry equals COMPLETION_REVIEW_DEDUP_DAYS, so without them a '
117
+ + 'fid leaves every corpus exactly when its item expires and refiles forever. A '
118
+ + 'quoted-completion-evidence check runs as INSTRUMENTATION ONLY (evidence.'
119
+ + 'quote_verified) and gates nothing — the transcript haystack is JSON-serialized, '
120
+ + 'so a substring gate rejects real prose and accepts serialized tool arguments.',
109
121
  },
110
122
  // ring3-filed
111
123
  'coverage-warning': {
@@ -133,7 +145,12 @@ export const INBOX_DETECTOR_REGISTRY = {
133
145
  'advisor-finding': {
134
146
  exempt: 'advisory observation from the session advisor pass (ring2 briefing panel + '
135
147
  + 'ring3 Phase 2m); capped per member per session and deduped vs pending + '
136
- + 'resolution corpora at filing; no mechanical truth condition.',
148
+ + 'resolution corpora at filing; no mechanical truth condition. The ring2 '
149
+ + 'cabinet-roster review files only ONE kind here now (act:ea23b3a5): '
150
+ + 'uncovered-tech, which proposes a cabinet seat and is therefore a decision. Its '
151
+ + 'siblings dormant-skill and stale-briefing were pure measurements and moved to '
152
+ + 'ambient state (state/roster-review.json → Ring 1 Standing Issues, rendered on '
153
+ + 'change); retireRosterFindingItems supersedes any that were still pending.',
137
154
  },
138
155
  'raised-unhandled': {
139
156
  exempt: 'session-historical fact — a loose end the session raised but neither did '
@@ -150,8 +167,44 @@ export const INBOX_DETECTOR_REGISTRY = {
150
167
  + 'reconciler: the referenced deferred action closed.',
151
168
  },
152
169
  'watchtower-health': {
153
- exempt: 'notification of a stale/failing ring window; recovery is visible in the '
154
- + 'ring health sidecars. Candidate future reconciler: health recovered retract.',
170
+ reconciler: 'retractClearedMemoryBudgetItems (ring2 slow, act:ea23b3a5): the '
171
+ + 'memory-budget alarm retracts on the SAME pass that measures it — '
172
+ + 'runMemoryHygiene already shells every project\'s validate-memory (exit 0 = '
173
+ + 'pass) and already computes passProjects, so the retraction condition is in '
174
+ + 'hand. Deliberately NOT in Ring 1: a second memory-health producer is forbidden '
175
+ + 'by name in this doc, and at a 30-minute throttle against ring2\'s 30-minute '
176
+ + 'cadence it would buy zero freshness. Fails toward KEEPING — only a project '
177
+ + 'positively classified `pass` retracts; any nonzero exit maps to `violations`. '
178
+ + 'The selector matches BOTH the new evidence.debt_class and the legacy '
179
+ + '{violations, streak} title shape, because every item on disk when this shipped '
180
+ + 'carried the latter. Filing side: ONE accumulating item per project, keyed by '
181
+ + 'CALENDAR DAY (a per-run key would count ~48 cron ticks a day), appended via '
182
+ + 'annotateItemEvidence so filed_at is never bumped. Other watchtower-health '
183
+ + 'filings (stale/failing ring windows) remain exempt — recovery is visible in the '
184
+ + 'ring health sidecars.',
185
+ },
186
+ // ring4-filed (in the census since act:ea23b3a5; ring4 was outside it before)
187
+ 'doc-drift': {
188
+ exempt: 'a documentary claim that no longer matches the tree, filed one item per '
189
+ + '(project, drift_key). Deliberately NOT aggregated into a per-project standing '
190
+ + 'debt: each item carries its own mutually-exclusive fix-doc / fix-code decision, '
191
+ + 'so one accumulated item would pose a question the operator cannot answer. No '
192
+ + 'mechanical retraction yet either — the weekly pass covers only 3 of 7 projects '
193
+ + 'per run, so "not reproduced this run" is not evidence a claim cleared, and a '
194
+ + 'reconciler built on it would mass-retract every item in the unscanned projects. '
195
+ + 'Candidate future reconciler: re-verify on the next pass that ACTUALLY scanned '
196
+ + 'that project, scoped to items with filed_by ring4. NOTE a second producer '
197
+ + 'exists outside the census — a consumer Plan-9 hook files doc-drift with no '
198
+ + 'drift_key at all (live: flow ring2-slow-post/10-pulse-mechanical.mjs).',
199
+ },
200
+ // routines-filed (in the census since act:ea23b3a5)
201
+ 'routine': {
202
+ exempt: 'a declared interactive routine that fired. The dispatch engine owns the '
203
+ + 'whole lifecycle, so there is nothing for a Ring 1 reconciler to re-check: a '
204
+ + 'pending item blocks refiring, a stale one is superseded at the next due firing, '
205
+ + 'and since act:ea23b3a5 a time-of-day routine self-supersedes at END OF ITS '
206
+ + 'FILING DAY on Ring 1\'s tick — its moment is over. Terminal exits clear the mux '
207
+ + 'dispatch descriptor via DISPATCHED_CATEGORIES, so the desk badge follows.',
155
208
  },
156
209
  'stale-project': {
157
210
  exempt: 'staleness notification. Candidate future reconciler: project activity '
@@ -149,7 +149,7 @@ const KNOWLEDGE_CATEGORY = 'knowledge-extraction';
149
149
  // is ALWAYS filed (never dropped) — home becomes the recomputation itself,
150
150
  // not a written record.
151
151
  export const KNOWLEDGE_EXTRACTION_TYPES = ['decision', 'constraint', 'lesson', 'preference', 'unclassifiable'];
152
- export const KNOWLEDGE_EXTRACTION_HOMES = ['memory', 'claude-md', 'pib-db-trigger', 'upstream-feedback', 'derivation'];
152
+ export const KNOWLEDGE_EXTRACTION_HOMES = ['memory', 'claude-md', 'pib-db-trigger', 'upstream-feedback', 'derivation', 'session-record'];
153
153
 
154
154
  // Categories whose items carry a structural recipient gate: they may never be
155
155
  // included in a batch disposition — each leaves the queue only through its own
@@ -917,6 +917,13 @@ export function createItem({
917
917
  if (evidence.derivable === true && (typeof evidence.derivation !== 'string' || !evidence.derivation.trim())) {
918
918
  throw new Error('createItem: derivable:true requires evidence.derivation naming how to recompute it — a derivable fact is routed to its derivation, never filed as an opaque flag');
919
919
  }
920
+ // The perishable twin (act:a69c21f8): a fact that is true at a moment and
921
+ // will become false, with no live source to recompute it from. perishes_when
922
+ // is to perishable what derivation is to derivable — the flag is never
923
+ // filed opaque.
924
+ if (evidence.perishable === true && (typeof evidence.perishes_when !== 'string' || !evidence.perishes_when.trim())) {
925
+ throw new Error('createItem: perishable:true requires evidence.perishes_when naming what event or time makes it false — a perishable fact is filed as a dated snapshot, never as an opaque flag');
926
+ }
920
927
  }
921
928
  // A 'merged' handoff must reference work actually on main. merge_state
922
929
  // replaced the old hand-flagged `merged_into: "PENDING — not yet merged"`
@@ -1161,11 +1168,46 @@ export function releaseHold(id) {
1161
1168
  * @param {string} id
1162
1169
  * @returns {object} The updated item
1163
1170
  */
1171
+ // ---------------------------------------------------------------------------
1172
+ // Category expiry — the ONE age policy (act:ea23b3a5)
1173
+ // ---------------------------------------------------------------------------
1174
+ //
1175
+ // There are TWO expiry engines: Ring 2's `escalateQueueItems` (the 5-minute
1176
+ // cron) and `runExpiry` below (called by /inbox and /briefing). Before this,
1177
+ // each hardcoded 30 days and each inlined its own qa-handoff carve-out — so a
1178
+ // per-category policy set in one would be silently violated by whichever engine
1179
+ // reached the item first. Both now delegate here.
1180
+ //
1181
+ // `categoryNeverExpires` lives beside `expireItem`'s throw deliberately: the
1182
+ // carve-out is STRUCTURAL, not a preference. `expireItem` throws on a
1183
+ // qa-handoff, so returning anything but null for it would crash the caller's
1184
+ // loop. One fact, one place, no drift.
1185
+ export const DEFAULT_EXPIRE_DAYS = 30;
1186
+
1187
+ // Per-category overrides. completion-review at 14d: the category's whole value
1188
+ // is time-bound — a completion candidate nobody confirmed in two weeks is not
1189
+ // going to be confirmed, and a real completion re-detects when the action is
1190
+ // next touched. NOTE the coupling to COMPLETION_REVIEW_DEDUP_DAYS (also 14) in
1191
+ // ring3-close: because the two are equal, that phase's dedup corpus MUST
1192
+ // include `expired` and `superseded`, or a fid leaves every corpus at exactly
1193
+ // the moment its item expires and refiles forever.
1194
+ export const CATEGORY_EXPIRE_DAYS = { 'completion-review': 14 };
1195
+
1196
+ export function categoryNeverExpires(category) {
1197
+ return category === QA_CATEGORY;
1198
+ }
1199
+
1200
+ /** Days before an item of this category expires; null = never. */
1201
+ export function expiryDaysFor(category) {
1202
+ if (categoryNeverExpires(category)) return null;
1203
+ return CATEGORY_EXPIRE_DAYS[category] ?? DEFAULT_EXPIRE_DAYS;
1204
+ }
1205
+
1164
1206
  export function expireItem(id) {
1165
1207
  const fp = itemPath(id);
1166
1208
  const item = readItem(fp);
1167
1209
  if (item.status !== 'pending') return null;
1168
- if (item.category === QA_CATEGORY) {
1210
+ if (categoryNeverExpires(item.category)) {
1169
1211
  throw gateError(item, 'qa-handoff items never expire — QA debt stays surfaced until a stamped verdict resolves it (or a typed dismissal waives it)');
1170
1212
  }
1171
1213
  item.status = 'expired';
@@ -1359,10 +1401,12 @@ export function getEnrichment(id) {
1359
1401
  * @param {object} params
1360
1402
  * @returns {object} { warned: [], expired: [] }
1361
1403
  */
1362
- export function runExpiry({ warnDays = 14, expireDays = 30 } = {}) {
1404
+ // `expireDays` is the DEFAULT, not a blanket: per-category overrides come from
1405
+ // expiryDaysFor (act:ea23b3a5), so this engine and Ring 2's cron cannot give
1406
+ // two different answers for the same item.
1407
+ export function runExpiry({ warnDays = 14, expireDays = DEFAULT_EXPIRE_DAYS } = {}) {
1363
1408
  const now = Date.now();
1364
1409
  const warnMs = warnDays * 24 * 60 * 60 * 1000;
1365
- const expireMs = expireDays * 24 * 60 * 60 * 1000;
1366
1410
  const pending = listPending();
1367
1411
  const warned = [];
1368
1412
  const expired = [];
@@ -1371,13 +1415,16 @@ export function runExpiry({ warnDays = 14, expireDays = 30 } = {}) {
1371
1415
  const age = now - new Date(item.filed_at).getTime();
1372
1416
  // qa-handoff items never auto-expire: an expired handoff is exactly the
1373
1417
  // silent QA gap the recipient gate forbids. They warn (by item) instead.
1374
- if (item.category === QA_CATEGORY) {
1418
+ const days = categoryNeverExpires(item.category)
1419
+ ? null
1420
+ : (CATEGORY_EXPIRE_DAYS[item.category] ?? expireDays);
1421
+ if (days === null) {
1375
1422
  if (age >= warnMs) warned.push(item);
1376
1423
  continue;
1377
1424
  }
1378
- if (age >= expireMs) {
1425
+ if (age >= days * 24 * 60 * 60 * 1000) {
1379
1426
  item.status = 'expired';
1380
- item.resolution_notes = `Auto-expired after ${expireDays} days. If still relevant, re-file with updated context.`;
1427
+ item.resolution_notes = `Auto-expired after ${days} days. If still relevant, re-file with updated context.`;
1381
1428
  atomicWrite(itemPath(item.id), item);
1382
1429
  if (DISPATCHED_CATEGORIES.has(item.category)) clearDispatchEntries(item);
1383
1430
  expired.push(item);
@@ -1645,6 +1645,72 @@ function readRecallCanary() {
1645
1645
  } catch { return {}; }
1646
1646
  }
1647
1647
 
1648
+ // readRosterMetrics — the Ring 2 cabinet-roster measurement sidecar
1649
+ // (act:ea23b3a5). Same shape of relationship as the recall canary: the
1650
+ // measurement is Ring 2's, the render is Ring 1's, because Ring 1 owns the
1651
+ // per-project state file. Absent or corrupt yields {} — a project with no
1652
+ // entry renders nothing at all.
1653
+ function readRosterMetrics() {
1654
+ const path = join(WATCHTOWER_DIR, 'state', 'roster-review.json');
1655
+ if (!existsSync(path)) return {};
1656
+ try {
1657
+ return JSON.parse(readFileSync(path, 'utf8')).metrics || {};
1658
+ } catch { return {}; }
1659
+ }
1660
+
1661
+ // Cap on skill names listed inline, matching FLAGGED_RENDER_CAP's discipline:
1662
+ // the COUNT is always true, only the enumeration is bounded.
1663
+ export const ROSTER_NAME_RENDER_CAP = 4;
1664
+
1665
+ // renderRosterEntries — the ambient Standing Issues lines for a project's
1666
+ // roster measurement. Returns [] when there is nothing worth a line.
1667
+ //
1668
+ // RENDERS ON CHANGE ONLY. An unchanging number in a file the operator reads at
1669
+ // every briefing is furniture by the third read — the recall canary set this
1670
+ // precedent by rendering only on alert. The count still lives in
1671
+ // state/roster-review.json for anyone who goes looking; what earns a line here
1672
+ // is movement. (First measurement counts as movement: `previous` is null.)
1673
+ //
1674
+ // Pure (no I/O) for hermetic testing.
1675
+ export function renderRosterEntries(metrics, cap = ROSTER_NAME_RENDER_CAP) {
1676
+ const lines = [];
1677
+ if (!metrics || typeof metrics !== 'object') return lines;
1678
+ const asOf = String(metrics.measured_at || '').slice(0, 10);
1679
+ const stamp = asOf ? ` (measured ${asOf})` : '';
1680
+
1681
+ const d = metrics.dormant;
1682
+ if (d && (d.dead_count > 0 || d.stale_count > 0)) {
1683
+ const prev = metrics.previous;
1684
+ const changed = !prev
1685
+ || prev.dead_count !== d.dead_count
1686
+ || prev.stale_count !== d.stale_count;
1687
+ if (changed) {
1688
+ const names = (Array.isArray(d.dead) ? d.dead : []).slice(0, cap);
1689
+ const more = (d.dead_count || 0) - names.length;
1690
+ const nameList = names.length
1691
+ ? ` — ${names.join(', ')}${more > 0 ? `, +${more} more` : ''}`
1692
+ : '';
1693
+ const wasNote = prev ? ` (was ${prev.dead_count})` : '';
1694
+ lines.push(
1695
+ `dormant skills: ${d.dead_count} never invoked${wasNote}, `
1696
+ + `${d.stale_count} stale >${d.days || 30}d${nameList}${stamp}`
1697
+ );
1698
+ }
1699
+ }
1700
+
1701
+ const briefings = metrics.stale_briefings;
1702
+ if (Array.isArray(briefings) && briefings.length > 0) {
1703
+ const oldest = briefings.reduce((a, b) =>
1704
+ (b.ageDaysBehind || 0) > (a.ageDaysBehind || 0) ? b : a);
1705
+ lines.push(
1706
+ `stale cabinet briefings: ${briefings.length}, `
1707
+ + `oldest ~${oldest.ageDaysBehind}d behind the project${stamp}`
1708
+ );
1709
+ }
1710
+
1711
+ return lines;
1712
+ }
1713
+
1648
1714
  function assembleProjectState(ps) {
1649
1715
  const now = new Date().toISOString();
1650
1716
  const lines = [];
@@ -1727,6 +1793,10 @@ function assembleProjectState(ps) {
1727
1793
  `sample (over-suppression only; recall-canary.json / see /briefing)`
1728
1794
  );
1729
1795
  }
1796
+ // Cabinet-roster measurements (act:ea23b3a5) — ambient state, not queue
1797
+ // items: a count describes a condition, it doesn't ask the operator to
1798
+ // decide anything. Rendered on CHANGE only; see renderRosterEntries.
1799
+ for (const line of renderRosterEntries(ps.roster)) issues.push(line);
1730
1800
  if (ps.divergedBranches && ps.divergedBranches.length > 0) {
1731
1801
  issues.push(`Diverged branches: ${ps.divergedBranches.join(', ')}`);
1732
1802
  }
@@ -1815,6 +1885,9 @@ function main() {
1815
1885
  // Ring 2 slow writes the recall-canary sidecar; Ring 1 renders an alerting
1816
1886
  // project's entry into its Standing Issues (the canary's named reader).
1817
1887
  const recallCanaryProjects = readRecallCanary();
1888
+ // Ring 2's weekly cabinet-roster measurement, rendered by Ring 1 (which
1889
+ // owns the per-project state file) — the recall-canary relationship.
1890
+ const rosterMetrics = readRosterMetrics();
1818
1891
 
1819
1892
  // Branch-diverged reconciliation inputs, resolved ONCE per tick: the
1820
1893
  // exclusion matcher (config-driven) and one pending-items snapshot —
@@ -1845,6 +1918,7 @@ function main() {
1845
1918
  ccFeedbackArrival: checkCcFeedbackArrival(projectPath),
1846
1919
  memoryIntegrity: checkMemoryIntegrity(projectPath),
1847
1920
  recall: recallCanaryProjects[name] || null,
1921
+ roster: rosterMetrics[name] || null,
1848
1922
  divergedBranches: [],
1849
1923
  hookResults: [],
1850
1924
  };