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
@@ -0,0 +1,130 @@
1
+ // Per-category expiry (act:ea23b3a5).
2
+ //
3
+ // Two engines expire queue items: Ring 2's `escalateQueueItems` (the 5-minute
4
+ // cron) and `runExpiry` (called by /inbox and /briefing). Each used to hardcode
5
+ // 30 days and inline its own qa-handoff carve-out, so a per-category policy set
6
+ // in one would be silently violated by whichever engine reached an item first.
7
+ // The table now lives in watchtower-queue and both delegate.
8
+ //
9
+ // The qa-handoff exemption is STRUCTURAL, not a preference: `expireItem` throws
10
+ // on a qa-handoff by the recipient-gate contract, so expiryDaysFor returning
11
+ // anything but null for it would crash the caller's loop.
12
+ //
13
+ // Hermetic: WATCHTOWER_DIR points at a temp dir before the dynamic import
14
+ // (the house pattern), plus a read-only source scan for the wiring assertions.
15
+
16
+ import { test } from 'node:test';
17
+ import assert from 'node:assert/strict';
18
+ import { mkdtempSync, mkdirSync, rmSync, readFileSync, writeFileSync } from 'node:fs';
19
+ import { join, dirname, resolve } from 'node:path';
20
+ import { tmpdir } from 'node:os';
21
+ import { fileURLToPath } from 'node:url';
22
+
23
+ const root = mkdtempSync(join(tmpdir(), 'category-expiry-'));
24
+ process.env.WATCHTOWER_DIR = root;
25
+ mkdirSync(join(root, 'queue', 'items'), { recursive: true });
26
+
27
+ const q = await import('../watchtower-queue.mjs');
28
+ const scriptsDir = resolve(dirname(fileURLToPath(import.meta.url)), '..');
29
+
30
+ test.after(() => rmSync(root, { recursive: true, force: true }));
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // The table
34
+ // ---------------------------------------------------------------------------
35
+
36
+ test('completion-review expires at 14 days, everything else at the 30-day default', () => {
37
+ assert.equal(q.expiryDaysFor('completion-review'), 14);
38
+ assert.equal(q.expiryDaysFor('knowledge-extraction'), 30);
39
+ assert.equal(q.expiryDaysFor('doc-drift'), 30);
40
+ assert.equal(q.expiryDaysFor('routine'), 30);
41
+ assert.equal(q.DEFAULT_EXPIRE_DAYS, 30);
42
+ });
43
+
44
+ test('qa-handoff never expires — null, because expireItem THROWS on it', () => {
45
+ assert.equal(q.expiryDaysFor('qa-handoff'), null);
46
+ assert.equal(q.categoryNeverExpires('qa-handoff'), true);
47
+ assert.equal(q.categoryNeverExpires('completion-review'), false);
48
+ });
49
+
50
+ test('an unknown category falls back to the default rather than never-expiring', () => {
51
+ // Fail direction: a category nobody registered should still age out, not
52
+ // accumulate forever behind a silent null.
53
+ assert.equal(q.expiryDaysFor('some-future-category'), 30);
54
+ assert.equal(q.expiryDaysFor(undefined), 30);
55
+ });
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // runExpiry honours the table
59
+ // ---------------------------------------------------------------------------
60
+
61
+ function writeItem({ id, category, ageDays }) {
62
+ const filed = new Date(Date.now() - ageDays * 24 * 60 * 60 * 1000).toISOString();
63
+ writeFileSync(join(root, 'queue', 'items', `${id}.json`), JSON.stringify({
64
+ schema_version: 1, id, project: 'p', project_path: '/tmp/p',
65
+ filed_at: filed, filed_by: 'test', status: 'pending',
66
+ category, urgency: 'normal', title: `t-${id}`, summary: 's',
67
+ }, null, 2) + '\n');
68
+ }
69
+
70
+ test('runExpiry expires a 20-day completion-review but not a 20-day knowledge-extraction', () => {
71
+ writeItem({ id: 'dec-cr20', category: 'completion-review', ageDays: 20 });
72
+ writeItem({ id: 'dec-ke20', category: 'knowledge-extraction', ageDays: 20 });
73
+ const { expired } = q.runExpiry();
74
+ const ids = expired.map((i) => i.id);
75
+ assert.ok(ids.includes('dec-cr20'), 'completion-review past 14d expires');
76
+ assert.ok(!ids.includes('dec-ke20'), 'knowledge-extraction at 20d is still inside its 30d policy');
77
+ assert.match(
78
+ expired.find((i) => i.id === 'dec-cr20').resolution_notes, /after 14 days/,
79
+ 'the note states the policy that actually applied, not the default');
80
+ });
81
+
82
+ test('runExpiry never expires a qa-handoff, however old', () => {
83
+ writeItem({ id: 'dec-qa99', category: 'qa-handoff', ageDays: 99 });
84
+ const { expired, warned } = q.runExpiry();
85
+ assert.ok(!expired.some((i) => i.id === 'dec-qa99'));
86
+ assert.ok(warned.some((i) => i.id === 'dec-qa99'), 'it warns instead — QA debt stays surfaced');
87
+ });
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Wiring — a correct table nobody calls is not a fix
91
+ // ---------------------------------------------------------------------------
92
+ //
93
+ // Read-only source scan, the house precedent (detector-registry.test.mjs, the
94
+ // suppression-ledger wiring assertion). `escalateQueueItems` is module-private
95
+ // in ring2, so behaviour cannot be exercised directly from here.
96
+
97
+ test('Ring 2 escalation delegates to expiryDaysFor and keeps no local expire constant', () => {
98
+ const src = readFileSync(join(scriptsDir, 'watchtower-ring2.mjs'), 'utf8');
99
+ assert.match(src, /expiryDaysFor/, 'ring2 must call the shared helper');
100
+ assert.match(src, /import \{[^}]*expiryDaysFor[^}]*\} from '\.\/watchtower-queue\.mjs'/s,
101
+ 'and import it from the queue, not redefine it');
102
+ assert.doesNotMatch(src, /const ESCALATION_EXPIRE_DAYS\s*=/,
103
+ 'a local expire constant is the divergence this consolidation removed');
104
+ assert.doesNotMatch(src, /item\.category !== 'qa-handoff'/,
105
+ 'the inlined carve-out belongs to categoryNeverExpires now');
106
+ });
107
+
108
+ test('runExpiry consults the per-category table, not just its expireDays argument', () => {
109
+ const src = readFileSync(join(scriptsDir, 'watchtower-queue.mjs'), 'utf8');
110
+ const body = src.slice(src.indexOf('export function runExpiry'));
111
+ assert.match(body.slice(0, 1500), /CATEGORY_EXPIRE_DAYS/,
112
+ 'the /inbox and /briefing engine must honour overrides too');
113
+ assert.match(body.slice(0, 1500), /categoryNeverExpires/);
114
+ });
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // The coupling that makes the 14-day policy safe
118
+ // ---------------------------------------------------------------------------
119
+
120
+ test('Phase 2c dedup includes expired and superseded — or 14d expiry is a refile loop', () => {
121
+ // COMPLETION_REVIEW_DEDUP_DAYS is also 14, so an item leaves the dedup window
122
+ // at exactly the moment it expires. Without these two statuses the fid is in
123
+ // no corpus at all and the next session refiles it — forever (Detector
124
+ // Symmetry §2). This assertion is the guard on that coupling.
125
+ const src = readFileSync(join(scriptsDir, 'watchtower-ring3-close.mjs'), 'utf8');
126
+ const at = src.indexOf("category: 'completion-review',\n statuses:");
127
+ const window = at >= 0 ? src.slice(at, at + 400) : src;
128
+ assert.match(window, /statuses: \[[^\]]*'expired'[^\]]*\]/s);
129
+ assert.match(window, /statuses: \[[^\]]*'superseded'[^\]]*\]/s);
130
+ });
@@ -24,10 +24,16 @@ const lib = await import('../watchtower-lib.mjs');
24
24
 
25
25
  const here = dirname(fileURLToPath(import.meta.url));
26
26
  const scriptsDir = resolve(here, '..');
27
+ // act:ea23b3a5 added ring4 and the routine engine. They were ring-filing
28
+ // categories (`doc-drift`, `routine`) the census could not see, so the
29
+ // contract's claim to map "every ring-filed category" was false — flagged in
30
+ // act:c2f06955 item 5 and closed here, because this program edits both files.
27
31
  const RING_SCRIPTS = [
28
32
  'watchtower-ring1.mjs',
29
33
  'watchtower-ring2.mjs',
30
34
  'watchtower-ring3-close.mjs',
35
+ 'watchtower-ring4.mjs',
36
+ 'watchtower-routines.mjs',
31
37
  ];
32
38
 
33
39
  // Call openers that can own a `category:` property, matched as
@@ -99,18 +105,46 @@ test('every filing-site category is a statically parseable string literal', () =
99
105
  });
100
106
 
101
107
  test('parser sanity: a real filing-site census, not a vacuous pass', () => {
102
- // 2026-07-13 census: 2 ring1 + 8 ring2 + 9 ring3 filing sites across 16
103
- // distinct categories. Floors, not exact counts categories may be added
104
- // (with registry entries) without touching this test.
105
- assert.ok(totalFilingSites >= 15,
106
- `expected ≥15 createItem filing sites across the rings, parsed ${totalFilingSites}`);
107
- assert.ok(allFiling.size >= 16,
108
- `expected ≥16 distinct filed categories, parsed ${allFiling.size}`);
108
+ // 2026-07-26 census (act:ea23b3a5, after ring4 + routines joined the scan):
109
+ // 27 filing sites across 20 distinct categories, 20 registry entries.
110
+ // The prior census was 2026-07-13: 19 sites / 16 categories over three
111
+ // scripts. Floors, not exact counts — categories may be added (with registry
112
+ // entries) without touching this test.
113
+ assert.ok(totalFilingSites >= 25,
114
+ `expected ≥25 createItem filing sites across the rings, parsed ${totalFilingSites}`);
115
+ assert.ok(allFiling.size >= 20,
116
+ `expected ≥20 distinct filed categories, parsed ${allFiling.size}`);
109
117
  // Read filters exist and were NOT counted as filings (the conflation guard):
110
118
  assert.ok(allReads.size >= 3,
111
119
  `expected the known listPending/listItems category filters to classify as reads, parsed ${allReads.size}`);
112
120
  });
113
121
 
122
+ test('the two categories ring4 and routines file are registered — the gap act:c2f06955 named', () => {
123
+ // Before act:ea23b3a5 the census scanned only ring1/2/3, so these two shipped
124
+ // outside the registry entirely and the contract's "maps every ring-filed
125
+ // category" claim was false.
126
+ for (const cat of ['doc-drift', 'routine']) {
127
+ assert.ok(allFiling.has(cat), `${cat} must be visible to the census`);
128
+ assert.ok(cat in lib.INBOX_DETECTOR_REGISTRY, `${cat} must be registered`);
129
+ }
130
+ });
131
+
132
+ test('watchtower-health declares a reconciler now that memory-budget retracts', () => {
133
+ // It was exempt ("recovery is visible in the sidecars") while three
134
+ // memory-budget alarms sat pending for days after the condition cleared.
135
+ assert.ok(lib.INBOX_DETECTOR_REGISTRY['watchtower-health']?.reconciler);
136
+ assert.match(lib.INBOX_DETECTOR_REGISTRY['watchtower-health'].reconciler,
137
+ /retractClearedMemoryBudgetItems/);
138
+ });
139
+
140
+ test('completion-review records the confidence inversion, not the deleted filter', () => {
141
+ const entry = lib.INBOX_DETECTOR_REGISTRY['completion-review']?.reconciler || '';
142
+ assert.match(entry, /anti-predictive/,
143
+ 'the registry must carry the measurement, or the next round re-derives the wrong theory');
144
+ assert.match(entry, /expired/i);
145
+ assert.match(entry, /superseded/i);
146
+ });
147
+
114
148
  test('every ring-filed category is registered with a reconciler or an explicit exemption', () => {
115
149
  const unregistered = [...allFiling.keys()]
116
150
  .filter(cat => !(cat in lib.INBOX_DETECTOR_REGISTRY));
@@ -18,6 +18,12 @@
18
18
  // C6: createItem's boundary check rejects an illegal type/home token, an
19
19
  // unclassifiable item missing its reason, and a derivable:true item
20
20
  // missing its derivation — mirroring the merge_state precedent.
21
+ // C7 (act:a69c21f8): a PERISHABLE item — true at a moment, will become
22
+ // false, NOT recomputable (the plan's anticipated single-valued-but-
23
+ // not-derivable specimen) — is routed to home:'session-record', never
24
+ // memory, ALWAYS filed, with its perishes_when visible; derivable WINS
25
+ // when both flags are set; perishable:true without perishes_when is
26
+ // rejected at the boundary.
21
27
  //
22
28
  // Hermetic: WATCHTOWER_DIR points at a temp dir, set BEFORE the dynamic
23
29
  // imports. No live Anthropic calls — callFn is injected throughout.
@@ -78,6 +84,7 @@ test('C2: the prompt\'s JSON schema literal matches the exported four-axis vocab
78
84
  `prompt home schema must read exactly "${homesLiteral}"`);
79
85
  // The taxonomy is present in prose too — not just the trailing JSON literal.
80
86
  assert.ok(/DERIVABLE/.test(prompt));
87
+ assert.ok(/PERISHABLE/.test(prompt));
81
88
  assert.ok(/SUBJECT/.test(prompt));
82
89
  assert.ok(/UNCLASSIFIABLE/.test(prompt));
83
90
  });
@@ -216,4 +223,84 @@ test('C6e: KNOWLEDGE_EXTRACTION_TYPES/HOMES are exported consistently from both
216
223
  assert.deepEqual(q.KNOWLEDGE_EXTRACTION_HOMES, lib.KNOWLEDGE_EXTRACTION_HOMES);
217
224
  assert.ok(q.KNOWLEDGE_EXTRACTION_TYPES.includes('unclassifiable'));
218
225
  assert.ok(q.KNOWLEDGE_EXTRACTION_HOMES.includes('derivation'));
226
+ assert.ok(q.KNOWLEDGE_EXTRACTION_HOMES.includes('session-record'));
227
+ });
228
+
229
+ // --- C7 (act:a69c21f8): perishable facts — true now, false soon, not recomputable ---
230
+
231
+ test('C7: a perishable ("phase 7 hasn\'t started yet") draft routes to session-record, never memory, never dropped', async () => {
232
+ clearQueue();
233
+ const callFn = async () => JSON.stringify([{
234
+ type: 'lesson', home: 'session-record', urgency: 'normal',
235
+ title: "phase 7 hasn't started yet",
236
+ content: 'As of this session, phase 7 of the ed-message rollout has not begun.',
237
+ perishable: true, perishes_when: 'the day phase 7 work starts',
238
+ subject: 'classify-proj', covered_by: 'none',
239
+ }]);
240
+ const r = await r3.decisionExtraction('transcript', project(), 'sess-perishable', '/t/x.jsonl', [], { callFn });
241
+ assert.equal(r.queued, 1, 'a perishable item is ALWAYS filed — never dropped');
242
+
243
+ const [item] = readItems();
244
+ assert.equal(item.evidence.home, 'session-record', 'routed to the session record, not memory');
245
+ assert.equal(item.evidence.perishable, true);
246
+ assert.equal(item.evidence.perishes_when, 'the day phase 7 work starts');
247
+ assert.equal(item.evidence.derivable, undefined, 'the derivable shape is absent — the two never mix');
248
+ assert.equal(item.draft_artifact, null, 'a perishable fact is never written as a memory draft');
249
+ assert.ok(item.summary.includes('Perishes:'), 'what makes it false is visible to the human');
250
+ assert.deepEqual(item.options.map((o) => o.key).sort(), ['acknowledge', 'dismiss']);
251
+ });
252
+
253
+ test('C7b: derivable WINS when both flags are set — the derivation is the better record', async () => {
254
+ clearQueue();
255
+ const callFn = async () => JSON.stringify([{
256
+ type: 'lesson', home: 'derivation', urgency: 'normal',
257
+ title: 'prod is at commit 35a5d15',
258
+ content: 'A deploy SHA is both recomputable and destined to change.',
259
+ derivable: true, derivation: 'check the deploy log or git log on the remote',
260
+ perishable: true, perishes_when: 'the next deploy',
261
+ subject: 'classify-proj', covered_by: 'none',
262
+ }]);
263
+ const r = await r3.decisionExtraction('transcript', project(), 'sess-both-flags', '/t/x.jsonl', [], { callFn });
264
+ assert.equal(r.queued, 1);
265
+ const [item] = readItems();
266
+ assert.equal(item.evidence.home, 'derivation');
267
+ assert.equal(item.evidence.derivable, true);
268
+ assert.equal(item.evidence.perishable, undefined, 'the perishable shape yields to derivable');
269
+ assert.equal(item.evidence.perishes_when, undefined);
270
+ assert.ok(item.summary.includes('Derivation:'));
271
+ });
272
+
273
+ test('C7c: a durable lesson carries neither flag — unaffected by the perishable path', async () => {
274
+ clearQueue();
275
+ const callFn = async () => JSON.stringify([{
276
+ type: 'lesson', home: 'memory', urgency: 'normal',
277
+ title: "git never reads a worktree's own info/exclude",
278
+ content: 'Git never consults a linked worktree\'s worktrees/<name>/info/exclude — per-worktree excludes need extensions.worktreeConfig plus a worktree-scoped core.excludesFile.',
279
+ subject: 'classify-proj', covered_by: 'none',
280
+ }]);
281
+ const r = await r3.decisionExtraction('transcript', project(), 'sess-durable-2', '/t/x.jsonl', [], { callFn });
282
+ assert.equal(r.queued, 1);
283
+ const [item] = readItems();
284
+ assert.equal(item.evidence.home, 'memory');
285
+ assert.equal(item.evidence.perishable, undefined);
286
+ assert.ok(item.draft_artifact, 'a durable lesson still gets its memory draft');
287
+ });
288
+
289
+ test('C7d: createItem rejects perishable:true without perishes_when', () => {
290
+ assert.throws(
291
+ () => q.createItem({
292
+ project: 'boundary-proj', project_path: '/tmp/boundary-proj', category: 'knowledge-extraction',
293
+ title: 'x', summary: 'x', context_anchor: 'x',
294
+ evidence: { type: 'lesson', home: 'session-record', perishable: true },
295
+ }),
296
+ /perishable:true requires evidence\.perishes_when/,
297
+ );
298
+ assert.throws(
299
+ () => q.createItem({
300
+ project: 'boundary-proj', project_path: '/tmp/boundary-proj', category: 'knowledge-extraction',
301
+ title: 'x', summary: 'x', context_anchor: 'x',
302
+ evidence: { type: 'lesson', home: 'session-record', perishable: true, perishes_when: ' ' },
303
+ }),
304
+ /perishable:true requires evidence\.perishes_when/,
305
+ );
219
306
  });
@@ -0,0 +1,180 @@
1
+ // Friction actionability gate (act:ea23b3a5) — Phase 2g's three arms.
2
+ //
3
+ // Five of the 2026-07-26 dismissals were friction reports with no party who
4
+ // could act on them: "Claude made iterative browser tweaks without writing to
5
+ // file", "Agent spawn interrupted by credit limit mid-task". True observations,
6
+ // addressed to nobody, aging in the queue for weeks.
7
+ //
8
+ // The gate copies Phase 2f's shape exactly — that lens files a methodology
9
+ // capture only when the claimed artifact verifiably exists on disk, after 11 of
10
+ // 11 unverified captures were dismissed over three weeks.
11
+ //
12
+ // The THIRD arm comes from the data rather than from theory: of the five
13
+ // friction items the operator KEPT, four exited as `captured-to-memory` —
14
+ // platform constraints with a durable workaround. Those have no upstream route
15
+ // and never will, so a two-arm gate would have thrown away the useful half of
16
+ // this lens.
17
+ //
18
+ // Hermetic: real queue in a temp WATCHTOWER_DIR, injected Claude call.
19
+
20
+ import { test } from 'node:test';
21
+ import assert from 'node:assert/strict';
22
+ import { mkdtempSync, mkdirSync, rmSync, readdirSync, readFileSync } from 'node:fs';
23
+ import { join } from 'node:path';
24
+ import { tmpdir } from 'node:os';
25
+
26
+ const root = mkdtempSync(join(tmpdir(), 'friction-gate-'));
27
+ process.env.WATCHTOWER_DIR = root;
28
+ mkdirSync(join(root, 'queue', 'items'), { recursive: true });
29
+
30
+ const r3 = await import('../watchtower-ring3-close.mjs');
31
+
32
+ test.after(() => rmSync(root, { recursive: true, force: true }));
33
+
34
+ function listQueue() {
35
+ const dir = join(root, 'queue', 'items');
36
+ let names = [];
37
+ try { names = readdirSync(dir).filter((f) => f.endsWith('.json')); } catch { return []; }
38
+ return names.map((f) => JSON.parse(readFileSync(join(dir, f), 'utf8')));
39
+ }
40
+ function clearQueue() {
41
+ rmSync(join(root, 'queue'), { recursive: true, force: true });
42
+ mkdirSync(join(root, 'queue', 'items'), { recursive: true });
43
+ }
44
+
45
+ const PROJECT = { name: 'proj', path: '/tmp/proj' };
46
+ const run = (findings) => r3.upstreamFriction('transcript', PROJECT, [], {
47
+ callFn: async () => JSON.stringify(findings),
48
+ });
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // The predicate
52
+ // ---------------------------------------------------------------------------
53
+
54
+ test('a route is actionable only with a known type AND a non-empty detail', () => {
55
+ assert.equal(r3.frictionRouteIsActionable({ type: 'cc-feedback', detail: 'fix the skill' }), true);
56
+ assert.equal(r3.frictionRouteIsActionable({ type: 'project-tracker', detail: 'file an action' }), true);
57
+ assert.equal(r3.frictionRouteIsActionable({ type: 'config-change', detail: 'set the hook' }), true);
58
+
59
+ assert.equal(r3.frictionRouteIsActionable({ type: 'cc-feedback', detail: ' ' }), false,
60
+ 'an empty detail is a route in name only');
61
+ assert.equal(r3.frictionRouteIsActionable({ type: 'cc-feedback' }), false);
62
+ assert.equal(r3.frictionRouteIsActionable({ type: 'somewhere-else', detail: 'x' }), false,
63
+ 'the vocabulary is CLOSED — an invented route must not become an escape hatch');
64
+ assert.equal(r3.frictionRouteIsActionable(null), false);
65
+ assert.equal(r3.frictionRouteIsActionable(undefined), false);
66
+ });
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Arm 1 — a named route files upstream-friction, as before
70
+ // ---------------------------------------------------------------------------
71
+
72
+ test('a routed finding files upstream-friction and carries the route', async () => {
73
+ clearQueue();
74
+ await run([{
75
+ title: 'qa-handoff skill omits the merge sha',
76
+ description: 'The skill text never asks for merged_commit.',
77
+ severity: 'high',
78
+ route: { type: 'cc-feedback', detail: 'add merged_commit to the qa-handoff SKILL.md prompt' },
79
+ }]);
80
+
81
+ const items = listQueue();
82
+ assert.equal(items.length, 1);
83
+ assert.equal(items[0].category, 'upstream-friction');
84
+ assert.equal(items[0].urgency, 'urgent', 'severity high still escalates');
85
+ assert.equal(items[0].evidence.route.type, 'cc-feedback');
86
+ assert.match(items[0].summary, /Route: cc-feedback/,
87
+ 'the operator can see who acts without opening the evidence');
88
+ });
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Arm 2 — a durable constraint with no route becomes knowledge
92
+ // ---------------------------------------------------------------------------
93
+
94
+ test('a durable no-route constraint files knowledge-extraction, not upstream-friction', async () => {
95
+ clearQueue();
96
+ await run([{
97
+ title: 'Claude-in-Chrome cannot connect while Claude Desktop is running',
98
+ description: 'Quit Desktop first; the extension then attaches on the next navigate.',
99
+ severity: 'medium',
100
+ route: null,
101
+ durable: true,
102
+ }]);
103
+
104
+ const items = listQueue();
105
+ assert.equal(items.length, 1);
106
+ assert.equal(items[0].category, 'knowledge-extraction',
107
+ 'four of the five KEPT friction items became memory — that arm must exist');
108
+ assert.equal(items[0].evidence.type, 'constraint');
109
+ assert.equal(items[0].evidence.home, 'memory');
110
+ assert.equal(items[0].evidence.source, 'friction-lens');
111
+ });
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Arm 3 — neither routed nor durable files nothing
115
+ // ---------------------------------------------------------------------------
116
+
117
+ test('a transient or behavioral observation files NOTHING', async () => {
118
+ clearQueue();
119
+ await run([
120
+ { title: 'Agent spawn interrupted by credit limit mid-task', description: 'x', severity: 'low', route: null, durable: false },
121
+ { title: 'Claude made iterative browser tweaks without writing to file', description: 'y', severity: 'medium', route: null, durable: false },
122
+ { title: 'Edit tool failed with a string mismatch after live DOM changes', description: 'z', severity: 'low' },
123
+ ]);
124
+ assert.deepEqual(listQueue(), [], 'these three are real 2026-07-26 dismissals');
125
+ });
126
+
127
+ test('an invented route type is dropped, not filed', async () => {
128
+ clearQueue();
129
+ await run([{
130
+ title: 'something annoying', description: 'x', severity: 'low',
131
+ route: { type: 'someone-should-fix-this', detail: 'please' },
132
+ }]);
133
+ assert.deepEqual(listQueue(), []);
134
+ });
135
+
136
+ test('a route with an empty detail is dropped, not filed', async () => {
137
+ clearQueue();
138
+ await run([{
139
+ title: 'vague', description: 'x', severity: 'low',
140
+ route: { type: 'cc-feedback', detail: '' },
141
+ }]);
142
+ assert.deepEqual(listQueue(), []);
143
+ });
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Mixed batches and the counting contract
147
+ // ---------------------------------------------------------------------------
148
+
149
+ test('a mixed batch routes each finding independently', async () => {
150
+ clearQueue();
151
+ await run([
152
+ { title: 'a', description: 'a', severity: 'low', route: { type: 'config-change', detail: 'flip the flag' } },
153
+ { title: 'b', description: 'b', severity: 'low', route: null, durable: true },
154
+ { title: 'c', description: 'c', severity: 'low', route: null, durable: false },
155
+ ]);
156
+ const byCategory = listQueue().reduce((acc, i) => {
157
+ acc[i.category] = (acc[i.category] || 0) + 1; return acc;
158
+ }, {});
159
+ assert.deepEqual(byCategory, { 'upstream-friction': 1, 'knowledge-extraction': 1 });
160
+ });
161
+
162
+ test('an empty finding list and a malformed reply both file nothing and do not throw', async () => {
163
+ clearQueue();
164
+ await run([]);
165
+ await r3.upstreamFriction('transcript', PROJECT, [], { callFn: async () => 'not json at all' });
166
+ await r3.upstreamFriction('transcript', PROJECT, [], { callFn: async () => { throw new Error('api down'); } });
167
+ assert.deepEqual(listQueue(), []);
168
+ });
169
+
170
+ test('the drop count is observable — silence must not look like a lens that never ran', async () => {
171
+ // The log line carries all three counts. Capturing stdout is brittle, so
172
+ // assert the wiring instead: the counting branch exists and names all three.
173
+ const src = readFileSync(new URL('../watchtower-ring3-close.mjs', import.meta.url), 'utf8');
174
+ const at = src.indexOf('Phase 2g: friction —');
175
+ assert.ok(at > 0, 'the summary log line must exist');
176
+ const line = src.slice(at, at + 220);
177
+ assert.match(line, /routed/);
178
+ assert.match(line, /durable/);
179
+ assert.match(line, /dropped/);
180
+ });