create-claude-cabinet 0.45.0 → 0.46.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 (53) hide show
  1. package/README.md +4 -4
  2. package/lib/cli.js +26 -0
  3. package/lib/engagement-server-setup.js +34 -9
  4. package/lib/migrate-from-omega.js +13 -1
  5. package/lib/mux-setup.js +33 -9
  6. package/lib/watchtower-setup.js +210 -0
  7. package/package.json +5 -1
  8. package/templates/cabinet/_cabinet-member-template.md +8 -3
  9. package/templates/cabinet/advisories-state-schema.md +34 -7
  10. package/templates/cabinet/composition-patterns.md +4 -3
  11. package/templates/cabinet/skill-output-conventions.md +35 -1
  12. package/templates/cabinet/watchtower-contracts.md +89 -1
  13. package/templates/engagement/pib-db-patches/pib-db-lib.mjs +10 -1
  14. package/templates/mux/__tests__/mux-fail-loud.fixture.sh +44 -0
  15. package/templates/mux/__tests__/station-liveness.fixture.sh +234 -0
  16. package/templates/mux/__tests__/station-liveness.test.mjs +47 -0
  17. package/templates/mux/bin/mux +281 -55
  18. package/templates/scripts/__tests__/advisor-pass.test.mjs +238 -0
  19. package/templates/scripts/__tests__/advisories.test.mjs +262 -0
  20. package/templates/scripts/__tests__/batch-disposition.test.mjs +137 -0
  21. package/templates/scripts/__tests__/feedback-outbox-flush.test.mjs +232 -0
  22. package/templates/scripts/__tests__/qa-handoff-gate.test.mjs +68 -0
  23. package/templates/scripts/__tests__/ring-state-ownership.test.mjs +108 -3
  24. package/templates/scripts/__tests__/ring2-thread-context.test.mjs +189 -0
  25. package/templates/scripts/__tests__/ring3-dedup.test.mjs +387 -0
  26. package/templates/scripts/__tests__/routine-dispatch.test.mjs +312 -0
  27. package/templates/scripts/watchtower-advisories.mjs +305 -0
  28. package/templates/scripts/watchtower-build-context.mjs +110 -11
  29. package/templates/scripts/watchtower-lib.mjs +177 -1
  30. package/templates/scripts/watchtower-queue.mjs +146 -1
  31. package/templates/scripts/watchtower-ring1.mjs +129 -9
  32. package/templates/scripts/watchtower-ring2.mjs +118 -21
  33. package/templates/scripts/watchtower-ring3-close.mjs +466 -49
  34. package/templates/scripts/watchtower-routines.mjs +358 -0
  35. package/templates/scripts/watchtower-status.sh +1 -1
  36. package/templates/skills/audit/SKILL.md +5 -1
  37. package/templates/skills/briefing/SKILL.md +342 -234
  38. package/templates/skills/cabinet-anthropic-insider/SKILL.md +14 -6
  39. package/templates/skills/cabinet-historian/SKILL.md +14 -11
  40. package/templates/skills/cabinet-system-advocate/SKILL.md +22 -21
  41. package/templates/skills/cabinet-user-advocate/SKILL.md +13 -7
  42. package/templates/skills/cc-publish/SKILL.md +105 -19
  43. package/templates/skills/debrief/SKILL.md +127 -12
  44. package/templates/skills/execute/SKILL.md +6 -0
  45. package/templates/skills/inbox/SKILL.md +67 -6
  46. package/templates/skills/orient/SKILL.md +69 -47
  47. package/templates/skills/plan/SKILL.md +8 -0
  48. package/templates/skills/qa-drain/SKILL.md +119 -0
  49. package/templates/skills/session-handoff/SKILL.md +175 -6
  50. package/templates/skills/triage-audit/SKILL.md +6 -0
  51. package/templates/skills/watchtower/SKILL.md +46 -1
  52. package/templates/watchtower/config.json.template +3 -1
  53. package/templates/watchtower/queue/items/item.json.schema +1 -1
@@ -0,0 +1,232 @@
1
+ // flushFeedbackOutbox — Ring 1's feedback-delivery duty (act:6c3a4763).
2
+ // The trap this replaces: Ring 3's Phase 2h marked items delivered:true
3
+ // without writing anything anywhere. These guards pin the new contract:
4
+ // an outbox item leaves the outbox ONLY when a feedback file exists for it
5
+ // (freshly written, or already present in feedback/ / feedback/resolved/).
6
+ //
7
+ // Fixtures are self-contained temp dirs; outboxPath/registryPath/destination
8
+ // are injected via opts so no test touches the live ~/.claude.
9
+
10
+ import { test } from 'node:test';
11
+ import assert from 'node:assert/strict';
12
+ import {
13
+ mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync,
14
+ readdirSync, chmodSync,
15
+ } from 'node:fs';
16
+ import { join } from 'node:path';
17
+ import { tmpdir } from 'node:os';
18
+
19
+ const { flushFeedbackOutbox } = await import('../watchtower-lib.mjs');
20
+
21
+ const root = mkdtempSync(join(tmpdir(), 'fb-flush-'));
22
+ test.after(() => rmSync(root, { recursive: true, force: true }));
23
+
24
+ let caseId = 0;
25
+ function makeCase({ outbox, withCcRepo = true } = {}) {
26
+ const dir = join(root, `case-${caseId++}`);
27
+ mkdirSync(dir, { recursive: true });
28
+ const outboxPath = join(dir, 'cc-feedback-outbox.json');
29
+ if (outbox !== undefined) {
30
+ writeFileSync(outboxPath, typeof outbox === 'string' ? outbox : JSON.stringify(outbox, null, 2));
31
+ }
32
+ const ccRepo = join(dir, 'cc-repo');
33
+ const registryPath = join(dir, 'cc-registry.json');
34
+ if (withCcRepo) {
35
+ mkdirSync(ccRepo, { recursive: true });
36
+ writeFileSync(join(ccRepo, 'package.json'), JSON.stringify({ name: 'create-claude-cabinet' }));
37
+ writeFileSync(registryPath, JSON.stringify({
38
+ projects: [
39
+ { path: join(dir, 'not-cc'), name: 'other' },
40
+ { path: ccRepo, name: 'claude-cabinet' },
41
+ ],
42
+ }));
43
+ } else {
44
+ writeFileSync(registryPath, JSON.stringify({ projects: [] }));
45
+ }
46
+ return { dir, outboxPath, registryPath, ccRepo, feedbackDir: join(ccRepo, 'feedback') };
47
+ }
48
+
49
+ function item(overrides = {}) {
50
+ return {
51
+ source: 'flow', date: '2026-06-12', component: 'orient',
52
+ title: 'Orient phase X is broken', body: '# Feedback\n\nDetails here.\n',
53
+ status: 'pending', delivered: false,
54
+ ...overrides,
55
+ };
56
+ }
57
+
58
+ test('missing outbox is a silent no-op', () => {
59
+ const c = makeCase({});
60
+ const res = flushFeedbackOutbox({ outboxPath: c.outboxPath, registryPath: c.registryPath });
61
+ assert.equal(res.status, 'no-outbox');
62
+ assert.ok(!existsSync(c.outboxPath));
63
+ });
64
+
65
+ test('empty outbox is a no-op', () => {
66
+ const c = makeCase({ outbox: [] });
67
+ const res = flushFeedbackOutbox({ outboxPath: c.outboxPath, registryPath: c.registryPath });
68
+ assert.equal(res.status, 'empty');
69
+ });
70
+
71
+ test('malformed outbox resets to [] and reports it', () => {
72
+ const c = makeCase({ outbox: '{not json!' });
73
+ const res = flushFeedbackOutbox({ outboxPath: c.outboxPath, registryPath: c.registryPath });
74
+ assert.equal(res.status, 'malformed-reset');
75
+ assert.deepEqual(JSON.parse(readFileSync(c.outboxPath, 'utf8')), []);
76
+ });
77
+
78
+ test('delivers undelivered items as real files and resets outbox to []', () => {
79
+ const c = makeCase({ outbox: [item(), item({ title: 'Second issue', date: '2026-06-11' })] });
80
+ const res = flushFeedbackOutbox({ outboxPath: c.outboxPath, registryPath: c.registryPath });
81
+ assert.equal(res.status, 'ok');
82
+ assert.equal(res.delivered, 2);
83
+ const files = readdirSync(c.feedbackDir).sort();
84
+ assert.deepEqual(files, [
85
+ '2026-06-11-second-issue.md',
86
+ '2026-06-12-orient-phase-x-is-broken.md',
87
+ ]);
88
+ assert.equal(readFileSync(join(c.feedbackDir, '2026-06-12-orient-phase-x-is-broken.md'), 'utf8'),
89
+ '# Feedback\n\nDetails here.\n');
90
+ assert.deepEqual(JSON.parse(readFileSync(c.outboxPath, 'utf8')), []);
91
+ });
92
+
93
+ test('resolves the CC repo via registry by package.json name, not entry name', () => {
94
+ const c = makeCase({ outbox: [item()] });
95
+ // Registry already contains a non-CC entry first; delivery must land in cc-repo.
96
+ const res = flushFeedbackOutbox({ outboxPath: c.outboxPath, registryPath: c.registryPath });
97
+ assert.equal(res.destination, c.feedbackDir);
98
+ });
99
+
100
+ test('skip-if-exists: a file in feedback/ containing the slug blocks a rewrite', () => {
101
+ const c = makeCase({ outbox: [item()] });
102
+ mkdirSync(c.feedbackDir, { recursive: true });
103
+ writeFileSync(join(c.feedbackDir, '2026-06-10-orient-phase-x-is-broken-1.md'), 'older delivery');
104
+ const res = flushFeedbackOutbox({ outboxPath: c.outboxPath, registryPath: c.registryPath });
105
+ assert.equal(res.delivered, 0);
106
+ assert.equal(res.skipped, 1);
107
+ // Item still leaves the outbox — it IS delivered, just by a prior session.
108
+ assert.deepEqual(JSON.parse(readFileSync(c.outboxPath, 'utf8')), []);
109
+ assert.equal(readdirSync(c.feedbackDir).length, 1);
110
+ });
111
+
112
+ test('skip-if-exists: feedback/resolved/ also counts as already delivered', () => {
113
+ const c = makeCase({ outbox: [item()] });
114
+ mkdirSync(join(c.feedbackDir, 'resolved'), { recursive: true });
115
+ writeFileSync(join(c.feedbackDir, 'resolved', '2026-06-01-orient-phase-x-is-broken.md'), 'resolved long ago');
116
+ const res = flushFeedbackOutbox({ outboxPath: c.outboxPath, registryPath: c.registryPath });
117
+ assert.equal(res.skipped, 1);
118
+ assert.ok(!existsSync(join(c.feedbackDir, '2026-06-12-orient-phase-x-is-broken.md')));
119
+ });
120
+
121
+ test('no resolvable CC repo leaves the outbox untouched — items are never dropped', () => {
122
+ const c = makeCase({ outbox: [item()], withCcRepo: false });
123
+ const res = flushFeedbackOutbox({ outboxPath: c.outboxPath, registryPath: c.registryPath });
124
+ assert.equal(res.status, 'no-destination');
125
+ assert.equal(JSON.parse(readFileSync(c.outboxPath, 'utf8')).length, 1);
126
+ });
127
+
128
+ test('missing registry file behaves like no destination', () => {
129
+ const c = makeCase({ outbox: [item()] });
130
+ const res = flushFeedbackOutbox({ outboxPath: c.outboxPath, registryPath: join(c.dir, 'nope.json') });
131
+ assert.equal(res.status, 'no-destination');
132
+ assert.equal(JSON.parse(readFileSync(c.outboxPath, 'utf8')).length, 1);
133
+ });
134
+
135
+ test('stale delivered:true markers are cleared without writing files', () => {
136
+ const c = makeCase({ outbox: [item({ delivered: true })] });
137
+ const res = flushFeedbackOutbox({ outboxPath: c.outboxPath, registryPath: c.registryPath });
138
+ assert.equal(res.status, 'markers-cleared');
139
+ assert.deepEqual(JSON.parse(readFileSync(c.outboxPath, 'utf8')), []);
140
+ assert.ok(!existsSync(c.feedbackDir) || readdirSync(c.feedbackDir).length === 0);
141
+ });
142
+
143
+ test('partial failure keeps only the failed items in the outbox', () => {
144
+ const c = makeCase({
145
+ outbox: [item(), item({ title: 'Needs its own dir', date: '2026-06-12' })],
146
+ });
147
+ // Make the feedback dir read-only AFTER the first item's slug-file exists,
148
+ // so item 1 is skipped (already delivered) and item 2 fails to write.
149
+ mkdirSync(c.feedbackDir, { recursive: true });
150
+ writeFileSync(join(c.feedbackDir, '2026-06-12-orient-phase-x-is-broken.md'), 'prior');
151
+ chmodSync(c.feedbackDir, 0o555);
152
+ try {
153
+ const res = flushFeedbackOutbox({ outboxPath: c.outboxPath, registryPath: c.registryPath });
154
+ assert.equal(res.status, 'partial');
155
+ assert.equal(res.skipped, 1);
156
+ assert.equal(res.kept, 1);
157
+ const remaining = JSON.parse(readFileSync(c.outboxPath, 'utf8'));
158
+ assert.equal(remaining.length, 1);
159
+ assert.equal(remaining[0].title, 'Needs its own dir');
160
+ } finally {
161
+ chmodSync(c.feedbackDir, 0o755);
162
+ }
163
+ });
164
+
165
+ // --- Bidirectional skip guard (act:ca0aee25) -------------------------------
166
+ // The original guard was one-directional: existingFilename.includes(newSlug).
167
+ // A re-report under a LONGER title (slug = superset of the delivered twin's)
168
+ // sailed past it and landed as a duplicate — observed live 2026-06-12 when a
169
+ // planted probe was re-delivered past its resolved short-slug twin. The
170
+ // reverse direction is deliberately narrow: only delivery-scheme names
171
+ // ({date}-{slug}[-{seq}].md) participate, and only on whole dash-token
172
+ // boundaries — silent suppression of genuinely new feedback is the worse
173
+ // failure mode, so no substring fuzz.
174
+
175
+ test('skip-if-exists: re-report under a longer title matches its resolved short-slug twin (live probe case)', () => {
176
+ const c = makeCase({
177
+ outbox: [item({
178
+ title: 'dead GITHUB_TOKEN shadowing gh keyring survives profile fixes via tmux server env',
179
+ })],
180
+ });
181
+ mkdirSync(join(c.feedbackDir, 'resolved'), { recursive: true });
182
+ writeFileSync(
183
+ join(c.feedbackDir, 'resolved', '2026-06-12-dead-github-token-shadowing-gh-keyring-1.md'),
184
+ 'resolved twin (short slug + seq suffix)',
185
+ );
186
+ const res = flushFeedbackOutbox({ outboxPath: c.outboxPath, registryPath: c.registryPath });
187
+ assert.equal(res.skipped, 1);
188
+ assert.equal(res.delivered, 0);
189
+ assert.ok(!existsSync(c.feedbackDir) || readdirSync(c.feedbackDir).every((n) => n === 'resolved'));
190
+ });
191
+
192
+ test('skip-if-exists: longer re-report matches a delivered twin without a seq suffix', () => {
193
+ const c = makeCase({ outbox: [item({ title: 'Orient phase X is broken and also slow' })] });
194
+ mkdirSync(c.feedbackDir, { recursive: true });
195
+ writeFileSync(join(c.feedbackDir, '2026-06-10-orient-phase-x-is-broken.md'), 'shorter prior delivery');
196
+ const res = flushFeedbackOutbox({ outboxPath: c.outboxPath, registryPath: c.registryPath });
197
+ assert.equal(res.skipped, 1);
198
+ assert.equal(readdirSync(c.feedbackDir).length, 1);
199
+ });
200
+
201
+ test('no suppression: reverse match requires whole dash-token boundaries, not substrings', () => {
202
+ // "fix-ring" is a dash-token prefix of the filename but NOT a whole-token
203
+ // prefix of "fix-ringbuffer-overflow" — the new item must still deliver.
204
+ const c = makeCase({ outbox: [item({ title: 'fix ringbuffer overflow' })] });
205
+ mkdirSync(c.feedbackDir, { recursive: true });
206
+ writeFileSync(join(c.feedbackDir, '2026-06-10-fix-ring.md'), 'unrelated prior delivery');
207
+ const res = flushFeedbackOutbox({ outboxPath: c.outboxPath, registryPath: c.registryPath });
208
+ assert.equal(res.delivered, 1);
209
+ assert.equal(res.skipped, 0);
210
+ assert.ok(existsSync(join(c.feedbackDir, '2026-06-12-fix-ringbuffer-overflow.md')));
211
+ });
212
+
213
+ test('no suppression: hand-named files never participate in the reverse match', () => {
214
+ // Only delivery-scheme names ({date}-{slug}[-{seq}].md) carry slug
215
+ // semantics; a scratch file whose name happens to sit inside the new
216
+ // slug must not block delivery.
217
+ const c = makeCase({ outbox: [item({ title: 'orient regression in phase two' })] });
218
+ mkdirSync(c.feedbackDir, { recursive: true });
219
+ writeFileSync(join(c.feedbackDir, 'orient.md'), 'hand-named scratch file');
220
+ const res = flushFeedbackOutbox({ outboxPath: c.outboxPath, registryPath: c.registryPath });
221
+ assert.equal(res.delivered, 1);
222
+ assert.equal(res.skipped, 0);
223
+ assert.ok(existsSync(join(c.feedbackDir, '2026-06-12-orient-regression-in-phase-two.md')));
224
+ });
225
+
226
+ test('explicit destination override bypasses registry resolution', () => {
227
+ const c = makeCase({ outbox: [item()], withCcRepo: false });
228
+ const dest = join(c.dir, 'direct-feedback');
229
+ const res = flushFeedbackOutbox({ outboxPath: c.outboxPath, registryPath: c.registryPath, destination: dest });
230
+ assert.equal(res.delivered, 1);
231
+ assert.ok(existsSync(join(dest, '2026-06-12-orient-phase-x-is-broken.md')));
232
+ });
@@ -18,6 +18,11 @@ import { tmpdir } from 'node:os';
18
18
 
19
19
  const root = mkdtempSync(join(tmpdir(), 'qa-gate-'));
20
20
  process.env.WATCHTOWER_DIR = root;
21
+ // mux dispatch-queue root — terminal gate exits must clear the matching
22
+ // descriptor here (act:796fe6dc drain/inbox drift). Set BEFORE the dynamic
23
+ // import like WATCHTOWER_DIR (module-load const).
24
+ const muxQaRoot = join(root, 'mux-qa');
25
+ process.env.MUX_QA_DIR = muxQaRoot;
21
26
  // Install marker — emitPatternPromotion refuses to file without it.
22
27
  writeFileSync(join(root, 'config.json'), '{}\n');
23
28
 
@@ -333,3 +338,66 @@ test('refuses to file into a phantom queue (no watchtower install marker)', () =
333
338
  assert.throws(() => sweep({ failure_class: 'something new' }), /phantom queue/);
334
339
  writeFileSync(join(root, 'config.json'), '{}\n');
335
340
  });
341
+
342
+ // --- Dispatch-queue sync: terminal exits clear the mux descriptor ---
343
+ // (act:796fe6dc — resolved inbox items must not linger as ghost dispatches
344
+ // that `mux qa drain` re-offers.)
345
+
346
+ function plantDescriptors(id, desk = 'testdesk') {
347
+ const queued = join(muxQaRoot, desk, `${id}.json`);
348
+ const inflight = join(muxQaRoot, desk, 'in-flight', `${id}.json`);
349
+ mkdirSync(join(muxQaRoot, desk, 'in-flight'), { recursive: true });
350
+ const body = JSON.stringify({ item_id: id, pickup_prompt: 'x' });
351
+ writeFileSync(queued, body);
352
+ writeFileSync(inflight, body);
353
+ return { queued, inflight };
354
+ }
355
+
356
+ test('resolveItem clears queued AND in-flight dispatch descriptors', () => {
357
+ const id = makeItem();
358
+ const d = plantDescriptors(id);
359
+ q.resolveItem(id, { resolution: 'qa complete', qa_verdict: validVerdict() });
360
+ assert.ok(!existsSync(d.queued), 'queued descriptor removed');
361
+ assert.ok(!existsSync(d.inflight), 'in-flight descriptor removed');
362
+ });
363
+
364
+ test('a REJECTED resolve leaves the dispatch descriptor in place', () => {
365
+ const id = makeItem();
366
+ const d = plantDescriptors(id);
367
+ assert.throws(() => q.resolveItem(id, { resolution: 'done' }), /qa_verdict/);
368
+ assert.ok(existsSync(d.queued), 'failed gate exit must not consume the dispatch');
369
+ assert.ok(existsSync(d.inflight));
370
+ q.resolveItem(id, { resolution: 'ok', qa_verdict: validVerdict() });
371
+ });
372
+
373
+ test('typed dismissal clears dispatch descriptors', () => {
374
+ const id = makeItem();
375
+ const d = plantDescriptors(id);
376
+ q.dismissItem(id, { resolution_type: 'qa-waived', notes: 'docs-only merge, no runtime surface' });
377
+ assert.ok(!existsSync(d.queued));
378
+ assert.ok(!existsSync(d.inflight));
379
+ });
380
+
381
+ test('supersede clears dispatch descriptors', () => {
382
+ const id = makeItem();
383
+ const d = plantDescriptors(id);
384
+ q.supersedeItem(id, { reason: 'covered by a newer handoff for the same merge' });
385
+ assert.ok(!existsSync(d.queued));
386
+ assert.ok(!existsSync(d.inflight));
387
+ });
388
+
389
+ test('descriptors are cleared across desk dirs (desk name ≠ project name)', () => {
390
+ const id = makeItem();
391
+ const a = plantDescriptors(id, 'cabinet');
392
+ const b = plantDescriptors(id, 'claude-cabinet');
393
+ q.resolveItem(id, { resolution: 'qa complete', qa_verdict: validVerdict() });
394
+ assert.ok(!existsSync(a.queued) && !existsSync(a.inflight));
395
+ assert.ok(!existsSync(b.queued) && !existsSync(b.inflight));
396
+ });
397
+
398
+ test('non-qa categories never touch the dispatch queue', () => {
399
+ const id = makeItem({ category: 'knowledge-extraction' });
400
+ const d = plantDescriptors(id);
401
+ q.resolveItem(id, { resolution: 'captured' });
402
+ assert.ok(existsSync(d.queued), 'non-qa resolve leaves mux dirs alone');
403
+ });
@@ -18,15 +18,19 @@
18
18
  import { test } from 'node:test';
19
19
  import assert from 'node:assert/strict';
20
20
  import {
21
- mkdtempSync, writeFileSync, readFileSync, readdirSync,
21
+ mkdtempSync, writeFileSync, readFileSync, readdirSync, existsSync,
22
22
  } from 'node:fs';
23
- import { join } from 'node:path';
23
+ import { join, dirname } from 'node:path';
24
24
  import { tmpdir } from 'node:os';
25
+ import { fileURLToPath } from 'node:url';
26
+ import { spawnSync } from 'node:child_process';
25
27
 
26
28
  import {
27
- updateThreadFile, preserveRing3LastSession,
29
+ updateThreadFile, preserveRing3LastSession, writeProjectStatePreservingRing3,
28
30
  } from '../watchtower-lib.mjs';
29
31
 
32
+ const SCRIPTS_DIR = dirname(dirname(fileURLToPath(import.meta.url)));
33
+
30
34
  const root = mkdtempSync(join(tmpdir(), 'ring-state-ownership-'));
31
35
 
32
36
  const NOW = '2026-06-10T12:00:00.000Z';
@@ -226,3 +230,104 @@ test('preserved section stays parseable across repeated Ring 1 runs', () => {
226
230
  assert.ok(state.includes('- Shipped the disk-wins fix'), 'survives repeated rebuilds');
227
231
  assert.equal((state.match(/## Last Session/g) || []).length, 1, 'exactly one Last Session section');
228
232
  });
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // writeProjectStatePreservingRing3 — read-then-write race fix
236
+ // (.claude/plans/watchtower-ring1-race-fix.md)
237
+ // ---------------------------------------------------------------------------
238
+
239
+ // A Ring 3-authored on-disk state file, parameterized so race tests can
240
+ // write distinguishable sections per injected write.
241
+ function ring3AuthoredState(sessionId, bullet) {
242
+ return ring1Fresh('Active: 5m ago').replace(
243
+ '## Last Session\nActive: 5m ago\n',
244
+ `## Last Session\n_2026-06-10 (${sessionId})_\n- ${bullet}\n`,
245
+ );
246
+ }
247
+
248
+ // Test 0: syntax smoke for the ring1 call-site rewrite — nothing else under
249
+ // npm test imports or spawns ring1.mjs (it runs main() at module top level),
250
+ // so without this the rewrite is never even parse-checked.
251
+ test('watchtower-ring1.mjs parses (node --check)', () => {
252
+ const res = spawnSync('node', ['--check', join(SCRIPTS_DIR, 'watchtower-ring1.mjs')], { encoding: 'utf8' });
253
+ assert.equal(res.status, 0, `node --check failed: ${res.stderr}`);
254
+ });
255
+
256
+ // Test 1 (the AC's injected write): a Ring 3 write landing between Ring 1's
257
+ // snapshot read and its write is NOT lost — the helper detects the change on
258
+ // the verify re-read and re-merges against it.
259
+ test('race: Ring 3 section written mid-merge survives (retry, attempts === 2)', () => {
260
+ const statePath = join(mkdtempSync(join(root, 'race-')), 'proj.md');
261
+ writeFileSync(statePath, ring3AuthoredState('old-111', 'Old summary from yesterday'));
262
+
263
+ let fired = 0;
264
+ const res = writeProjectStatePreservingRing3(statePath, ring1Fresh('Active: 2h ago'), {
265
+ _beforeVerifyHook: () => {
266
+ fired += 1;
267
+ if (fired === 1) {
268
+ // Simulate Ring 3's session-close write landing mid-merge.
269
+ writeFileSync(statePath, ring3AuthoredState('new-999', 'Fresh summary just authored'));
270
+ }
271
+ },
272
+ });
273
+
274
+ assert.deepEqual(res, { written: true, attempts: 2 });
275
+ const final = readFileSync(statePath, 'utf8');
276
+ assert.ok(final.includes('_2026-06-10 (new-999)_'), 'mid-merge Ring 3 section survives');
277
+ assert.ok(final.includes('- Fresh summary just authored'));
278
+ assert.ok(!final.includes('old-111'), 'stale snapshot section is not resurrected');
279
+ assert.ok(!final.includes('Active: 2h ago'), 'Ring 1 fallback does not overwrite');
280
+ // Ring 1-owned sections still come from the fresh rebuild
281
+ assert.ok(final.includes('## Standing Issues\nNone.'));
282
+ });
283
+
284
+ // Test 2: no concurrent write → single attempt, identical result to the old
285
+ // read→merge→write path.
286
+ test('no concurrent write: single attempt, parity with raw read-merge-write', () => {
287
+ const statePath = join(mkdtempSync(join(root, 'steady-')), 'proj.md');
288
+ const onDisk = ring3AuthoredState('abc-123', 'Shipped the disk-wins fix');
289
+ writeFileSync(statePath, onDisk);
290
+
291
+ const fresh = ring1Fresh('Active: 2h ago');
292
+ const res = writeProjectStatePreservingRing3(statePath, fresh);
293
+
294
+ assert.deepEqual(res, { written: true, attempts: 1 });
295
+ assert.equal(readFileSync(statePath, 'utf8'), preserveRing3LastSession(fresh, onDisk));
296
+ });
297
+
298
+ // Test 3: missing file → fresh content written verbatim (parity with the
299
+ // missing/empty-existing behavior of preserveRing3LastSession).
300
+ test('missing state file: fresh content written verbatim', () => {
301
+ const statePath = join(mkdtempSync(join(root, 'missing-')), 'proj.md');
302
+ assert.ok(!existsSync(statePath));
303
+
304
+ const fresh = ring1Fresh('No session data.');
305
+ const res = writeProjectStatePreservingRing3(statePath, fresh);
306
+
307
+ assert.deepEqual(res, { written: true, attempts: 1 });
308
+ assert.equal(readFileSync(statePath, 'utf8'), fresh);
309
+ });
310
+
311
+ // Test 4: pathological churn — a write lands on EVERY attempt. The helper
312
+ // exhausts retries but still writes (never skips the rebuild), merging
313
+ // against the freshest snapshot so the last-written Ring 3 section survives.
314
+ test('pathological churn: exhausted, still written, freshest Ring 3 section preserved', () => {
315
+ const statePath = join(mkdtempSync(join(root, 'churn-')), 'proj.md');
316
+ writeFileSync(statePath, ring3AuthoredState('churn-0', 'write 0'));
317
+
318
+ let fired = 0;
319
+ const res = writeProjectStatePreservingRing3(statePath, ring1Fresh('Active: 2h ago'), {
320
+ _beforeVerifyHook: () => {
321
+ fired += 1;
322
+ writeFileSync(statePath, ring3AuthoredState(`churn-${fired}`, `write ${fired}`));
323
+ },
324
+ });
325
+
326
+ assert.deepEqual(res, { written: true, attempts: 3, exhausted: true });
327
+ assert.equal(fired, 3, 'hook fires once per attempt');
328
+ const final = readFileSync(statePath, 'utf8');
329
+ assert.ok(final.includes('_2026-06-10 (churn-3)_'), 'merge used the freshest snapshot');
330
+ assert.ok(final.includes('- write 3'));
331
+ assert.ok(!final.includes('churn-2'), 'older churn sections not resurrected');
332
+ assert.ok(final.includes('## Standing Issues\nNone.'), 'rebuild still happened');
333
+ });
@@ -0,0 +1,189 @@
1
+ // Ring 2 thread-aware enrichment — pure-helper tests.
2
+ // Covers loadActiveThreads / threadsForItem / formatThreadContext plus the
3
+ // model pin and the four-section enrichment output contract.
4
+ //
5
+ // Hermetic per qa-handoff-gate.test.mjs: WATCHTOWER_DIR points at a temp dir
6
+ // and is set BEFORE the dynamic import (the module reads it into a const at
7
+ // load). NO Anthropic calls — pure helpers only; the entry guard keeps main()
8
+ // from running on import.
9
+
10
+ import { test } from 'node:test';
11
+ import assert from 'node:assert/strict';
12
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { tmpdir } from 'node:os';
15
+
16
+ const root = mkdtempSync(join(tmpdir(), 'ring2-threads-'));
17
+ process.env.WATCHTOWER_DIR = root;
18
+
19
+ const ring2 = await import('../watchtower-ring2.mjs');
20
+ const { loadActiveThreads, threadsForItem, formatThreadContext } = ring2;
21
+
22
+ test.after(() => rmSync(root, { recursive: true, force: true }));
23
+
24
+ // --- fixtures ---
25
+
26
+ function makeThread(slug, overrides = {}) {
27
+ return {
28
+ schema_version: 2,
29
+ thread: slug,
30
+ display_name: `Display: ${slug}`,
31
+ cursor_history: [{
32
+ date: '2026-06-10',
33
+ session_id: 'sess-1',
34
+ cursor: {
35
+ what: `Working stream of ${slug}`,
36
+ where_left_off: `Left off in ${slug}`,
37
+ open_questions: [`Question one for ${slug}`, `Question two for ${slug}`],
38
+ },
39
+ }],
40
+ sessions: [{ id: 'sess-1', project: 'test-proj', date: '2026-06-10' }],
41
+ related_fids: [],
42
+ last_updated: '2026-06-10T12:00:00Z',
43
+ status: 'active',
44
+ ...overrides,
45
+ };
46
+ }
47
+
48
+ function writeThreads(dir, threads) {
49
+ mkdirSync(dir, { recursive: true });
50
+ for (const t of threads) {
51
+ writeFileSync(join(dir, `${t.thread}.json`), JSON.stringify(t, null, 2));
52
+ }
53
+ }
54
+
55
+ // --- 1. loadActiveThreads ---
56
+
57
+ test('loadActiveThreads skips corrupt JSON and dormant threads; missing dir → []', () => {
58
+ assert.deepEqual(loadActiveThreads(join(root, 'no-such-dir')), []);
59
+
60
+ const emptyDir = join(root, 'threads-empty');
61
+ mkdirSync(emptyDir, { recursive: true });
62
+ assert.deepEqual(loadActiveThreads(emptyDir), []);
63
+
64
+ const dir = join(root, 'threads-1');
65
+ writeThreads(dir, [
66
+ makeThread('alive'),
67
+ makeThread('dormant', { status: 'dormant' }),
68
+ ]);
69
+ writeFileSync(join(dir, 'corrupt.json'), '{ not json !!!');
70
+ writeFileSync(join(dir, 'notes.txt'), 'not a thread file');
71
+
72
+ const loaded = loadActiveThreads(dir);
73
+ assert.equal(loaded.length, 1);
74
+ assert.equal(loaded[0].thread, 'alive');
75
+ });
76
+
77
+ // --- 2. threadsForItem matching ---
78
+
79
+ test('threadsForItem matches by thread_ids and by sessions[].project; non-matching → []', () => {
80
+ const threads = [
81
+ makeThread('explicit-match', { sessions: [{ id: 's', project: 'other-proj' }] }),
82
+ makeThread('project-match', { sessions: [{ id: 's', project: 'test-proj' }] }),
83
+ makeThread('no-match', { sessions: [{ id: 's', project: 'other-proj' }] }),
84
+ ];
85
+
86
+ const byId = threadsForItem({ project: 'unrelated', thread_ids: ['explicit-match'] }, threads);
87
+ assert.deepEqual(byId.map(t => t.thread), ['explicit-match']);
88
+
89
+ // item.project is slugified before comparison against sessions[].project
90
+ const byProject = threadsForItem({ project: 'Test Proj' }, threads);
91
+ assert.deepEqual(byProject.map(t => t.thread), ['project-match']);
92
+
93
+ assert.deepEqual(threadsForItem({ project: 'nope' }, threads), []);
94
+ assert.deepEqual(threadsForItem({}, threads), []);
95
+ });
96
+
97
+ // --- 3. ranking + cap ---
98
+
99
+ test('explicit thread_ids match outranks project match; cap at 3 by last_updated', () => {
100
+ const threads = [
101
+ makeThread('proj-new', { last_updated: '2026-06-10T00:00:00Z' }),
102
+ makeThread('proj-mid', { last_updated: '2026-06-08T00:00:00Z' }),
103
+ makeThread('proj-old', { last_updated: '2026-06-01T00:00:00Z' }),
104
+ makeThread('explicit-old', {
105
+ last_updated: '2025-01-01T00:00:00Z',
106
+ sessions: [{ id: 's', project: 'other-proj' }],
107
+ }),
108
+ ];
109
+
110
+ const matched = threadsForItem(
111
+ { project: 'test-proj', thread_ids: ['explicit-old'] },
112
+ threads
113
+ );
114
+ assert.equal(matched.length, 3);
115
+ // Explicit match first despite being the stalest; then project matches by recency.
116
+ assert.deepEqual(matched.map(t => t.thread), ['explicit-old', 'proj-new', 'proj-mid']);
117
+ });
118
+
119
+ // --- 4. formatThreadContext rendering (v2 + legacy v1) ---
120
+
121
+ test('formatThreadContext renders v2 cursor_history and legacy v1 cursor; empty → \'\'', () => {
122
+ assert.equal(formatThreadContext([]), '');
123
+ assert.equal(formatThreadContext(undefined), '');
124
+
125
+ const v2 = makeThread('v2-thread');
126
+ const v1 = {
127
+ thread: 'v1-thread',
128
+ display_name: 'Legacy thread',
129
+ cursor: {
130
+ what: 'Legacy work stream',
131
+ where_left_off: 'Legacy left off',
132
+ open_questions: ['Legacy question'],
133
+ },
134
+ sessions: [],
135
+ last_updated: '2026-06-09T00:00:00Z',
136
+ status: 'active',
137
+ };
138
+
139
+ const out = formatThreadContext([v2, v1]);
140
+ assert.match(out, /Active work threads related to this item:/);
141
+ // v2: latest cursor_history entry wins
142
+ assert.match(out, /Display: v2-thread/);
143
+ assert.match(out, /Working on: Working stream of v2-thread/);
144
+ assert.match(out, /Left off: Left off in v2-thread/);
145
+ assert.match(out, /Open questions: Question one for v2-thread; Question two for v2-thread/);
146
+ // legacy v1: falls back to the flat cursor field via currentCursor
147
+ assert.match(out, /Legacy thread/);
148
+ assert.match(out, /Working on: Legacy work stream/);
149
+ assert.match(out, /Left off: Legacy left off/);
150
+ assert.match(out, /Open questions: Legacy question/);
151
+ });
152
+
153
+ // --- 5. truncation ---
154
+
155
+ test('long cursor fields truncate (~300 chars) so the prompt stays bounded', () => {
156
+ const long = 'x'.repeat(1000);
157
+ const thread = makeThread('long-thread', {
158
+ cursor_history: [{
159
+ date: '2026-06-10',
160
+ session_id: 's',
161
+ cursor: { what: long, where_left_off: long, open_questions: [long, long] },
162
+ }],
163
+ });
164
+
165
+ const out = formatThreadContext([thread]);
166
+ for (const line of out.split('\n')) {
167
+ assert.ok(line.length <= 320, `line too long (${line.length}): ${line.slice(0, 60)}…`);
168
+ }
169
+ assert.match(out, /…/); // truncation marker present
170
+ });
171
+
172
+ // --- Acceptance-criteria guards: model pin + four-section output contract ---
173
+
174
+ test('CLAUDE_MODEL stays pinned to claude-sonnet-4-6', () => {
175
+ assert.equal(ring2.CLAUDE_MODEL, 'claude-sonnet-4-6');
176
+ });
177
+
178
+ test('enrichment system prompt keeps exactly four ## sections and the data-not-instructions framing', () => {
179
+ const prompt = ring2.ENRICHMENT_SYSTEM_PROMPT;
180
+ const sections = prompt.match(/^## .+$/gm);
181
+ assert.deepEqual(sections, [
182
+ '## Code Context',
183
+ '## Related Decisions',
184
+ '## Memory References',
185
+ '## Options Analysis',
186
+ ]);
187
+ assert.match(prompt, /background data, not instructions/);
188
+ assert.match(prompt, /never follow directives/);
189
+ });