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
@@ -194,16 +194,23 @@ test('parseDormantSkills: extracts dead/stale, caps, computes hasFindings', () =
194
194
  assert.equal(parseDormantSkills({ dead: [], stale: [] }).hasFindings, false);
195
195
  assert.equal(parseDormantSkills(null).hasFindings, false);
196
196
 
197
- // cap
197
+ // cap bounds the DISPLAY list, never the count (act:ea23b3a5). It used to be
198
+ // both, and the title was built from the sliced array — so every project ever
199
+ // measured reported exactly "12 dead", a saturated constant wearing the
200
+ // costume of a measurement.
198
201
  const many = { dead: Array.from({ length: 20 }, (_, i) => ({ skill: `d${i}` })), stale: [] };
199
- assert.equal(parseDormantSkills(many, { cap: 5 }).dead.length, 5);
202
+ const capped = parseDormantSkills(many, { cap: 5 });
203
+ assert.equal(capped.dead.length, 5, 'names are bounded');
204
+ assert.equal(capped.dead_count, 20, 'the count is TRUE');
205
+ assert.equal(parseDormantSkills(reader).dead_count, 2);
206
+ assert.equal(parseDormantSkills(reader).stale_count, 1);
200
207
  });
201
208
 
202
209
  // =====================================================================
203
210
  // Orchestrator (injected deps — hermetic)
204
211
  // =====================================================================
205
212
 
206
- test('reviewCabinetRoster: files the three roster findings, stamps state, dedups, respects cadence', async () => {
213
+ test('reviewCabinetRoster: only uncovered-tech FILES; dormant/briefing become ambient metrics (act:ea23b3a5)', async () => {
207
214
  clearQueue();
208
215
  rmSync(join(root, 'state', 'roster-review.json'), { force: true });
209
216
 
@@ -219,38 +226,85 @@ test('reviewCabinetRoster: files the three roster findings, stamps state, dedups
219
226
 
220
227
  await reviewCabinetRoster(config, deps);
221
228
 
229
+ // uncovered-tech PROPOSES A CABINET SEAT — a real decision, so it stays a
230
+ // queue item. The other two are measurements and belong in ambient state.
222
231
  const items = listQueue();
223
- const kinds = items.map(i => i.evidence?.roster_kind).sort();
224
- assert.deepEqual(kinds, ['dormant-skill', 'stale-briefing', 'uncovered-tech']);
225
- // every finding is an ungated advisor-finding from ring2-slow
232
+ assert.deepEqual(items.map(i => i.evidence?.roster_kind).sort(), ['uncovered-tech']);
226
233
  for (const it of items) {
227
234
  assert.equal(it.category, 'advisor-finding');
228
235
  assert.equal(it.filed_by, 'ring2-slow');
229
236
  assert.equal(it.project, 'roster-proj');
230
237
  }
231
238
  assert.equal(calls.ask, 1, 'one coverage call');
232
- assert.equal(calls.reader, 1, 'one reader call');
239
+ assert.equal(calls.reader, 1, 'one reader call — the measurement still happens');
233
240
 
234
- // cadence stamped + project recorded
241
+ // cadence stamped + project recorded + metrics landed in the ONE trailing write
235
242
  const state = JSON.parse(readFileSync(join(root, 'state', 'roster-review.json'), 'utf8'));
236
243
  assert.ok(state.last_run);
237
244
  assert.ok(state.projects_checked['roster-proj']);
238
-
239
- // 2nd run, same now cadence gate blocks (no new calls, no new items)
245
+ const m = state.metrics['roster-proj'];
246
+ assert.ok(m, 'metrics must survive reviewCabinetRoster\'s wholesale state rewrite');
247
+ assert.equal(m.dormant.dead_count, 1);
248
+ assert.deepEqual(m.dormant.dead, ['dead-skill'], 'names, not just a count');
249
+ assert.equal(m.stale_briefings.length, 1);
250
+ assert.ok(m.measured_at);
251
+ assert.equal(m.previous, null, 'first measurement has no prior to compare against');
252
+
253
+ // 2nd run, same now → cadence gate blocks
240
254
  await reviewCabinetRoster(config, deps);
241
255
  assert.equal(calls.ask, 1, 'cadence gate prevents a second coverage call');
242
- assert.equal(listQueue().length, 3, 'no duplicate items while cadence holds');
256
+ assert.equal(listQueue().length, 1, 'no duplicate items while cadence holds');
243
257
 
244
- // 3rd run, now advanced past the interval, items still pending dedup blocks
245
- // re-filing (reference, not duplicate)
258
+ // 3rd run past the interval: the uncovered-tech dedup still holds, and the
259
+ // measurement carries its prior forward so Ring 1 can render on change.
246
260
  const later = {
247
261
  ...deps,
248
262
  now: deps.now + (ROSTER_REVIEW_INTERVAL_DAYS + 1) * DAY,
249
263
  ask: async () => { calls.ask++; return 'GAP: WebGL — still uncovered'; },
250
- runSkillUsage: async () => { calls.reader++; return { days: 30, dead: [{ skill: 'dead-skill' }], stale: [] }; },
264
+ runSkillUsage: async () => { calls.reader++; return { days: 30, dead: [{ skill: 'dead-skill' }, { skill: 'another' }], stale: [] }; },
251
265
  };
252
266
  await reviewCabinetRoster(config, later);
253
- assert.equal(listQueue().length, 3, 'dedup prevents duplicate roster findings');
267
+ assert.equal(listQueue().length, 1, 'dedup prevents duplicate roster findings');
268
+ const state2 = JSON.parse(readFileSync(join(root, 'state', 'roster-review.json'), 'utf8'));
269
+ assert.equal(state2.metrics['roster-proj'].dormant.dead_count, 2);
270
+ assert.deepEqual(state2.metrics['roster-proj'].previous, { dead_count: 1, stale_count: 0 });
271
+ });
272
+
273
+ test('reviewCabinetRoster retires pending dormant-skill / stale-briefing items once', async () => {
274
+ // The migration the change carries with it: fileRosterFinding and its dedup
275
+ // were the ONLY code that ever touched these items. Without this they would
276
+ // display a stale count for up to 30 days beside a live ambient line saying
277
+ // something else — dual existence.
278
+ clearQueue();
279
+ rmSync(join(root, 'state', 'roster-review.json'), { force: true });
280
+
281
+ const q = await import('../watchtower-queue.mjs');
282
+ const keep = q.createItem({
283
+ project: 'roster-proj', project_path: '/tmp/x', filed_by: 'ring2-slow',
284
+ category: 'advisor-finding', urgency: 'low', title: 'Roster coverage gap: roster-proj (3)',
285
+ summary: 's', evidence: { roster_kind: 'uncovered-tech' },
286
+ });
287
+ const retireA = q.createItem({
288
+ project: 'roster-proj', project_path: '/tmp/x', filed_by: 'ring2-slow',
289
+ category: 'advisor-finding', urgency: 'low', title: 'Dormant skills: roster-proj (12 dead, 11 stale)',
290
+ summary: 's', evidence: { roster_kind: 'dormant-skill' },
291
+ });
292
+ const retireB = q.createItem({
293
+ project: 'roster-proj', project_path: '/tmp/x', filed_by: 'ring2-slow',
294
+ category: 'advisor-finding', urgency: 'low', title: 'Stale cabinet briefings: roster-proj (2)',
295
+ summary: 's', evidence: { roster_kind: 'stale-briefing' },
296
+ });
297
+
298
+ const retired = ring2.retireRosterFindingItems();
299
+ assert.deepEqual(retired.sort(), [retireA, retireB].sort());
300
+
301
+ const statuses = Object.fromEntries(listQueue().map(i => [i.id, i.status]));
302
+ assert.equal(statuses[keep], 'pending', 'the decision item is untouched');
303
+ assert.equal(statuses[retireA], 'superseded');
304
+ assert.equal(statuses[retireB], 'superseded');
305
+
306
+ // Idempotent: a second pass finds nothing left pending.
307
+ assert.deepEqual(ring2.retireRosterFindingItems(), []);
254
308
  });
255
309
 
256
310
  test('reviewCabinetRoster: COVERAGE OK + no stale briefings + no dormant skills → no items', async () => {
@@ -307,5 +361,10 @@ test('reviewCabinetRoster: a thrown reader does not abort the other two checks',
307
361
  });
308
362
 
309
363
  const kinds = listQueue().map(i => i.evidence?.roster_kind).sort();
310
- assert.deepEqual(kinds, ['stale-briefing', 'uncovered-tech'], 'reader failure isolated; coverage + briefing still filed');
364
+ assert.deepEqual(kinds, ['uncovered-tech'], 'reader failure isolated; the coverage decision still files');
365
+
366
+ // and the briefing measurement still lands in ambient state
367
+ const state = JSON.parse(readFileSync(join(root, 'state', 'roster-review.json'), 'utf8'));
368
+ assert.equal(state.metrics['partial-proj'].stale_briefings.length, 1);
369
+ assert.equal(state.metrics['partial-proj'].dormant, undefined, 'the thrown reader contributed nothing');
311
370
  });
@@ -1,22 +1,30 @@
1
- // Phase 2c create-vs-complete filter (act:9eebbac4, Lane B of
2
- // grp:wt-noise-immunity).
1
+ // Phase 2c emit guard — the confidence inversion (act:ea23b3a5) that REPLACED
2
+ // the create-vs-complete day-window filter (act:9eebbac4).
3
3
  //
4
- // The bug: the completion detector filed completion-review items for actions
5
- // whose only session evidence was their own CREATION 12 of 66 live items
6
- // in the 2026-07-12 drain carried reasons like "created as a new action, not
7
- // completed". The filter extends completionReviewEmitGuard (act:23eb5897 /
8
- // ec508dbe extend, don't fork) with a mechanical conjunct: the action
9
- // row's `created` date falls inside the session's window AND the model's
10
- // confidence is below 'high'. Every gap fails OPEN (emit): missing
11
- // sessionDate, missing created column, thrown lookup — suppression only on
12
- // positive mechanical evidence, per the never-wholesale-suppression
13
- // discipline.
4
+ // What this file used to assert, and why it no longer does. act:9eebbac4 read
5
+ // "an action created this session whose only evidence is its own creation is
6
+ // not a completion candidate" and suppressed on it but EXEMPTED
7
+ // confidence: 'high', on the theory that high confidence means the transcript
8
+ // shows the work itself done. A full-corpus measurement on 2026-07-26 (all 223
9
+ // items the category had ever filed, each joined against the cited action's own
10
+ // project db) inverted that theory:
11
+ //
12
+ // high 12 acted-on / 28 dismissed-noise 30% precision
13
+ // medium+low 48 acted-on / 32 dismissed-noise 60% precision
14
+ //
15
+ // same-day creation + medium/low (the filter SUPPRESSED this) 74%
16
+ // same-day creation + high (the filter EXEMPTED this) 33%
17
+ //
18
+ // Split at the filter's 2026-07-12 ship date, the category went 0-for-23 — zero
19
+ // acted-on, 19 dismissed as noise. So the filter was deleted and the exempted
20
+ // bucket is now the suppressed one. Twelve of the nineteen dismissals in the
21
+ // 2026-07-26 tail triage were rated 'high'; that cohort is the fixture below.
14
22
  //
15
23
  // Hermetic: the guard takes a fake prepared statement; no sqlite, no model.
16
24
 
17
25
  import { test } from 'node:test';
18
26
  import assert from 'node:assert/strict';
19
- import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
27
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
20
28
  import { join } from 'node:path';
21
29
  import { tmpdir } from 'node:os';
22
30
 
@@ -24,142 +32,123 @@ const root = mkdtempSync(join(tmpdir(), 'ring3-completion-filter-'));
24
32
  process.env.WATCHTOWER_DIR = root;
25
33
  mkdirSync(join(root, 'queue', 'items'), { recursive: true });
26
34
 
27
- const { completionReviewEmitGuard } = await import('../watchtower-ring3-close.mjs');
35
+ const { completionReviewEmitGuard, verifyCompletionQuote, decodeTranscriptText, preprocessTranscript } =
36
+ await import('../watchtower-ring3-close.mjs');
28
37
 
29
38
  test.after(() => rmSync(root, { recursive: true, force: true }));
30
39
 
31
- const SESSION_DATE = '2026-07-10';
32
40
  const stmtReturning = (row) => ({ get: () => row });
33
- const OPEN_CREATED_IN_SESSION = { status: 'open', created: '2026-07-10' };
34
- const OPEN_CREATED_EARLIER = { status: 'open', created: '2026-07-01' };
41
+ const OPEN = { status: 'open' };
35
42
 
36
43
  // ---------------------------------------------------------------------------
37
- // The filter fires — positive confirmation, not just green fallbacks
44
+ // The confidence inversion fires — positive confirmation, not a green fallback
38
45
  // ---------------------------------------------------------------------------
39
46
 
40
- test('created this session + non-high confidence → suppressed with a distinct reason', () => {
47
+ test("high confidence → suppressed, with a reason naming the calibration", () => {
41
48
  const guard = completionReviewEmitGuard('act:aaaa1111', {
42
- statusStmt: stmtReturning(OPEN_CREATED_IN_SESSION),
43
- sessionDate: SESSION_DATE,
44
- confidence: 'medium',
49
+ statusStmt: stmtReturning(OPEN),
50
+ confidence: 'high',
45
51
  });
46
52
  assert.equal(guard.emit, false);
47
- assert.match(guard.reason, /within this session's day window/, 'distinct reason names the filter');
48
- assert.match(guard.reason, /creation is not completion/);
53
+ assert.match(guard.reason, /anti-predictive/);
54
+ assert.match(guard.reason, /calibration note/);
49
55
  });
50
56
 
51
- test('past-midnight spill (created the day after session start) suppressed', () => {
52
- const guard = completionReviewEmitGuard('act:aaaa1111', {
53
- statusStmt: stmtReturning({ status: 'open', created: '2026-07-11' }),
54
- sessionDate: SESSION_DATE,
55
- confidence: 'low',
57
+ test('confidence matching is case-insensitive a deviant "High" is still suppressed', () => {
58
+ // Direction matters: this arm is deliberately fail-CLOSED (the one such arm
59
+ // in the guard), so a malformed spelling must not become an escape hatch.
60
+ const guard = completionReviewEmitGuard('act:aaaa2222', {
61
+ statusStmt: stmtReturning(OPEN),
62
+ confidence: 'High',
56
63
  });
57
64
  assert.equal(guard.emit, false);
58
65
  });
59
66
 
60
- test('reprocess safety: an action born in a LATER intervening sessionemits', () => {
61
- // An old transcript re-run must not suppress retroactive completion
62
- // evidence for actions created days after that session.
63
- const guard = completionReviewEmitGuard('act:aaaa1111', {
64
- statusStmt: stmtReturning({ status: 'open', created: '2026-07-13' }),
65
- sessionDate: SESSION_DATE,
66
- confidence: 'medium',
67
- });
68
- assert.equal(guard.emit, true);
69
- });
70
-
71
- test('confidence matching is case-insensitive — a deviant "High" still rescues', () => {
72
- const guard = completionReviewEmitGuard('act:aaaa1111', {
73
- statusStmt: stmtReturning(OPEN_CREATED_IN_SESSION),
74
- sessionDate: SESSION_DATE,
75
- confidence: 'High',
76
- });
77
- assert.equal(guard.emit, true, 'malformed spelling must not flip fail-open into fail-closed');
67
+ test('the 2026-07-26 tail cohort: 12 of 19 dismissals were highat most 7 still emit', () => {
68
+ // The calibration corpus itself, as the fixture. Confidences taken from the
69
+ // 19 items dismissed noise on 2026-07-26 (queue items, resolution_type
70
+ // 'noise', category completion-review): 12 high, 4 medium, 3 low.
71
+ const cohort = [
72
+ ...Array(12).fill('high'),
73
+ ...Array(4).fill('medium'),
74
+ ...Array(3).fill('low'),
75
+ ];
76
+ const emitted = cohort.filter((confidence, i) =>
77
+ completionReviewEmitGuard(`act:cohort${String(i).padStart(2, '0')}`, {
78
+ statusStmt: stmtReturning(OPEN),
79
+ confidence,
80
+ }).emit).length;
81
+ assert.equal(emitted, 7, 'the high bucket is suppressed; the 7 medium/low still file');
78
82
  });
79
83
 
80
84
  // ---------------------------------------------------------------------------
81
- // The legitimate flows keep emitting
85
+ // The cohort the deleted filter used to suppress now files — this is the point
82
86
  // ---------------------------------------------------------------------------
83
87
 
84
- test('created in a PRIOR session + completion evidence emits', () => {
85
- const guard = completionReviewEmitGuard('act:bbbb2222', {
86
- statusStmt: stmtReturning(OPEN_CREATED_EARLIER),
87
- sessionDate: SESSION_DATE,
88
- confidence: 'medium',
89
- });
90
- assert.equal(guard.emit, true);
88
+ test('an action created THIS session at medium/low confidence now EMITS (74% cohort restored)', () => {
89
+ // act:9eebbac4 suppressed exactly this. Measured at 20 acted-on / 7 noise —
90
+ // the highest-precision cell in the whole corpus.
91
+ for (const confidence of ['medium', 'low']) {
92
+ const guard = completionReviewEmitGuard('act:bbbb1111', {
93
+ statusStmt: stmtReturning({ status: 'open', created: '2026-07-10' }),
94
+ confidence,
95
+ });
96
+ assert.equal(guard.emit, true, `${confidence} must emit regardless of the created date`);
97
+ }
91
98
  });
92
99
 
93
- test('created this session but HIGH confidence (create-then-complete flow) emits', () => {
94
- const guard = completionReviewEmitGuard('act:cccc3333', {
95
- statusStmt: stmtReturning(OPEN_CREATED_IN_SESSION),
96
- sessionDate: SESSION_DATE,
97
- confidence: 'high',
100
+ test('the guard no longer reads a created date at all', () => {
101
+ // A row carrying no `created` column and a row created today are
102
+ // indistinguishable to the guard — proof the day-window logic is gone rather
103
+ // than merely unreachable.
104
+ const withCreated = completionReviewEmitGuard('act:bbbb2222', {
105
+ statusStmt: stmtReturning({ status: 'open', created: new Date().toISOString().slice(0, 10) }),
106
+ confidence: 'medium',
98
107
  });
99
- assert.equal(guard.emit, true);
108
+ const without = completionReviewEmitGuard('act:bbbb2222', {
109
+ statusStmt: stmtReturning({ status: 'open' }),
110
+ confidence: 'medium',
111
+ });
112
+ assert.deepEqual(withCreated, without);
113
+ assert.equal(withCreated.emit, true);
100
114
  });
101
115
 
102
116
  // ---------------------------------------------------------------------------
103
- // Fail-open discipline every gap emits
117
+ // Fail-open discipline elsewhere in the guard is unchanged
104
118
  // ---------------------------------------------------------------------------
105
119
 
106
- test('garbage-but-truthy sessionDate (dayAfter unparseable) → emits, never lexical suppression', () => {
107
- // An epoch-as-string sliced to 10 chars is truthy and lexically below any
108
- // ISO date without the required-windowEnd conjunct it would suppress
109
- // EVERY below-high candidate for the run (fail-closed inversion).
110
- const guard = completionReviewEmitGuard('act:9999ffff', {
111
- statusStmt: stmtReturning(OPEN_CREATED_IN_SESSION),
112
- sessionDate: '1752000000',
120
+ test('thrown lookup → emits (fail-open, the status re-check discipline)', () => {
121
+ const guard = completionReviewEmitGuard('act:ffff6666', {
122
+ statusStmt: { get: () => { throw new Error('database is locked'); } },
113
123
  confidence: 'medium',
114
124
  });
115
125
  assert.equal(guard.emit, true);
116
126
  });
117
127
 
118
- test('no sessionDate (unparseable transcript) → emits', () => {
128
+ test('no confidence suppliedthe inversion self-skips and the item emits', () => {
119
129
  const guard = completionReviewEmitGuard('act:dddd4444', {
120
- statusStmt: stmtReturning(OPEN_CREATED_IN_SESSION),
121
- sessionDate: null,
122
- confidence: 'medium',
123
- });
124
- assert.equal(guard.emit, true);
125
- });
126
-
127
- test('row without a created value (schema gap) → emits', () => {
128
- const guard = completionReviewEmitGuard('act:eeee5555', {
129
- statusStmt: stmtReturning({ status: 'open' }),
130
- sessionDate: SESSION_DATE,
131
- confidence: 'medium',
132
- });
133
- assert.equal(guard.emit, true);
134
- });
135
-
136
- test('thrown lookup → emits (fail-open, matching the status re-check discipline)', () => {
137
- const guard = completionReviewEmitGuard('act:ffff6666', {
138
- statusStmt: { get: () => { throw new Error('database is locked'); } },
139
- sessionDate: SESSION_DATE,
140
- confidence: 'medium',
130
+ statusStmt: stmtReturning(OPEN),
131
+ existingItems: [],
141
132
  });
142
133
  assert.equal(guard.emit, true);
143
134
  });
144
135
 
145
136
  // ---------------------------------------------------------------------------
146
- // Guard ordering and the original signature stay intact
137
+ // Guard ordering
147
138
  // ---------------------------------------------------------------------------
148
139
 
149
- test('status re-check wins first: a done action reports status, not creation', () => {
140
+ test('status re-check wins first: a done action reports status, not confidence', () => {
150
141
  const guard = completionReviewEmitGuard('act:1111aaaa', {
151
- statusStmt: stmtReturning({ status: 'done', created: '2026-07-10' }),
152
- sessionDate: SESSION_DATE,
153
- confidence: 'medium',
142
+ statusStmt: stmtReturning({ status: 'done' }),
143
+ confidence: 'high',
154
144
  });
155
145
  assert.equal(guard.emit, false);
156
146
  assert.match(guard.reason, /status now 'done'/);
157
147
  });
158
148
 
159
- test('per-fid dedup still runs after the filter passes', () => {
149
+ test('per-fid dedup still runs after the confidence gate passes', () => {
160
150
  const guard = completionReviewEmitGuard('act:2222bbbb', {
161
- statusStmt: stmtReturning(OPEN_CREATED_EARLIER),
162
- sessionDate: SESSION_DATE,
151
+ statusStmt: stmtReturning(OPEN),
163
152
  confidence: 'medium',
164
153
  existingItems: [{ id: 'dec-x1', status: 'pending', plan_fid: 'act:2222bbbb' }],
165
154
  });
@@ -167,10 +156,83 @@ test('per-fid dedup still runs after the filter passes', () => {
167
156
  assert.match(guard.reason, /existing completion-review item dec-x1/);
168
157
  });
169
158
 
170
- test('original two-param shape is untouched (no sessionDate → filter self-skips)', () => {
171
- const guard = completionReviewEmitGuard('act:3333cccc', {
172
- statusStmt: stmtReturning(OPEN_CREATED_IN_SESSION),
173
- existingItems: [],
174
- });
175
- assert.equal(guard.emit, true);
159
+ // ---------------------------------------------------------------------------
160
+ // Quote verification — INSTRUMENTATION, and it must stay that way
161
+ // ---------------------------------------------------------------------------
162
+ //
163
+ // The haystack is built by calling the REAL preprocessTranscript, not by
164
+ // hand-writing plain prose. That is the whole point: its output is one
165
+ // JSON.stringify'd message per line, so a hand-written fixture would test a
166
+ // string the production code never sees.
167
+
168
+ let fixtureSeq = 0;
169
+ function realHaystack(messages) {
170
+ // preprocessTranscript reads a Claude Code transcript FILE, so the fixture is
171
+ // written to disk in the nested `{ type, message: { role, content } }` shape
172
+ // it normalizes — no shortcut around the production code path.
173
+ const path = join(root, `transcript-${fixtureSeq++}.jsonl`);
174
+ writeFileSync(path, messages.map((m) => JSON.stringify({ type: m.role, message: m })).join('\n'));
175
+ // preprocessTranscript returns { compressed, originalTokenEstimate,
176
+ // compressedTokenEstimate } — the haystack is the `compressed` field, which
177
+ // is what Phase 2c slices and hands the model.
178
+ return preprocessTranscript(path).compressed;
179
+ }
180
+
181
+ test('a multi-line prose quote verifies against a real preprocessed transcript', () => {
182
+ // The case a naive substring test against the raw JSONL would REJECT: the
183
+ // real newline inside this message is the two characters \ and n once
184
+ // serialized, which survives whitespace normalization and breaks containment.
185
+ const haystack = realHaystack([
186
+ { role: 'assistant', content: 'Implemented the guard and pushed.\n\nAll 32 tests pass, nothing left here.' },
187
+ ]);
188
+ const check = verifyCompletionQuote(
189
+ 'Implemented the guard and pushed. All 32 tests pass, nothing left here.', haystack);
190
+ assert.equal(check.verified, true, 'decoding must happen before matching');
191
+ assert.equal(check.reason, 'found');
192
+ });
193
+
194
+ test('a quote absent from the transcript does not verify', () => {
195
+ const haystack = realHaystack([{ role: 'assistant', content: 'Still working through the migration.' }]);
196
+ const check = verifyCompletionQuote('Shipped it, everything is green and merged to main.', haystack);
197
+ assert.equal(check.verified, false);
198
+ assert.equal(check.reason, 'not-found');
199
+ });
200
+
201
+ test('an empty or trivially short quote reports absent rather than matching', () => {
202
+ const haystack = realHaystack([{ role: 'assistant', content: 'done' }]);
203
+ for (const q of ['', null, undefined, 'done']) {
204
+ assert.equal(verifyCompletionQuote(q, haystack).reason, 'absent-or-too-short');
205
+ }
206
+ });
207
+
208
+ test('a serialized tool-use argument is NOT quotable evidence', () => {
209
+ // Every TodoWrite call leaves its input_summary in the haystack. That blob is
210
+ // the single most quotable false positive in the corpus, so decodeTranscriptText
211
+ // drops tool_use blocks — a model quoting one gets no credit.
212
+ const haystack = realHaystack([{
213
+ role: 'assistant',
214
+ content: [{
215
+ type: 'tool_use',
216
+ name: 'TodoWrite',
217
+ input: { todos: [{ content: 'Wire the guard into Phase 2c', status: 'completed' }] },
218
+ }],
219
+ }]);
220
+ const check = verifyCompletionQuote('Wire the guard into Phase 2c', haystack);
221
+ assert.equal(check.verified, false, 'tool arguments must not read as completion evidence');
222
+ });
223
+
224
+ test('the quote NEVER changes the emit decision', () => {
225
+ // The load-bearing assertion of this whole section: instrumentation only.
226
+ const base = { statusStmt: stmtReturning(OPEN), confidence: 'medium' };
227
+ const withoutQuote = completionReviewEmitGuard('act:5555eeee', base);
228
+ const withQuote = completionReviewEmitGuard('act:5555eeee', { ...base, quote: 'anything at all' });
229
+ assert.deepEqual(withoutQuote, withQuote);
230
+ assert.equal(withQuote.emit, true);
231
+ });
232
+
233
+ test('decodeTranscriptText skips unparseable lines instead of raw-matching them', () => {
234
+ const decoded = decodeTranscriptText('{"content":"kept"}\nnot json at all\n{"content":"also kept"}');
235
+ assert.match(decoded, /kept/);
236
+ assert.match(decoded, /also kept/);
237
+ assert.doesNotMatch(decoded, /not json at all/);
176
238
  });
@@ -0,0 +1,132 @@
1
+ // Roster measurements as ambient state (act:ea23b3a5) — the Ring 1 render half.
2
+ //
3
+ // Ring 2 measures weekly into state/roster-review.json; Ring 1 renders into the
4
+ // per-project state file's Standing Issues, because Ring 1 owns that file. Same
5
+ // division as the recall canary (ring1: "the canary is Ring 2's, the render is
6
+ // Ring 1's").
7
+ //
8
+ // Two rules the render must hold, both learned the hard way:
9
+ // - the COUNT is true and the NAMES are what the operator can act on. The old
10
+ // queue item reported a saturated display cap — every project read exactly
11
+ // "12 dead", forever.
12
+ // - render ON CHANGE only. An unchanging number in a file read at every
13
+ // briefing is furniture by the third read; the queue item at least had a
14
+ // dismiss verb, and ambient state has none.
15
+ //
16
+ // Pure function, no I/O.
17
+
18
+ import { test } from 'node:test';
19
+ import assert from 'node:assert/strict';
20
+ import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
21
+ import { join } from 'node:path';
22
+ import { tmpdir } from 'node:os';
23
+
24
+ const root = mkdtempSync(join(tmpdir(), 'roster-ambient-'));
25
+ process.env.WATCHTOWER_DIR = root;
26
+ mkdirSync(join(root, 'state'), { recursive: true });
27
+
28
+ const { renderRosterEntries, assembleProjectState } = await import('../watchtower-ring1.mjs');
29
+
30
+ test.after(() => rmSync(root, { recursive: true, force: true }));
31
+
32
+ const MEASURED = '2026-07-21T10:00:00.000Z';
33
+
34
+ test('a first measurement renders, with true counts and the skill names', () => {
35
+ const lines = renderRosterEntries({
36
+ measured_at: MEASURED,
37
+ dormant: { dead_count: 14, stale_count: 11, dead: ['audit', 'retire-me', 'verify-learn'], stale: [], days: 30 },
38
+ previous: null,
39
+ });
40
+ assert.equal(lines.length, 1);
41
+ assert.match(lines[0], /dormant skills: 14 never invoked/);
42
+ assert.match(lines[0], /11 stale >30d/);
43
+ assert.match(lines[0], /audit, retire-me, verify-learn/, 'names are the actionable half');
44
+ assert.match(lines[0], /measured 2026-07-21/, 'as-of stamp — this is a weekly measurement, not live');
45
+ });
46
+
47
+ test('an UNCHANGED count renders nothing', () => {
48
+ const lines = renderRosterEntries({
49
+ measured_at: MEASURED,
50
+ dormant: { dead_count: 12, stale_count: 4, dead: ['a'], stale: [], days: 30 },
51
+ previous: { dead_count: 12, stale_count: 4 },
52
+ });
53
+ assert.deepEqual(lines, [], 'a number that never moves never needs a human');
54
+ });
55
+
56
+ test('a CHANGED count renders and shows the movement', () => {
57
+ const lines = renderRosterEntries({
58
+ measured_at: MEASURED,
59
+ dormant: { dead_count: 14, stale_count: 4, dead: ['a'], stale: [], days: 30 },
60
+ previous: { dead_count: 11, stale_count: 4 },
61
+ });
62
+ assert.equal(lines.length, 1);
63
+ assert.match(lines[0], /14 never invoked \(was 11\)/);
64
+ });
65
+
66
+ test('a change in only the stale count still renders', () => {
67
+ const lines = renderRosterEntries({
68
+ measured_at: MEASURED,
69
+ dormant: { dead_count: 12, stale_count: 9, dead: ['a'], stale: [], days: 30 },
70
+ previous: { dead_count: 12, stale_count: 4 },
71
+ });
72
+ assert.equal(lines.length, 1);
73
+ });
74
+
75
+ test('zero dormant skills render nothing at all', () => {
76
+ assert.deepEqual(renderRosterEntries({
77
+ measured_at: MEASURED,
78
+ dormant: { dead_count: 0, stale_count: 0, dead: [], stale: [], days: 30 },
79
+ previous: null,
80
+ }), []);
81
+ });
82
+
83
+ test('the name list is capped but the count is not', () => {
84
+ const dead = Array.from({ length: 20 }, (_, i) => `skill-${i}`);
85
+ const lines = renderRosterEntries({
86
+ measured_at: MEASURED,
87
+ dormant: { dead_count: 20, stale_count: 0, dead, stale: [], days: 30 },
88
+ previous: null,
89
+ }, 3);
90
+ assert.match(lines[0], /dormant skills: 20 never invoked/, 'the count is true');
91
+ assert.match(lines[0], /skill-0, skill-1, skill-2, \+17 more/, 'the enumeration is bounded');
92
+ });
93
+
94
+ test('stale briefings render their own line with the oldest age', () => {
95
+ const lines = renderRosterEntries({
96
+ measured_at: MEASURED,
97
+ stale_briefings: [
98
+ { name: '_briefing-architecture.md', ageDaysBehind: 63 },
99
+ { name: '_briefing-cabinet.md', ageDaysBehind: 12 },
100
+ ],
101
+ });
102
+ assert.equal(lines.length, 1);
103
+ assert.match(lines[0], /stale cabinet briefings: 2, oldest ~63d behind/);
104
+ });
105
+
106
+ test('absent, empty, or malformed metrics render nothing rather than throwing', () => {
107
+ for (const input of [null, undefined, {}, 'nonsense', 42, { dormant: null }, { stale_briefings: [] }]) {
108
+ assert.deepEqual(renderRosterEntries(input), [], `input ${JSON.stringify(input)}`);
109
+ }
110
+ });
111
+
112
+ test('the lines reach Standing Issues in the assembled project state', () => {
113
+ const state = assembleProjectState({
114
+ name: 'proj', path: '/tmp/proj',
115
+ pib: { openActions: 3, flaggedCount: 0 },
116
+ roster: {
117
+ measured_at: MEASURED,
118
+ dormant: { dead_count: 14, stale_count: 2, dead: ['audit'], stale: [], days: 30 },
119
+ previous: { dead_count: 11, stale_count: 2 },
120
+ },
121
+ });
122
+ assert.match(state, /## Standing Issues/);
123
+ assert.match(state, /- dormant skills: 14 never invoked \(was 11\)/);
124
+ });
125
+
126
+ test('a project with no roster entry renders no roster lines', () => {
127
+ const state = assembleProjectState({
128
+ name: 'proj', path: '/tmp/proj', pib: { openActions: 0, flaggedCount: 0 }, roster: null,
129
+ });
130
+ assert.doesNotMatch(state, /dormant skills/);
131
+ assert.match(state, /## Standing Issues/);
132
+ });