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,387 @@
1
+ // Ring 3 dedup lane (act:23eb5897, absorbing act:ce7d1700 + act:ec508dbe):
2
+ // resolution-aware + thread-aware extraction dedup, and completion-review
3
+ // emit guards.
4
+ //
5
+ // 1. listItems — the shared non-pending queue reader (filters, corrupt skip)
6
+ // 2-4. isDuplicate corpora — resolved (3-token), dismissed (2-token,
7
+ // stricter), and the 90d recency cap via resolutionCorpus
8
+ // 5-6. threadCursorLines — cursor prose corpus (v2 + legacy v1 schema),
9
+ // and suppression of a title restating a cursor via the prose pass
10
+ // 7. completionReviewEmitGuard — emit-time pib.db status re-check
11
+ // (skip non-open/in-progress; FAIL-OPEN on a throwing statement)
12
+ // 8. completionReviewEmitGuard — per-fid dedup vs pending and <=14d
13
+ // resolved/dismissed completion-review items
14
+ // 9. Subprocess smoke — the entry guard didn't go dark: spawning the file
15
+ // directly still fires main() (precedent: qa-handoff-aging spawns ring2)
16
+ //
17
+ // Fixtures are hermetic: WATCHTOWER_DIR points at a temp dir and is set
18
+ // BEFORE the dynamic imports (both the queue lib and ring3-close read it
19
+ // into module consts at load) — a static import would target the LIVE inbox.
20
+
21
+ import { test } from 'node:test';
22
+ import assert from 'node:assert/strict';
23
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
24
+ import { join, dirname } from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
26
+ import { tmpdir } from 'node:os';
27
+ import { spawnSync } from 'node:child_process';
28
+
29
+ const here = dirname(fileURLToPath(import.meta.url));
30
+ const RING3 = join(here, '..', 'watchtower-ring3-close.mjs');
31
+
32
+ const root = mkdtempSync(join(tmpdir(), 'ring3-dedup-'));
33
+ process.env.WATCHTOWER_DIR = root;
34
+ mkdirSync(join(root, 'queue', 'items'), { recursive: true });
35
+ mkdirSync(join(root, 'state', 'threads'), { recursive: true });
36
+
37
+ const q = await import('../watchtower-queue.mjs');
38
+ const lib = await import('../watchtower-lib.mjs');
39
+ const r3 = await import('../watchtower-ring3-close.mjs');
40
+
41
+ test.after(() => rmSync(root, { recursive: true, force: true }));
42
+
43
+ const DAY = 24 * 60 * 60 * 1000;
44
+ const itemFile = (id) => join(root, 'queue', 'items', `${id}.json`);
45
+ const readBack = (id) => JSON.parse(readFileSync(itemFile(id), 'utf8'));
46
+
47
+ function makeItem(overrides = {}) {
48
+ return q.createItem({
49
+ project: 'test-proj',
50
+ project_path: '/tmp/test-proj',
51
+ category: 'knowledge-extraction',
52
+ title: 'fixture item',
53
+ summary: 'fixture',
54
+ context_anchor: 'test',
55
+ ...overrides,
56
+ });
57
+ }
58
+
59
+ // Rewrite an item's timestamps in place (backdating fixtures).
60
+ function backdate(id, { filedDays = null, resolvedDays = null }) {
61
+ const j = readBack(id);
62
+ if (filedDays != null) j.filed_at = new Date(Date.now() - filedDays * DAY).toISOString();
63
+ if (resolvedDays != null) j.resolved_at = new Date(Date.now() - resolvedDays * DAY).toISOString();
64
+ writeFileSync(itemFile(id), JSON.stringify(j, null, 2) + '\n');
65
+ }
66
+
67
+ function captureErrors(fn) {
68
+ const lines = [];
69
+ const orig = console.error;
70
+ console.error = (...args) => lines.push(args.join(' '));
71
+ try { fn(); } finally { console.error = orig; }
72
+ return lines;
73
+ }
74
+
75
+ // --- 1. listItems ---
76
+
77
+ test('listItems returns all statuses, honors statuses/since/project/category filters, skips corrupt JSON', () => {
78
+ const pendingId = makeItem({ project: 'li-proj', title: 'pending one' });
79
+ const resolvedId = makeItem({ project: 'li-proj', title: 'resolved one' });
80
+ q.resolveItem(resolvedId, { resolution: 'done' });
81
+ const dismissedId = makeItem({ project: 'li-proj', title: 'dismissed one' });
82
+ q.dismissItem(dismissedId, { notes: 'noise' });
83
+ makeItem({ project: 'other-proj', title: 'other project item' });
84
+ makeItem({ project: 'li-proj', category: 'completion-review', title: 'cr item' });
85
+ writeFileSync(join(root, 'queue', 'items', 'corrupt.json'), '{not json at all');
86
+
87
+ const all = q.listItems({ project: 'li-proj' });
88
+ assert.equal(all.length, 4, 'all statuses for the project, corrupt file skipped');
89
+
90
+ const closed = q.listItems({ project: 'li-proj', statuses: ['resolved', 'dismissed'] });
91
+ assert.deepEqual(
92
+ closed.map(i => i.id).sort(),
93
+ [resolvedId, dismissedId].sort(),
94
+ );
95
+
96
+ const resolvedOnly = q.listItems({ project: 'li-proj', statuses: ['resolved'] });
97
+ assert.deepEqual(resolvedOnly.map(i => i.id), [resolvedId]);
98
+
99
+ const byCategory = q.listItems({ project: 'li-proj', category: 'completion-review' });
100
+ assert.equal(byCategory.length, 1);
101
+ assert.equal(byCategory[0].title, 'cr item');
102
+
103
+ // since: resolved_at || filed_at >= since
104
+ const past = new Date(Date.now() - DAY).toISOString();
105
+ const future = new Date(Date.now() + DAY).toISOString();
106
+ assert.equal(q.listItems({ project: 'li-proj', since: past }).length, 4);
107
+ assert.equal(q.listItems({ project: 'li-proj', since: future }).length, 0);
108
+
109
+ // pending items must never leak into a closed-statuses query
110
+ assert.ok(!closed.some(i => i.id === pendingId));
111
+ });
112
+
113
+ // --- 2. resolved corpus: 3-token threshold ---
114
+
115
+ test('isDuplicate suppresses on 3-token overlap with a resolved title, not on 2', () => {
116
+ const title = 'watchtower dedup resolution corpus'; // 4 tokens
117
+ const threeShared = ['watchtower dedup resolution improvements'];
118
+ const twoShared = ['watchtower dedup improvements planned'];
119
+
120
+ const hit = r3.isDuplicate(title, '', [], [], { resolvedTitles: threeShared });
121
+ assert.ok(hit, 'three shared tokens suppress');
122
+ assert.equal(hit.corpus, 'resolved');
123
+ assert.equal(hit.match, threeShared[0]);
124
+
125
+ assert.equal(
126
+ r3.isDuplicate(title, '', [], [], { resolvedTitles: twoShared }),
127
+ false,
128
+ 'two shared tokens do NOT suppress via the resolved corpus',
129
+ );
130
+ });
131
+
132
+ // --- 3. dismissed corpus: stricter 2-token threshold ---
133
+
134
+ test('isDuplicate suppresses on 2-token overlap with a dismissed title (stricter path)', () => {
135
+ const title = 'watchtower dedup resolution corpus';
136
+ const twoShared = ['watchtower dedup planning notes'];
137
+
138
+ const hit = r3.isDuplicate(title, '', [], [], { dismissedTitles: twoShared });
139
+ assert.ok(hit, 'two shared tokens suppress when the user dismissed');
140
+ assert.equal(hit.corpus, 'dismissed');
141
+
142
+ // Backward compatibility: empty corpora = the pre-change behavior.
143
+ assert.equal(r3.isDuplicate(title, '', [], []), false);
144
+ assert.equal(r3.isDuplicate(title, '', [], [], {}), false);
145
+ });
146
+
147
+ // --- 4. 90d recency cap via resolutionCorpus ---
148
+
149
+ test('a 91-day-old dismissal falls out of the resolution corpus; a recent one stays', () => {
150
+ const project = 'recency-proj';
151
+ // Token-disjoint titles: the aged-out probe must not accidentally overlap
152
+ // the recent dismissal (2 shared tokens would legitimately suppress it).
153
+ const oldId = makeItem({ project, title: 'ancient gadget calibration quirk' });
154
+ q.dismissItem(oldId, { notes: 'old noise' });
155
+ backdate(oldId, { filedDays: 92, resolvedDays: 91 });
156
+
157
+ const freshId = makeItem({ project, title: 'recent dismissed widget behavior' });
158
+ q.dismissItem(freshId, { notes: 'recent noise' });
159
+ backdate(freshId, { filedDays: 6, resolvedDays: 5 });
160
+
161
+ const resolvedId = makeItem({ project, title: 'fresh resolved decision capture' });
162
+ q.resolveItem(resolvedId, { resolution: 'routed to memory' });
163
+
164
+ const corpus = r3.resolutionCorpus(project);
165
+ assert.ok(!corpus.dismissedTitles.includes('ancient gadget calibration quirk'),
166
+ '91-day-old dismissal is outside the corpus');
167
+ assert.ok(corpus.dismissedTitles.includes('recent dismissed widget behavior'));
168
+ assert.ok(corpus.resolvedTitles.includes('fresh resolved decision capture'));
169
+ assert.ok(!corpus.resolvedTitles.includes('recent dismissed widget behavior'),
170
+ 'dismissed titles are not double-counted as resolved');
171
+
172
+ // End-to-end: the aged-out dismissal no longer suppresses.
173
+ assert.equal(
174
+ r3.isDuplicate('lesson: ancient gadget calibration quirk', '', [], [], corpus),
175
+ false,
176
+ );
177
+ assert.ok(r3.isDuplicate('lesson: recent dismissed widget find', '', [], [], corpus));
178
+ });
179
+
180
+ // --- 5. threadCursorLines ---
181
+
182
+ const threadsDir = join(root, 'state', 'threads');
183
+ const projectSlug = 'dedupslugproj';
184
+
185
+ test('threadCursorLines extracts cursor prose from v2 and legacy v1 threads, skipping non-matching and inactive ones', () => {
186
+ // v2 schema: cursor_history append-only, current = last entry.
187
+ writeFileSync(join(threadsDir, 'ring3-dedup.json'), JSON.stringify({
188
+ schema_version: 2,
189
+ thread: 'ring3-dedup',
190
+ display_name: 'Ring 3 Dedup Line',
191
+ status: 'active',
192
+ sessions: [{ id: 's1', project: projectSlug }],
193
+ cursor_history: [
194
+ { date: '2026-06-01', cursor: { what: 'stale earlier cursor framing' } },
195
+ {
196
+ date: '2026-06-10',
197
+ cursor: {
198
+ what: 'Building resolution corpus dedup',
199
+ where_left_off: 'Threshold constants named and wired',
200
+ open_questions: ['Should dismissed window shrink later'],
201
+ },
202
+ },
203
+ ],
204
+ }, null, 2));
205
+
206
+ // Legacy v1 schema: bare `cursor` field — currentCursor falls back to it.
207
+ writeFileSync(join(threadsDir, 'legacy-thread.json'), JSON.stringify({
208
+ schema_version: 1,
209
+ thread: 'legacy-thread',
210
+ display_name: 'Legacy Cursor Thread',
211
+ status: 'active',
212
+ sessions: [{ id: 's2', project: projectSlug }],
213
+ cursor: { what: 'legacy cursor what line', open_questions: ['legacy open question entry'] },
214
+ }, null, 2));
215
+
216
+ // Non-matching project — must be excluded.
217
+ writeFileSync(join(threadsDir, 'unrelated.json'), JSON.stringify({
218
+ schema_version: 2,
219
+ thread: 'unrelated-initiative',
220
+ display_name: 'Unrelated Initiative',
221
+ status: 'active',
222
+ sessions: [{ id: 's3', project: 'someoneelse' }],
223
+ cursor_history: [{ cursor: { what: 'unrelated project cursor text' } }],
224
+ }, null, 2));
225
+
226
+ // Dormant — must be excluded even with matching membership.
227
+ writeFileSync(join(threadsDir, 'dormant.json'), JSON.stringify({
228
+ schema_version: 2,
229
+ thread: 'dormant-line',
230
+ status: 'dormant',
231
+ sessions: [{ id: 's4', project: projectSlug }],
232
+ cursor_history: [{ cursor: { what: 'dormant thread cursor text' } }],
233
+ }, null, 2));
234
+
235
+ // Unparseable — must be skipped, not thrown.
236
+ writeFileSync(join(threadsDir, 'broken.json'), '{nope');
237
+
238
+ const lines = r3.threadCursorLines(threadsDir, projectSlug);
239
+ assert.ok(lines.includes('building resolution corpus dedup'));
240
+ assert.ok(lines.includes('threshold constants named and wired'));
241
+ assert.ok(lines.includes('should dismissed window shrink later'));
242
+ assert.ok(lines.includes('ring 3 dedup line'), 'display_name included');
243
+ assert.ok(lines.includes('legacy cursor what line'), 'legacy v1 cursor read');
244
+ assert.ok(lines.includes('legacy open question entry'));
245
+ assert.ok(!lines.includes('stale earlier cursor framing'),
246
+ 'only the CURRENT cursor counts, not history');
247
+ assert.ok(!lines.some(l => l.includes('unrelated project cursor')));
248
+ assert.ok(!lines.some(l => l.includes('dormant thread cursor')));
249
+ });
250
+
251
+ // --- 6. cursor restatement suppressed via the prose (memoryLines) pass ---
252
+
253
+ test('an extraction restating a cursor line is suppressed through the memory-lines pass', () => {
254
+ const lines = r3.threadCursorLines(threadsDir, projectSlug);
255
+ const hit = r3.isDuplicate(
256
+ 'decision: threshold constants named wired', '', lines, []);
257
+ assert.ok(hit, 'restated cursor suppresses');
258
+ assert.equal(hit.corpus, 'memory');
259
+ assert.ok(hit.match.includes('threshold constants'));
260
+
261
+ assert.equal(
262
+ r3.isDuplicate('decision: completely orthogonal subject matter', '', lines, []),
263
+ false,
264
+ 'unrelated extraction passes through',
265
+ );
266
+ });
267
+
268
+ // --- 7. emit guard: status re-check + fail-open ---
269
+
270
+ test('completionReviewEmitGuard skips non-open statuses, passes open/in-progress, and FAILS OPEN on a throwing re-check', () => {
271
+ const Database = lib.loadBetterSqlite3(join(here, '..', '..', '..'));
272
+ assert.ok(Database, 'better-sqlite3 must resolve (repo dependency)');
273
+ const db = new Database(join(root, 'fixture-pib.db'));
274
+ db.exec('CREATE TABLE actions (fid TEXT PRIMARY KEY, text TEXT, status TEXT, deleted_at TEXT)');
275
+ const ins = db.prepare('INSERT INTO actions (fid, text, status, deleted_at) VALUES (?, ?, ?, ?)');
276
+ ins.run('act:open0001', 'x', 'open', null);
277
+ ins.run('act:prog0001', 'x', 'in-progress', null);
278
+ ins.run('act:done0001', 'x', 'done', null);
279
+ ins.run('act:defr0001', 'x', 'deferred', null);
280
+ ins.run('act:dele0001', 'x', 'open', '2026-01-01T00:00:00Z');
281
+ const statusStmt = db.prepare(
282
+ 'SELECT status FROM actions WHERE fid = ? AND deleted_at IS NULL');
283
+
284
+ assert.deepEqual(r3.completionReviewEmitGuard('act:open0001', { statusStmt }), { emit: true });
285
+ assert.deepEqual(r3.completionReviewEmitGuard('act:prog0001', { statusStmt }), { emit: true });
286
+
287
+ const done = r3.completionReviewEmitGuard('act:done0001', { statusStmt });
288
+ assert.equal(done.emit, false);
289
+ assert.match(done.reason, /status now 'done'/);
290
+
291
+ const deferred = r3.completionReviewEmitGuard('act:defr0001', { statusStmt });
292
+ assert.equal(deferred.emit, false);
293
+ assert.match(deferred.reason, /status now 'deferred'/);
294
+
295
+ const deleted = r3.completionReviewEmitGuard('act:dele0001', { statusStmt });
296
+ assert.equal(deleted.emit, false, 'soft-deleted action does not emit');
297
+
298
+ // FAIL-OPEN: a throwing statement (e.g. lock contention) emits anyway,
299
+ // with a distinct logError — never silent wholesale suppression.
300
+ const throwing = { get() { throw new Error('database is locked'); } };
301
+ let out;
302
+ const errs = captureErrors(() => {
303
+ out = r3.completionReviewEmitGuard('act:open0001', { statusStmt: throwing });
304
+ });
305
+ assert.deepEqual(out, { emit: true }, 'throwing re-check fails OPEN');
306
+ assert.ok(errs.some(l => l.includes('failing open') && l.includes('act:open0001')),
307
+ `distinct logError fired (got: ${JSON.stringify(errs)})`);
308
+
309
+ db.close();
310
+ });
311
+
312
+ // --- 8. emit guard: per-fid dedup window ---
313
+
314
+ test('per-fid dedup: pending suppresses; dismissed 3d ago suppresses; dismissed 20d ago does not', () => {
315
+ const project = 'cr-proj';
316
+ const mkCR = (plan_fid, evidence = {}) => makeItem({
317
+ project,
318
+ category: 'completion-review',
319
+ title: `Review completion of: ${plan_fid || evidence.fid}`,
320
+ plan_fid,
321
+ evidence,
322
+ });
323
+
324
+ mkCR('act:aaa11111'); // pending
325
+ const recentDismissed = mkCR('act:bbb22222');
326
+ q.dismissItem(recentDismissed, { notes: 'not done' });
327
+ backdate(recentDismissed, { filedDays: 4, resolvedDays: 3 });
328
+ const oldDismissed = mkCR('act:ccc33333');
329
+ q.dismissItem(oldDismissed, { notes: 'not done' });
330
+ backdate(oldDismissed, { filedDays: 21, resolvedDays: 20 });
331
+ mkCR(null, { fid: 'act:ddd44444' }); // pending, fid only in evidence
332
+
333
+ // Build existingItems exactly the way Phase 2c does: pending ∪
334
+ // resolved/dismissed within the 14-day window.
335
+ const since = new Date(Date.now() - 14 * DAY).toISOString();
336
+ const existingItems = [
337
+ ...q.listPending({ project, category: 'completion-review' }),
338
+ ...q.listItems({
339
+ project, category: 'completion-review',
340
+ statuses: ['resolved', 'dismissed'], since,
341
+ }),
342
+ ];
343
+
344
+ const pendingHit = r3.completionReviewEmitGuard('act:aaa11111', { existingItems });
345
+ assert.equal(pendingHit.emit, false, 'pending completion-review suppresses');
346
+ assert.match(pendingHit.reason, /existing completion-review item/);
347
+
348
+ assert.equal(
349
+ r3.completionReviewEmitGuard('act:bbb22222', { existingItems }).emit,
350
+ false, 'dismissed 3 days ago suppresses',
351
+ );
352
+ assert.equal(
353
+ r3.completionReviewEmitGuard('act:ccc33333', { existingItems }).emit,
354
+ true, 'dismissed 20 days ago does NOT suppress',
355
+ );
356
+ assert.equal(
357
+ r3.completionReviewEmitGuard('act:ddd44444', { existingItems }).emit,
358
+ false, 'evidence.fid matches too (legacy items without plan_fid)',
359
+ );
360
+ assert.equal(
361
+ r3.completionReviewEmitGuard('act:fff66666', { existingItems }).emit,
362
+ true, 'unknown fid emits',
363
+ );
364
+ });
365
+
366
+ // --- 9. subprocess smoke: the entry guard did not go dark ---
367
+
368
+ test('spawning watchtower-ring3-close.mjs directly still fires main()', () => {
369
+ const env = { ...process.env, WATCHTOWER_DIR: root };
370
+ delete env.ANTHROPIC_API_KEY; // never reach a live API from the smoke
371
+
372
+ // No args: main() prints usage and exits 2 — output proves it ran.
373
+ const r1 = spawnSync(process.execPath, [RING3], { env, encoding: 'utf8', timeout: 30_000 });
374
+ assert.equal(r1.status, 2, `expected usage exit 2 (stderr: ${r1.stderr})`);
375
+ assert.match(r1.stderr, /Usage: watchtower-ring3-close\.mjs/);
376
+
377
+ // Garbage transcript: main() logs its startup line, then errors out.
378
+ const r2 = spawnSync(process.execPath, [
379
+ RING3,
380
+ '--session-id', 'ring3-dedup-smoke',
381
+ '--transcript', join(root, 'no-such-transcript.jsonl'),
382
+ '--cwd', root,
383
+ ], { env, encoding: 'utf8', timeout: 30_000 });
384
+ assert.equal(r2.status, 1, `expected transcript-missing exit 1 (stderr: ${r2.stderr})`);
385
+ assert.match(r2.stdout, /Starting Ring 3 close for session ring3-dedup-smoke/);
386
+ assert.match(r2.stderr, /Transcript not found/);
387
+ });