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
@@ -13,6 +13,7 @@
13
13
  import { readFileSync, readdirSync, existsSync, statSync, mkdirSync } from 'fs';
14
14
  import { join, resolve, basename } from 'path';
15
15
  import { currentCursor, resolveProjectIdentity } from './watchtower-lib.mjs';
16
+ import { runAdvisoryPass } from './watchtower-advisories.mjs';
16
17
 
17
18
  const WATCHTOWER_DIR = process.env.WATCHTOWER_DIR
18
19
  || join(process.env.HOME, '.claude-cabinet', 'watchtower');
@@ -22,6 +23,18 @@ const STALENESS_THRESHOLD_MS = 24 * 60 * 60 * 1000; // 24 hours
22
23
  const RINGS_WARNING_THRESHOLD_MS = 48 * 60 * 60 * 1000; // 48 hours
23
24
  const PROJECT_STALENESS_DAYS = 7;
24
25
 
26
+ // Section truncation priority — LOWER survives, HIGHER is dropped first.
27
+ // `assembleSections` removes whole sections (never trims within one) in
28
+ // descending priority order until the output fits MAX_OUTPUT_CHARS;
29
+ // PRIORITY_NEVER is never dropped.
30
+ const PRIORITY_NEVER = 0; // summary — always kept
31
+ const PRIORITY_KEEP = 1; // threads, inbox, advisories — try hard to keep
32
+ const PRIORITY_PROJECT = 2; // per-project state
33
+ const PRIORITY_DOMAIN = 3; // injected domain files
34
+ const PRIORITY_PATTERNS = 4; // enforcement patterns — nice-to-have, drop first
35
+
36
+ const MAX_PATTERN_LINES = 5;
37
+
25
38
  // --- Argument parsing ---
26
39
 
27
40
  function parseArgs() {
@@ -183,6 +196,63 @@ function renderFocalZoom(threads, projectSlug) {
183
196
  return lines.join('\n');
184
197
  }
185
198
 
199
+ // --- Enforcement patterns (.claude/memory/patterns/) ---
200
+
201
+ // Pull the project-root patterns directory (NOT the ~/.claude/projects/<slug>/
202
+ // memory tree) so captured enforcement lessons keep shaping behavior past
203
+ // orient (act:202e5934). Index-line style only — name + one-line description,
204
+ // budget-capped. A two-field line scan, NOT a YAML parser: there is no shared
205
+ // frontmatter parser in templates/scripts/ and a full YAML parse trips on flow
206
+ // sequences (see MEMORY.md lesson_parsefrontmatter_flow_sequences).
207
+ function extractField(content, field) {
208
+ // Only look inside the leading --- frontmatter block.
209
+ const m = content.match(/^---\n([\s\S]*?)\n---/);
210
+ const block = m ? m[1] : content;
211
+ for (const line of block.split('\n')) {
212
+ const fm = line.match(new RegExp(`^${field}:\\s*(.+)$`));
213
+ if (fm) return fm[1].trim().replace(/^["']|["']$/g, '');
214
+ }
215
+ return null;
216
+ }
217
+
218
+ function renderPatterns(projectPath) {
219
+ if (!projectPath) return null;
220
+ const patternsDir = join(projectPath, '.claude', 'memory', 'patterns');
221
+ if (!existsSync(patternsDir)) return null;
222
+
223
+ let files;
224
+ try {
225
+ files = readdirSync(patternsDir)
226
+ .filter(f => f.endsWith('.md') && !f.startsWith('_'))
227
+ .sort();
228
+ } catch {
229
+ return null;
230
+ }
231
+ if (files.length === 0) return null;
232
+
233
+ const lines = [];
234
+ for (const f of files.slice(0, MAX_PATTERN_LINES)) {
235
+ let name = null, desc = null;
236
+ try {
237
+ const content = safeReadFile(join(patternsDir, f));
238
+ if (content) {
239
+ name = extractField(content, 'name');
240
+ desc = extractField(content, 'description');
241
+ }
242
+ } catch {
243
+ // fall through to basename fallback
244
+ }
245
+ if (!name) name = f.replace(/\.md$/, '');
246
+ lines.push(desc ? `- ${name} — ${desc}` : `- ${name}`);
247
+ }
248
+ if (lines.length === 0) return null;
249
+
250
+ const more = files.length > MAX_PATTERN_LINES
251
+ ? `\n…and ${files.length - MAX_PATTERN_LINES} more`
252
+ : '';
253
+ return `--- Enforcement Patterns ---\n${lines.join('\n')}${more}`;
254
+ }
255
+
186
256
  // --- Main ---
187
257
 
188
258
  function main() {
@@ -234,7 +304,7 @@ function main() {
234
304
  }
235
305
 
236
306
  // Summary is always included and never truncated
237
- sections.push({ key: 'summary', content: summarySection, priority: 0 });
307
+ sections.push({ key: 'summary', content: summarySection, priority: PRIORITY_NEVER });
238
308
 
239
309
  // Step 3: If project has inject_domains, read each state/<domain>.md
240
310
  const domainSections = [];
@@ -246,7 +316,7 @@ function main() {
246
316
  domainSections.push({
247
317
  key: `domain:${domain}`,
248
318
  content: `--- ${domain} ---\n${domainContent.trim()}`,
249
- priority: 3, // Truncated first
319
+ priority: PRIORITY_DOMAIN,
250
320
  });
251
321
  }
252
322
  }
@@ -264,7 +334,7 @@ function main() {
264
334
  sections.push({
265
335
  key: `project:${projectSlug}`,
266
336
  content: `--- Project: ${projectSlug} ---\n${projectStateContent.trim()}`,
267
- priority: 2, // Truncated second (after domains)
337
+ priority: PRIORITY_PROJECT,
268
338
  });
269
339
  }
270
340
  }
@@ -278,7 +348,7 @@ function main() {
278
348
  sections.push({
279
349
  key: 'threads',
280
350
  content: focalZoom,
281
- priority: 1,
351
+ priority: PRIORITY_KEEP,
282
352
  });
283
353
  }
284
354
  }
@@ -293,16 +363,44 @@ function main() {
293
363
  sections.push({
294
364
  key: 'queue',
295
365
  content: `--- Inbox ---\n${headline}\n${breakdown}`,
296
- priority: 1,
366
+ priority: PRIORITY_KEEP,
297
367
  });
298
368
  }
299
369
 
370
+ // Step 6b: Enforcement patterns from the project-root patterns dir
371
+ const patternsSection = renderPatterns(projectPath);
372
+ if (patternsSection) {
373
+ sections.push({
374
+ key: 'patterns',
375
+ content: patternsSection,
376
+ priority: PRIORITY_PATTERNS,
377
+ });
378
+ }
379
+
380
+ // Step 6c: Environment advisories (LSP / Railway-MCP / hookify / briefing
381
+ // file / registry orphans) with per-project dismissal memory. The module
382
+ // owns all logic + I/O and never throws; the builder only renders. This is
383
+ // orient's advisory home now that orient is being retired (act:f9ea075d).
384
+ try {
385
+ const advisories = runAdvisoryPass({ projectPath });
386
+ if (advisories.length > 0) {
387
+ const body = advisories.map(a => `- ${a.action}`).join('\n');
388
+ sections.push({
389
+ key: 'advisories',
390
+ content: `--- Advisories ---\n${body}`,
391
+ priority: PRIORITY_KEEP,
392
+ });
393
+ }
394
+ } catch {
395
+ // never block session start on advisory rendering
396
+ }
397
+
300
398
  // Untracked project mode — if no project match, just add a note
301
399
  if (!projectSlug && projectPath) {
302
400
  sections.push({
303
401
  key: 'untracked',
304
402
  content: '(This project is not tracked by watchtower. Only global state is shown.)',
305
- priority: 1,
403
+ priority: PRIORITY_KEEP,
306
404
  });
307
405
  }
308
406
 
@@ -322,16 +420,17 @@ function assembleSections(sections) {
322
420
  }
323
421
 
324
422
  // Need to truncate. Remove sections in reverse priority order (highest first).
325
- // Priority 3 = domain files (truncated first)
326
- // Priority 2 = project file (truncated second)
327
- // Priority 0 = summary (never truncated)
328
- // Priority 1 = queue/untracked (try to keep)
423
+ // PRIORITY_PATTERNS (4) = enforcement patterns (truncated first)
424
+ // PRIORITY_DOMAIN (3) = domain files
425
+ // PRIORITY_PROJECT (2) = project file
426
+ // PRIORITY_KEEP (1) = threads/queue/advisories/untracked (try to keep)
427
+ // PRIORITY_NEVER (0) = summary (never truncated)
329
428
 
330
429
  const sortedByTruncPriority = [...sections].sort((a, b) => b.priority - a.priority);
331
430
 
332
431
  let remaining = [...sections];
333
432
  for (const section of sortedByTruncPriority) {
334
- if (section.priority === 0) break; // Never truncate summary
433
+ if (section.priority === PRIORITY_NEVER) break; // Never truncate summary
335
434
 
336
435
  const idx = remaining.findIndex(s => s.key === section.key);
337
436
  if (idx === -1) continue;
@@ -6,7 +6,7 @@
6
6
 
7
7
  import {
8
8
  readFileSync, writeFileSync, existsSync,
9
- mkdirSync, renameSync, realpathSync,
9
+ mkdirSync, renameSync, realpathSync, readdirSync,
10
10
  } from 'fs';
11
11
  import { join, dirname, basename, resolve as resolvePath } from 'path';
12
12
  import { homedir } from 'os';
@@ -68,6 +68,133 @@ export function slugify(text) {
68
68
  return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
69
69
  }
70
70
 
71
+ // ---------------------------------------------------------------------------
72
+ // flushFeedbackOutbox — deliver the GLOBAL cc-feedback outbox to the CC repo
73
+ // ---------------------------------------------------------------------------
74
+ // Ring 1 mechanical duty (act:6c3a4763). Ports orient's delivery algorithm:
75
+ // read ~/.claude/cc-feedback-outbox.json, resolve the CC source repo via
76
+ // cc-registry (the entry whose package.json name is create-claude-cabinet),
77
+ // write each undelivered item's body to <cc>/feedback/{date}-{slug}.md with
78
+ // a skip-if-exists guard against feedback/ AND feedback/resolved/ — a name
79
+ // containing the slug, or a delivery-scheme name whose slug is a whole-token
80
+ // prefix/subset of the new one, means a prior session already delivered or
81
+ // resolved it — then atomically rewrite the outbox — [] on a clean pass, only the
82
+ // failed items otherwise. Fail-safe: if the destination cannot be resolved,
83
+ // the outbox is left untouched — an item is never dropped without a file
84
+ // existing for it. Ring 1 is now the SOLE feedback-delivery owner: orient's
85
+ // duplicate flush (incompatible {date}-{slug}-{seq}.md scheme) was retired
86
+ // (act:d53ff509) once this duty was verified in the live runtime.
87
+
88
+ const CC_PACKAGE_NAME = 'create-claude-cabinet';
89
+
90
+ function resolveCcFeedbackDir(registryPath) {
91
+ if (!existsSync(registryPath)) return null;
92
+ let registry;
93
+ try {
94
+ registry = JSON.parse(readFileSync(registryPath, 'utf8'));
95
+ } catch {
96
+ return null;
97
+ }
98
+ for (const proj of registry.projects || []) {
99
+ const pkgPath = join(proj.path || '', 'package.json');
100
+ if (!existsSync(pkgPath)) continue;
101
+ try {
102
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
103
+ if (pkg.name === CC_PACKAGE_NAME) return join(proj.path, 'feedback');
104
+ } catch { /* unreadable package.json — not the CC repo */ }
105
+ }
106
+ return null;
107
+ }
108
+
109
+ function feedbackFileExists(feedbackDir, slug) {
110
+ for (const dir of [feedbackDir, join(feedbackDir, 'resolved')]) {
111
+ if (!existsSync(dir)) continue;
112
+ for (const name of readdirSync(dir)) {
113
+ if (name.includes(slug)) return true;
114
+ // Reverse direction (act:ca0aee25): a re-report under a LONGER title
115
+ // must still match its already-delivered shorter twin. Deliberately
116
+ // narrow — only delivery-scheme names ({date}-{slug}[-{seq}].md)
117
+ // carry slug semantics, and the existing slug must sit on whole
118
+ // dash-token boundaries inside the new one. Silent suppression of
119
+ // genuinely new feedback is worse than a duplicate delivery, so no
120
+ // substring fuzz and no participation by hand-named files.
121
+ const m = /^\d{4}-\d{2}-\d{2}-(.+?)(?:-\d+)?\.md$/.exec(name);
122
+ if (m && `-${slug}-`.includes(`-${m[1]}-`)) return true;
123
+ }
124
+ }
125
+ return false;
126
+ }
127
+
128
+ export function flushFeedbackOutbox(opts = {}) {
129
+ const outboxPath = opts.outboxPath
130
+ || join(homedir(), '.claude', 'cc-feedback-outbox.json');
131
+ const registryPath = opts.registryPath
132
+ || join(homedir(), '.claude', 'cc-registry.json');
133
+
134
+ const result = { status: 'ok', delivered: 0, skipped: 0, kept: 0, destination: null };
135
+
136
+ if (!existsSync(outboxPath)) {
137
+ result.status = 'no-outbox';
138
+ return result;
139
+ }
140
+
141
+ let outbox;
142
+ try {
143
+ outbox = JSON.parse(readFileSync(outboxPath, 'utf8'));
144
+ } catch {
145
+ // Malformed outbox: per orient's contract, warn and reset to [].
146
+ atomicWrite(outboxPath, '[]\n');
147
+ result.status = 'malformed-reset';
148
+ return result;
149
+ }
150
+
151
+ if (!Array.isArray(outbox) || outbox.length === 0) {
152
+ result.status = 'empty';
153
+ return result;
154
+ }
155
+
156
+ const undelivered = outbox.filter((item) => item && item.delivered !== true);
157
+ if (undelivered.length === 0) {
158
+ // Only stale delivered:true markers remain — don't accumulate them.
159
+ atomicWrite(outboxPath, '[]\n');
160
+ result.status = 'markers-cleared';
161
+ return result;
162
+ }
163
+
164
+ const feedbackDir = opts.destination || resolveCcFeedbackDir(registryPath);
165
+ if (!feedbackDir) {
166
+ // Cannot resolve where feedback lives. Leave the outbox untouched —
167
+ // never mark/drop an item without a delivered file existing for it.
168
+ result.status = 'no-destination';
169
+ result.kept = outbox.length;
170
+ return result;
171
+ }
172
+ result.destination = feedbackDir;
173
+
174
+ const failed = [];
175
+ for (const item of undelivered) {
176
+ try {
177
+ const slug = slugify(item.title || 'untitled') || 'untitled';
178
+ if (feedbackFileExists(feedbackDir, slug)) {
179
+ result.skipped++;
180
+ continue;
181
+ }
182
+ const date = item.date || new Date().toISOString().slice(0, 10);
183
+ if (!existsSync(feedbackDir)) mkdirSync(feedbackDir, { recursive: true });
184
+ writeFileSync(join(feedbackDir, `${date}-${slug}.md`), item.body || item.title || '');
185
+ result.delivered++;
186
+ } catch {
187
+ failed.push(item);
188
+ }
189
+ }
190
+
191
+ // Clean pass resets to []; otherwise keep only the failed items.
192
+ atomicWrite(outboxPath, JSON.stringify(failed, null, 2) + '\n');
193
+ result.kept = failed.length;
194
+ if (failed.length > 0) result.status = 'partial';
195
+ return result;
196
+ }
197
+
71
198
  // ---------------------------------------------------------------------------
72
199
  // Thread cursor history (schema v2)
73
200
  // ---------------------------------------------------------------------------
@@ -236,6 +363,55 @@ export function preserveRing3LastSession(freshContent, existingContent) {
236
363
  return freshContent.slice(0, freshIdx) + preserved + '\n' + freshContent.slice(end);
237
364
  }
238
365
 
366
+ // ---------------------------------------------------------------------------
367
+ // writeProjectStatePreservingRing3 — race-safe project-state rebuild write
368
+ // ---------------------------------------------------------------------------
369
+ // preserveRing3LastSession is a pure merge; a raw read→merge→atomicWrite
370
+ // around it has a read-then-write race: if Ring 3 writes its fresh Last
371
+ // Session between Ring 1's snapshot read and the rename, the rebuild is
372
+ // computed from a stale snapshot and silently drops the just-authored
373
+ // summary until the NEXT session close. This helper closes that window
374
+ // with a re-read check-and-retry: merge against a snapshot, re-read to
375
+ // verify nothing changed underneath us, and only then write; on change,
376
+ // re-merge against the fresh read (up to maxAttempts). If attempts are
377
+ // exhausted (pathological churn), it merges against the FRESHEST read and
378
+ // writes anyway — the rebuild is never skipped.
379
+ //
380
+ // Residual window: a write landing between the verify read and the rename
381
+ // inside atomicWrite is still theoretically possible — microseconds wide,
382
+ // down from the full merge-computation window. If it ever bites in
383
+ // practice, the structural fix is the Ring 3 sidecar-file design (option c
384
+ // in .claude/plans/watchtower-ring1-race-fix.md).
385
+ //
386
+ // opts._beforeVerifyHook is a TEST-ONLY injection seam, fired between the
387
+ // merge and the verify re-read so tests can deterministically simulate a
388
+ // concurrent Ring 3 write. Production callers must not pass it.
389
+ //
390
+ // Returns { written: true, attempts, exhausted? }.
391
+ export function writeProjectStatePreservingRing3(statePath, freshContent, opts = {}) {
392
+ const maxAttempts = opts.maxAttempts ?? 3;
393
+ const read = () => (existsSync(statePath) ? readFileSync(statePath, 'utf8') : null);
394
+
395
+ let snapshot = read();
396
+ for (let i = 0; i < maxAttempts; i++) {
397
+ const merged = preserveRing3LastSession(freshContent, snapshot);
398
+ opts._beforeVerifyHook?.();
399
+ const verify = read();
400
+ if (verify !== snapshot) {
401
+ // Someone (Ring 3) wrote since our read — re-merge against it.
402
+ snapshot = verify;
403
+ continue;
404
+ }
405
+ atomicWrite(statePath, merged);
406
+ return { written: true, attempts: i + 1 };
407
+ }
408
+
409
+ // Attempts exhausted (pathological churn): merge against the freshest
410
+ // read and write anyway — never skip the rebuild.
411
+ atomicWrite(statePath, preserveRing3LastSession(freshContent, read()));
412
+ return { written: true, attempts: maxAttempts, exhausted: true };
413
+ }
414
+
239
415
  // ---------------------------------------------------------------------------
240
416
  // resolveProjectIdentity — THE canonical "which project is this?" answer
241
417
  // ---------------------------------------------------------------------------
@@ -4,7 +4,7 @@
4
4
  // All writes use atomic temp+rename per watchtower-contracts.md.
5
5
  // Queue uses directory listing, not index files (no-index convention).
6
6
 
7
- import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync, renameSync } from 'fs';
7
+ import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync, renameSync, unlinkSync } from 'fs';
8
8
  import { join } from 'path';
9
9
  import { randomBytes } from 'crypto';
10
10
 
@@ -13,6 +13,23 @@ const WATCHTOWER_DIR = process.env.WATCHTOWER_DIR
13
13
 
14
14
  const QUEUE_DIR = join(WATCHTOWER_DIR, 'queue', 'items');
15
15
 
16
+ // mux's dispatch queue (bin/mux `qa` verbs — the single desk-dispatch path,
17
+ // carrying qa-handoffs AND routine dispatches). Descriptors are routing
18
+ // convenience keyed <desk>/<item_id>.json (plus <desk>/in-flight/ once
19
+ // drained); the inbox item is the durable record. Terminal exits here must
20
+ // clear the matching descriptor or the two stores drift (act:796fe6dc —
21
+ // resolved ghosts get re-offered by `mux qa drain`). The dir name is legacy.
22
+ const MUX_QA_DIR = process.env.MUX_QA_DIR
23
+ || join(process.env.HOME, '.local', 'share', 'mux', 'qa-handoff');
24
+
25
+ // Categories whose items are pushed to a desk via the mux dispatch queue.
26
+ // Every terminal exit (resolve / dismiss / supersede / expire) on an item in
27
+ // one of these categories clears its dispatch descriptor(s). Exported so
28
+ // consumers (e.g. the /session-handoff epilogue drain) exclude dispatched
29
+ // items by importing this set rather than re-listing categories in prose —
30
+ // a hand-maintained copy would drift the moment a third category is added.
31
+ export const DISPATCHED_CATEGORIES = new Set(['qa-handoff', 'routine']);
32
+
16
33
  // --- Helpers ---
17
34
 
18
35
  function ensureDir(dir) {
@@ -42,6 +59,27 @@ function generateId() {
42
59
  return 'dec-' + randomBytes(4).toString('hex');
43
60
  }
44
61
 
62
+ // Best-effort removal of the mux dispatch-queue descriptor(s) for an item —
63
+ // both the queued copy and the in-flight copy, across every desk dir (desk
64
+ // names differ from project names, so we sweep rather than guess). Never
65
+ // throws: a routing-cleanup failure must not block a gate exit. The ·N badge
66
+ // is recomputed by mux on its next mutation/desk-open, so a deletion here is
67
+ // picked up without tmux involvement.
68
+ function clearDispatchEntries(item) {
69
+ try {
70
+ if (!existsSync(MUX_QA_DIR)) return;
71
+ for (const desk of readdirSync(MUX_QA_DIR, { withFileTypes: true })) {
72
+ if (!desk.isDirectory()) continue;
73
+ for (const sub of ['.', 'in-flight']) {
74
+ const fp = join(MUX_QA_DIR, desk.name, sub, `${item.id}.json`);
75
+ try {
76
+ if (existsSync(fp)) unlinkSync(fp);
77
+ } catch { /* best-effort per file */ }
78
+ }
79
+ }
80
+ } catch { /* best-effort overall */ }
81
+ }
82
+
45
83
  // Urgency sort order: urgent < normal < low (urgent first)
46
84
  const URGENCY_ORDER = { urgent: 0, normal: 1, low: 2 };
47
85
 
@@ -57,6 +95,15 @@ const URGENCY_ORDER = { urgent: 0, normal: 1, low: 2 };
57
95
  // functions below only call into it.
58
96
 
59
97
  const QA_CATEGORY = 'qa-handoff';
98
+
99
+ // Categories whose items carry a structural recipient gate: they may never be
100
+ // included in a batch disposition — each leaves the queue only through its own
101
+ // gate (per-item resolve with a validated verdict, or a typed per-item
102
+ // dismissal). Distinct from DISPATCHED_CATEGORIES: 'routine' is dispatched but
103
+ // NOT gated — stale routines are legal batch fodder. Future gated categories
104
+ // join this set and inherit batch refusal for free.
105
+ export const GATED_CATEGORIES = new Set([QA_CATEGORY]);
106
+
60
107
  const QA_TERMINAL_VERDICTS = ['runtime-verified', 'blocked'];
61
108
  // The parameterized token. Canonical form uses U+00B7 (·); input tolerates
62
109
  // common separator drift (-, –, —, *, •) because sessions retype labels.
@@ -326,6 +373,7 @@ export function resolveItem(id, { resolution, resolution_notes = null, resolutio
326
373
  item.resolution_type = resolution_type;
327
374
  item.resolution_notes = resolution_notes;
328
375
  atomicWrite(fp, item);
376
+ if (DISPATCHED_CATEGORIES.has(item.category)) clearDispatchEntries(item);
329
377
  return item;
330
378
  }
331
379
 
@@ -349,6 +397,7 @@ export function dismissItem(id, { notes = null, resolution_type = null } = {}) {
349
397
  item.resolution_type = resolution_type;
350
398
  item.resolution_notes = notes;
351
399
  atomicWrite(fp, item);
400
+ if (DISPATCHED_CATEGORIES.has(item.category)) clearDispatchEntries(item);
352
401
  return item;
353
402
  }
354
403
 
@@ -368,6 +417,7 @@ export function supersedeItem(id, { reason = null } = {}) {
368
417
  item.status = 'superseded';
369
418
  item.resolution_notes = reason;
370
419
  atomicWrite(fp, item);
420
+ if (DISPATCHED_CATEGORIES.has(item.category)) clearDispatchEntries(item);
371
421
  return item;
372
422
  }
373
423
 
@@ -386,9 +436,64 @@ export function expireItem(id) {
386
436
  item.status = 'expired';
387
437
  item.resolution_notes = 'Auto-expired by age policy';
388
438
  atomicWrite(fp, item);
439
+ if (DISPATCHED_CATEGORIES.has(item.category)) clearDispatchEntries(item);
389
440
  return item;
390
441
  }
391
442
 
443
+ /**
444
+ * Apply ONE disposition to a batch of ungated items — the single bulk path,
445
+ * used by /briefing's batch dispositions and /inbox's bulk triage. A batch is one
446
+ * operator decision applied to many items, so the typed reason is REQUIRED:
447
+ * it lands on every item as its audit trail.
448
+ *
449
+ * All-or-nothing pre-validation: throws BEFORE any write when (a) any id is
450
+ * unknown, (b) any item is in a GATED_CATEGORIES category — gated items never
451
+ * batch; each goes through its own gate — or (c) the typed reason is absent.
452
+ * A batch that would hit a gate fails whole, never half-applies.
453
+ * Non-pending items are not an error (another session may have raced the
454
+ * disposition); they are skipped and reported.
455
+ * @param {string[]} ids
456
+ * @param {object} params
457
+ * @param {'dismiss'|'resolve'} params.disposition
458
+ * @param {string} params.resolution_type - typed reason (e.g. 'stale',
459
+ * 'noise', 'captured-to-memory', 'acted-on') — required
460
+ * @param {string} params.notes - plain-language reason — required
461
+ * @param {string} [params.resolution] - resolve batches: the resolution
462
+ * value stamped on each item (defaults to resolution_type)
463
+ * @returns {{applied: string[], skipped_not_pending: string[]}}
464
+ */
465
+ export function applyBatch(ids, { disposition, resolution_type, notes, resolution = null } = {}) {
466
+ if (!Array.isArray(ids) || ids.length === 0) {
467
+ throw new Error('applyBatch: ids must be a non-empty array');
468
+ }
469
+ if (disposition !== 'dismiss' && disposition !== 'resolve') {
470
+ throw new Error("applyBatch: disposition must be 'dismiss' or 'resolve'");
471
+ }
472
+ if (typeof resolution_type !== 'string' || !resolution_type.trim()
473
+ || typeof notes !== 'string' || !notes.trim()) {
474
+ throw new Error('applyBatch: a typed resolution_type AND notes are required — a batch is one decision applied to many items, and the reason lands on every one');
475
+ }
476
+ const items = ids.map((id) => {
477
+ const item = getItem(id);
478
+ if (!item) throw new Error(`applyBatch: unknown item ${id} — batches pre-validate whole; nothing was applied`);
479
+ return item;
480
+ });
481
+ const gated = items.filter((i) => GATED_CATEGORIES.has(i.category));
482
+ if (gated.length > 0) {
483
+ throw new Error(`applyBatch: ${gated.map((i) => `${i.id} (${i.category})`).join(', ')} carr${gated.length === 1 ? 'ies' : 'y'} a recipient gate — gated items never batch; handle each through its own gate. Nothing was applied`);
484
+ }
485
+ const applied = [];
486
+ const skipped_not_pending = [];
487
+ for (const item of items) {
488
+ const result = disposition === 'dismiss'
489
+ ? dismissItem(item.id, { notes, resolution_type })
490
+ : resolveItem(item.id, { resolution: resolution ?? resolution_type, resolution_notes: notes, resolution_type });
491
+ if (result) applied.push(item.id);
492
+ else skipped_not_pending.push(item.id);
493
+ }
494
+ return { applied, skipped_not_pending };
495
+ }
496
+
392
497
  /**
393
498
  * List pending inbox items with optional filters.
394
499
  * @param {object} filters
@@ -425,6 +530,45 @@ export function listPending({ project, category, urgency, maxAge } = {}) {
425
530
  return items;
426
531
  }
427
532
 
533
+ /**
534
+ * List inbox items across ALL statuses with optional filters — the single
535
+ * source for "read non-pending items". (Ring 2's private readAllQueueItems
536
+ * fork predates this helper; consolidating it is a separate-lane follow-up.)
537
+ * @param {object} filters
538
+ * @param {string} [filters.project] - exact match on item.project
539
+ * @param {string} [filters.category] - exact match on item.category
540
+ * @param {string[]} [filters.statuses] - keep items whose status is in the
541
+ * array (omit = all statuses)
542
+ * @param {string} [filters.since] - ISO date; keep items whose
543
+ * `resolved_at || filed_at` >= since (bounds corpus growth for dedup callers)
544
+ * @returns {Array} items sorted newest first by (resolved_at || filed_at)
545
+ */
546
+ export function listItems({ project, category, statuses, since } = {}) {
547
+ if (!existsSync(QUEUE_DIR)) return [];
548
+ const sinceMs = since ? new Date(since).getTime() : null;
549
+ const entries = readdirSync(QUEUE_DIR, { withFileTypes: true });
550
+ const items = [];
551
+ for (const entry of entries) {
552
+ if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
553
+ try {
554
+ const item = readItem(join(QUEUE_DIR, entry.name));
555
+ if (project && item.project !== project) continue;
556
+ if (category && item.category !== category) continue;
557
+ if (Array.isArray(statuses) && !statuses.includes(item.status)) continue;
558
+ if (sinceMs != null) {
559
+ const ts = new Date(item.resolved_at || item.filed_at).getTime();
560
+ if (!(ts >= sinceMs)) continue;
561
+ }
562
+ items.push(item);
563
+ } catch {
564
+ // Skip unparseable items
565
+ }
566
+ }
567
+ items.sort((a, b) =>
568
+ new Date(b.resolved_at || b.filed_at) - new Date(a.resolved_at || a.filed_at));
569
+ return items;
570
+ }
571
+
428
572
  /**
429
573
  * Get a single inbox item by id.
430
574
  * @param {string} id
@@ -490,6 +634,7 @@ export function runExpiry({ warnDays = 14, expireDays = 30 } = {}) {
490
634
  item.status = 'expired';
491
635
  item.resolution_notes = `Auto-expired after ${expireDays} days. If still relevant, re-file with updated context.`;
492
636
  atomicWrite(itemPath(item.id), item);
637
+ if (DISPATCHED_CATEGORIES.has(item.category)) clearDispatchEntries(item);
493
638
  expired.push(item);
494
639
  } else if (age >= warnMs) {
495
640
  warned.push(item);