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.
- package/lib/cli.js +2 -2
- package/package.json +1 -1
- package/templates/CLAUDE.md +164 -20
- package/templates/cabinet/watchtower-contracts.md +202 -10
- package/templates/mux/bin/mux +5 -0
- package/templates/mux/config/mux-server.py +7 -5
- package/templates/scripts/__tests__/category-expiry.test.mjs +130 -0
- package/templates/scripts/__tests__/detector-registry.test.mjs +41 -7
- package/templates/scripts/__tests__/extraction-classification.test.mjs +87 -0
- package/templates/scripts/__tests__/friction-actionability.test.mjs +180 -0
- package/templates/scripts/__tests__/memory-budget-debt.test.mjs +264 -0
- package/templates/scripts/__tests__/ring2-pattern-gate.test.mjs +315 -0
- package/templates/scripts/__tests__/ring2-roster-review.test.mjs +75 -16
- package/templates/scripts/__tests__/ring3-completion-filter.test.mjs +167 -105
- package/templates/scripts/__tests__/roster-ambient-render.test.mjs +132 -0
- package/templates/scripts/__tests__/routine-dispatch.test.mjs +29 -1
- package/templates/scripts/__tests__/routine-moment-expiry.test.mjs +192 -0
- package/templates/scripts/skill-validator.sh +7 -4
- package/templates/scripts/watchtower-lib.mjs +58 -5
- package/templates/scripts/watchtower-queue.mjs +54 -7
- package/templates/scripts/watchtower-ring1.mjs +74 -0
- package/templates/scripts/watchtower-ring2.mjs +442 -81
- package/templates/scripts/watchtower-ring3-close.mjs +271 -82
- package/templates/scripts/watchtower-routines.mjs +107 -1
- package/templates/skills/cabinet-anthropic-insider/SKILL.md +35 -2
- package/templates/skills/cabinet-anti-confirmation/SKILL.md +24 -0
- package/templates/skills/cabinet-cc-health/SKILL.md +22 -0
- package/templates/skills/cabinet-data-integrity/SKILL.md +19 -0
- package/templates/skills/cabinet-debugger/SKILL.md +36 -0
- package/templates/skills/cabinet-deployment/SKILL.md +22 -0
- package/templates/skills/cabinet-elegance/SKILL.md +12 -0
- package/templates/skills/cabinet-goal-alignment/SKILL.md +11 -0
- package/templates/skills/cabinet-historian/SKILL.md +33 -0
- package/templates/skills/cabinet-organized-mind/SKILL.md +11 -0
- package/templates/skills/cabinet-process-therapist/SKILL.md +132 -6
- package/templates/skills/cabinet-qa/SKILL.md +63 -0
- package/templates/skills/cabinet-roster-check/SKILL.md +12 -0
- 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-
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
assert.ok(
|
|
108
|
-
`expected ≥
|
|
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
|
+
});
|