create-claude-cabinet 0.45.0 → 0.47.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 (165) hide show
  1. package/README.md +6 -10
  2. package/lib/cli.js +374 -91
  3. package/lib/copy.js +108 -6
  4. package/lib/db-setup.js +122 -17
  5. package/lib/engagement-server-setup.js +34 -9
  6. package/lib/metadata.js +51 -2
  7. package/lib/migrate-from-omega.js +13 -1
  8. package/lib/mux-setup.js +33 -9
  9. package/lib/settings-merge.js +52 -10
  10. package/lib/watchtower-setup.js +230 -0
  11. package/package.json +5 -1
  12. package/templates/cabinet/_cabinet-member-template.md +8 -3
  13. package/templates/cabinet/advisories-state-schema.md +34 -7
  14. package/templates/cabinet/checklist-stats-schema.md +15 -1
  15. package/templates/cabinet/composition-patterns.md +4 -3
  16. package/templates/cabinet/memory-lifecycle-contract.md +135 -0
  17. package/templates/cabinet/pib-db-access.md +13 -0
  18. package/templates/cabinet/skill-output-conventions.md +35 -1
  19. package/templates/cabinet/watchtower-contracts.md +503 -1
  20. package/templates/cabinet/worktree-invocation-contract.md +87 -0
  21. package/templates/engagement/OVERVIEW.md +12 -0
  22. package/templates/engagement/__tests__/engagement.test.mjs +177 -2
  23. package/templates/engagement/__tests__/invoice-doc.test.mjs +195 -0
  24. package/templates/engagement/engagement-preview.mjs +100 -0
  25. package/templates/engagement/engagement-schema.md +240 -0
  26. package/templates/engagement/engagement.mjs +391 -11
  27. package/templates/engagement/pib-db-patches/pib-db-lib.mjs +10 -1
  28. package/templates/engagement/pib-db-patches/pib-db-mcp-server.mjs +5 -44
  29. package/templates/engagement/pib-db-patches/pib-db.mjs +5 -2
  30. package/templates/hooks/action-completion-gate.sh +9 -3
  31. package/templates/hooks/memory-index-guard.sh +17 -11
  32. package/templates/hooks/watchtower-session-start.sh +24 -2
  33. package/templates/mcp/pib-db.json +1 -4
  34. package/templates/mux/__tests__/mux-fail-loud.fixture.sh +44 -0
  35. package/templates/mux/__tests__/station-liveness.fixture.sh +234 -0
  36. package/templates/mux/__tests__/station-liveness.test.mjs +47 -0
  37. package/templates/mux/bin/mux +289 -56
  38. package/templates/mux/config/help.txt +1 -0
  39. package/templates/rules/acknowledge-when-corrected.md +33 -0
  40. package/templates/rules/maintainability.md +11 -0
  41. package/templates/rules/memory-capture.md +26 -4
  42. package/templates/rules/plan-before-bulk-or-irreversible-actions.md +48 -0
  43. package/templates/rules/verify-before-asserting.md +79 -0
  44. package/templates/scripts/__tests__/advisor-pass.test.mjs +238 -0
  45. package/templates/scripts/__tests__/advisories.test.mjs +262 -0
  46. package/templates/scripts/__tests__/ahead-check-origin.test.mjs +355 -0
  47. package/templates/scripts/__tests__/batch-disposition.test.mjs +235 -0
  48. package/templates/scripts/__tests__/claude-churn-authored.test.mjs +90 -0
  49. package/templates/scripts/__tests__/cross-ring-reader.test.mjs +505 -0
  50. package/templates/scripts/__tests__/dx-captures-briefing.test.mjs +169 -0
  51. package/templates/scripts/__tests__/feedback-outbox-flush.test.mjs +232 -0
  52. package/templates/scripts/__tests__/hook-runner.test.mjs +247 -0
  53. package/templates/scripts/__tests__/narrative-corpus.test.mjs +247 -0
  54. package/templates/scripts/__tests__/phase-shim.test.mjs +112 -0
  55. package/templates/scripts/__tests__/qa-handoff-gate.test.mjs +68 -0
  56. package/templates/scripts/__tests__/qa-handoff-merge-state.test.mjs +162 -0
  57. package/templates/scripts/__tests__/resolve-cli.test.mjs +314 -0
  58. package/templates/scripts/__tests__/ring-state-ownership.test.mjs +228 -3
  59. package/templates/scripts/__tests__/ring1-content-detector.test.mjs +168 -0
  60. package/templates/scripts/__tests__/ring1-flagged-actions.test.mjs +135 -0
  61. package/templates/scripts/__tests__/ring1-script-drift.test.mjs +126 -0
  62. package/templates/scripts/__tests__/ring2-queue-reader.test.mjs +80 -0
  63. package/templates/scripts/__tests__/ring2-recall-canary.test.mjs +150 -0
  64. package/templates/scripts/__tests__/ring2-roster-review.test.mjs +311 -0
  65. package/templates/scripts/__tests__/ring2-thread-context.test.mjs +189 -0
  66. package/templates/scripts/__tests__/ring3-chunk-merge.test.mjs +122 -0
  67. package/templates/scripts/__tests__/ring3-close-lenses.test.mjs +359 -0
  68. package/templates/scripts/__tests__/ring3-dedup.test.mjs +467 -0
  69. package/templates/scripts/__tests__/ring3-memory-titles.test.mjs +187 -0
  70. package/templates/scripts/__tests__/ring3-novelty-rescue.test.mjs +148 -0
  71. package/templates/scripts/__tests__/ring3-recent-slice.test.mjs +101 -0
  72. package/templates/scripts/__tests__/ring4-reconcile.test.mjs +393 -0
  73. package/templates/scripts/__tests__/routine-dispatch.test.mjs +312 -0
  74. package/templates/scripts/__tests__/shared-thread-reader.test.mjs +187 -0
  75. package/templates/scripts/__tests__/suppression-ledger.test.mjs +148 -0
  76. package/templates/scripts/__tests__/verify-backfill.test.mjs +103 -0
  77. package/templates/scripts/__tests__/verify-coverage.test.mjs +80 -0
  78. package/templates/scripts/__tests__/watchtower-snapshot.test.mjs +261 -0
  79. package/templates/scripts/__tests__/watchtower-sync.test.mjs +311 -0
  80. package/templates/scripts/load-triage-history.js +5 -2
  81. package/templates/scripts/pib-db-mcp-server.mjs +5 -44
  82. package/templates/scripts/pib-db-path.mjs +61 -0
  83. package/templates/scripts/pib-db.mjs +5 -2
  84. package/templates/scripts/validate-memory.mjs +214 -16
  85. package/templates/scripts/watchtower-advisories.mjs +309 -0
  86. package/templates/scripts/watchtower-build-context.mjs +301 -18
  87. package/templates/scripts/watchtower-cross-ring-reader.mjs +700 -0
  88. package/templates/scripts/watchtower-hook-runner.mjs +422 -0
  89. package/templates/scripts/watchtower-lib.mjs +447 -2
  90. package/templates/scripts/watchtower-narrative-corpus.mjs +385 -0
  91. package/templates/scripts/watchtower-phase-shim.mjs +171 -0
  92. package/templates/scripts/watchtower-queue.mjs +484 -1
  93. package/templates/scripts/watchtower-ring1.mjs +533 -53
  94. package/templates/scripts/watchtower-ring2.mjs +787 -48
  95. package/templates/scripts/watchtower-ring3-close.mjs +1357 -99
  96. package/templates/scripts/watchtower-ring4-runner.sh +85 -0
  97. package/templates/scripts/watchtower-ring4.mjs +753 -0
  98. package/templates/scripts/watchtower-routines.mjs +358 -0
  99. package/templates/scripts/watchtower-snapshot.mjs +452 -0
  100. package/templates/scripts/watchtower-status.sh +1 -1
  101. package/templates/scripts/watchtower-sync.mjs +393 -0
  102. package/templates/skills/audit/SKILL.md +5 -1
  103. package/templates/skills/briefing/SKILL.md +584 -236
  104. package/templates/skills/cabinet-anthropic-insider/SKILL.md +14 -6
  105. package/templates/skills/cabinet-historian/SKILL.md +14 -11
  106. package/templates/skills/cabinet-process-therapist/SKILL.md +28 -0
  107. package/templates/skills/cabinet-security/SKILL.md +11 -0
  108. package/templates/skills/cabinet-system-advocate/SKILL.md +22 -21
  109. package/templates/skills/cabinet-user-advocate/SKILL.md +13 -7
  110. package/templates/skills/catch-up/SKILL.md +113 -0
  111. package/templates/skills/cc-publish/SKILL.md +148 -25
  112. package/templates/skills/cc-remember/SKILL.md +45 -0
  113. package/templates/skills/close/SKILL.md +107 -0
  114. package/templates/skills/collab-client/SKILL.md +22 -5
  115. package/templates/skills/collab-consultant/SKILL.md +110 -2
  116. package/templates/skills/debrief/SKILL.md +148 -16
  117. package/templates/skills/debrief-classic/SKILL.md +696 -0
  118. package/templates/skills/debrief-classic/calibration.md +44 -0
  119. package/templates/skills/debrief-classic/phases/audit-pattern-capture.md +78 -0
  120. package/templates/skills/debrief-classic/phases/auto-maintenance.md +48 -0
  121. package/templates/skills/debrief-classic/phases/checklist-feedback.md +123 -0
  122. package/templates/skills/debrief-classic/phases/close-work.md +163 -0
  123. package/templates/skills/debrief-classic/phases/health-checks.md +54 -0
  124. package/templates/skills/debrief-classic/phases/inventory.md +40 -0
  125. package/templates/skills/debrief-classic/phases/loose-ends.md +52 -0
  126. package/templates/skills/debrief-classic/phases/methodology-capture.md +223 -0
  127. package/templates/skills/debrief-classic/phases/qa-handoff-sweep.md +78 -0
  128. package/templates/skills/debrief-classic/phases/record-lessons.md +177 -0
  129. package/templates/skills/debrief-classic/phases/report.md +59 -0
  130. package/templates/skills/debrief-classic/phases/update-state.md +48 -0
  131. package/templates/skills/debrief-classic/phases/upstream-feedback.md +185 -0
  132. package/templates/skills/debrief-classic/phases/verify-coverage.md +101 -0
  133. package/templates/skills/execute/SKILL.md +77 -6
  134. package/templates/skills/execute/phases/commit-and-deploy.md +8 -0
  135. package/templates/skills/execute-group/SKILL.md +23 -26
  136. package/templates/skills/generate-plan-groups/SKILL.md +20 -0
  137. package/templates/skills/inbox/SKILL.md +124 -7
  138. package/templates/skills/memory/SKILL.md +22 -6
  139. package/templates/skills/orient/SKILL.md +140 -52
  140. package/templates/skills/orient-classic/SKILL.md +770 -0
  141. package/templates/skills/orient-classic/phases/auto-maintenance.md +52 -0
  142. package/templates/skills/orient-classic/phases/briefing.md +53 -0
  143. package/templates/skills/orient-classic/phases/cabinet.md +46 -0
  144. package/templates/skills/orient-classic/phases/checklist-status.md +54 -0
  145. package/templates/skills/orient-classic/phases/context.md +88 -0
  146. package/templates/skills/orient-classic/phases/data-sync.md +35 -0
  147. package/templates/skills/orient-classic/phases/deferred-check.md +55 -0
  148. package/templates/skills/orient-classic/phases/dx-captures.md +53 -0
  149. package/templates/skills/orient-classic/phases/health-checks.md +50 -0
  150. package/templates/skills/orient-classic/phases/verify-backfill.md +109 -0
  151. package/templates/skills/orient-classic/phases/work-scan.md +69 -0
  152. package/templates/skills/plan/SKILL.md +8 -0
  153. package/templates/skills/qa-drain/SKILL.md +157 -0
  154. package/templates/skills/qa-handoff/SKILL.md +287 -46
  155. package/templates/skills/session-handoff/SKILL.md +175 -6
  156. package/templates/skills/setup-accounts/SKILL.md +38 -16
  157. package/templates/skills/threads/SKILL.md +4 -0
  158. package/templates/skills/triage-audit/SKILL.md +6 -0
  159. package/templates/skills/validate/phases/validators.md +41 -0
  160. package/templates/skills/watchtower/SKILL.md +199 -2
  161. package/templates/verify-runtime/CONVENTIONS.md +9 -0
  162. package/templates/verify-runtime/README.md +37 -0
  163. package/templates/watchtower/config.json.template +10 -2
  164. package/templates/watchtower/queue/items/item.json.schema +1 -1
  165. package/templates/workflows/execute-group-complete.js +10 -1
@@ -4,15 +4,33 @@
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, realpathSync } from 'fs';
8
8
  import { join } from 'path';
9
9
  import { randomBytes } from 'crypto';
10
+ import { pathToFileURL } from 'url';
10
11
 
11
12
  const WATCHTOWER_DIR = process.env.WATCHTOWER_DIR
12
13
  || join(process.env.HOME, '.claude-cabinet', 'watchtower');
13
14
 
14
15
  const QUEUE_DIR = join(WATCHTOWER_DIR, 'queue', 'items');
15
16
 
17
+ // mux's dispatch queue (bin/mux `qa` verbs — the single desk-dispatch path,
18
+ // carrying qa-handoffs AND routine dispatches). Descriptors are routing
19
+ // convenience keyed <desk>/<item_id>.json (plus <desk>/in-flight/ once
20
+ // drained); the inbox item is the durable record. Terminal exits here must
21
+ // clear the matching descriptor or the two stores drift (act:796fe6dc —
22
+ // resolved ghosts get re-offered by `mux qa drain`). The dir name is legacy.
23
+ const MUX_QA_DIR = process.env.MUX_QA_DIR
24
+ || join(process.env.HOME, '.local', 'share', 'mux', 'qa-handoff');
25
+
26
+ // Categories whose items are pushed to a desk via the mux dispatch queue.
27
+ // Every terminal exit (resolve / dismiss / supersede / expire) on an item in
28
+ // one of these categories clears its dispatch descriptor(s). Exported so
29
+ // consumers (e.g. the /session-handoff epilogue drain) exclude dispatched
30
+ // items by importing this set rather than re-listing categories in prose —
31
+ // a hand-maintained copy would drift the moment a third category is added.
32
+ export const DISPATCHED_CATEGORIES = new Set(['qa-handoff', 'routine']);
33
+
16
34
  // --- Helpers ---
17
35
 
18
36
  function ensureDir(dir) {
@@ -42,6 +60,27 @@ function generateId() {
42
60
  return 'dec-' + randomBytes(4).toString('hex');
43
61
  }
44
62
 
63
+ // Best-effort removal of the mux dispatch-queue descriptor(s) for an item —
64
+ // both the queued copy and the in-flight copy, across every desk dir (desk
65
+ // names differ from project names, so we sweep rather than guess). Never
66
+ // throws: a routing-cleanup failure must not block a gate exit. The ·N badge
67
+ // is recomputed by mux on its next mutation/desk-open, so a deletion here is
68
+ // picked up without tmux involvement.
69
+ function clearDispatchEntries(item) {
70
+ try {
71
+ if (!existsSync(MUX_QA_DIR)) return;
72
+ for (const desk of readdirSync(MUX_QA_DIR, { withFileTypes: true })) {
73
+ if (!desk.isDirectory()) continue;
74
+ for (const sub of ['.', 'in-flight']) {
75
+ const fp = join(MUX_QA_DIR, desk.name, sub, `${item.id}.json`);
76
+ try {
77
+ if (existsSync(fp)) unlinkSync(fp);
78
+ } catch { /* best-effort per file */ }
79
+ }
80
+ }
81
+ } catch { /* best-effort overall */ }
82
+ }
83
+
45
84
  // Urgency sort order: urgent < normal < low (urgent first)
46
85
  const URGENCY_ORDER = { urgent: 0, normal: 1, low: 2 };
47
86
 
@@ -55,8 +94,26 @@ const URGENCY_ORDER = { urgent: 0, normal: 1, low: 2 };
55
94
  // sin, a silent stamp with no coverage look, is impossible rather than
56
95
  // discouraged. Keep all qa-handoff domain knowledge fenced here; the CRUD
57
96
  // functions below only call into it.
97
+ //
98
+ // merge_state (evidence.merge_state): a handoff is normally filed AFTER the
99
+ // worktree branch merged ('merged' — the original and default contract). A
100
+ // handoff filed BEFORE the merge (the operator is gating the merge on an
101
+ // in-flight staging deploy) carries 'merge-pending': the skill defers stage-2
102
+ // dispatch and the pickup prompt says "merge then QA" instead of asserting a
103
+ // merge — no more hand-flagging merged_into: "PENDING". The recipient gate is
104
+ // unchanged: a merge-pending handoff still resolves only through a stamped
105
+ // qa_verdict, cited against the post-merge commit once the merge has happened.
58
106
 
59
107
  const QA_CATEGORY = 'qa-handoff';
108
+
109
+ // Categories whose items carry a structural recipient gate: they may never be
110
+ // included in a batch disposition — each leaves the queue only through its own
111
+ // gate (per-item resolve with a validated verdict, or a typed per-item
112
+ // dismissal). Distinct from DISPATCHED_CATEGORIES: 'routine' is dispatched but
113
+ // NOT gated — stale routines are legal batch fodder. Future gated categories
114
+ // join this set and inherit batch refusal for free.
115
+ export const GATED_CATEGORIES = new Set([QA_CATEGORY]);
116
+
60
117
  const QA_TERMINAL_VERDICTS = ['runtime-verified', 'blocked'];
61
118
  // The parameterized token. Canonical form uses U+00B7 (·); input tolerates
62
119
  // common separator drift (-, –, —, *, •) because sessions retype labels.
@@ -82,6 +139,28 @@ export function normalizeQaVerdictToken(raw) {
82
139
  return null;
83
140
  }
84
141
 
142
+ // The two legal merge states for a qa-handoff item.
143
+ export const QA_MERGE_STATES = ['merged', 'merge-pending'];
144
+
145
+ /**
146
+ * Normalize a qa-handoff merge_state token to canonical form.
147
+ * Absent (null/undefined) defaults to 'merged' — the original contract, where
148
+ * a handoff was always filed AFTER the worktree branch merged. A handoff filed
149
+ * before the merge carries 'merge-pending'. Tolerates separator/case drift
150
+ * (merge_pending, "merge pending", pending, unmerged); returns null only for a
151
+ * present-but-illegal token, so a typo cannot silently read as 'merged'.
152
+ * @param {*} raw
153
+ * @returns {'merged'|'merge-pending'|null}
154
+ */
155
+ export function normalizeMergeState(raw) {
156
+ if (raw == null) return 'merged';
157
+ if (typeof raw !== 'string') return null;
158
+ const t = raw.normalize('NFKC').trim().toLowerCase().replace(/[\s_]+/g, '-');
159
+ if (t === 'merged') return 'merged';
160
+ if (['merge-pending', 'mergepending', 'pending', 'unmerged'].includes(t)) return 'merge-pending';
161
+ return null;
162
+ }
163
+
85
164
  function gateError(item, message) {
86
165
  // Never echo resolution/notes content back — name fields only.
87
166
  return new Error(`qa-handoff gate: cannot close ${item.id} — ${message}`);
@@ -238,6 +317,82 @@ export function emitPatternPromotion({
238
317
  });
239
318
  }
240
319
 
320
+ // --- High-confidence sign-off predicate (bulk-triage batching) ---
321
+ //
322
+ // Most pending items are knowledge-extraction items the rings already DRAFTED
323
+ // (Ring 3 mints a memory `draft_artifact` from the session transcript). These
324
+ // aren't judgment calls — they're SIGN-OFFS on work the system already did.
325
+ // Because each is uniquely titled, title-grouping degrades bulk triage to a
326
+ // per-item walk, burning the same decision fuel on a sign-off as on a hard
327
+ // choice. The predicate below isolates that class so bulk triage can offer it
328
+ // as ONE explicit batch sign-off (still human-confirmed — never auto-resolved;
329
+ // the frozen-at-propose invariant holds because the operator approves the
330
+ // whole batch before any applyBatch write).
331
+ //
332
+ // The signal reuses the SAME "drafts ready" cue the SessionStart context
333
+ // builder already counts (watchtower-build-context.mjs: knowledge-extraction +
334
+ // non-empty draft_artifact) — single source for "the system pre-authored this".
335
+ // An item is held back for individual attention (NOT signed off in a batch)
336
+ // when it is urgent, low-confidence, or carries no pre-drafted artifact —
337
+ // those are the ones that actually want a human look. Gated categories
338
+ // (qa-handoff) are never high-confidence sign-off by construction: they exit
339
+ // only through their own recipient gate.
340
+
341
+ // Categories whose pre-drafted, normal-priority items are sign-off-shaped.
342
+ // Today only knowledge-extraction (Ring 3's memory drafts). A future
343
+ // pre-drafted category joins here and inherits batch sign-off for free.
344
+ const SIGNOFF_CATEGORIES = new Set(['knowledge-extraction']);
345
+
346
+ /**
347
+ * True when an item is a high-confidence SIGN-OFF — pre-drafted work the
348
+ * system already did, safe to approve as part of an explicit one-shot batch
349
+ * rather than as its own micro-decision. False for anything that wants an
350
+ * individual human look (urgent, low-confidence, or no pre-drafted artifact)
351
+ * and for any gated-category item (those leave only through their own gate).
352
+ *
353
+ * Conservative by design: when in doubt the item is surfaced individually.
354
+ * The operator still confirms the whole batch before any write — this only
355
+ * decides WHICH items are eligible to ride in a batch sign-off.
356
+ * @param {object} item
357
+ * @returns {boolean}
358
+ */
359
+ export function isHighConfidenceSignoff(item) {
360
+ if (!item || typeof item !== 'object') return false;
361
+ if (item.status && item.status !== 'pending') return false;
362
+ if (GATED_CATEGORIES.has(item.category)) return false;
363
+ if (!SIGNOFF_CATEGORIES.has(item.category)) return false;
364
+ // The system must have actually drafted something — a non-empty artifact is
365
+ // the proof the work is done and only needs a sign-off.
366
+ if (typeof item.draft_artifact !== 'string' || !item.draft_artifact.trim()) return false;
367
+ // Urgent items want eyes, not a batch nod.
368
+ if (item.urgency === 'urgent') return false;
369
+ // An explicit low-confidence stamp means "look at this one".
370
+ if (item.confidence === 'low') return false;
371
+ return true;
372
+ }
373
+
374
+ /**
375
+ * Partition pending items into the high-confidence sign-off set (eligible for
376
+ * a one-shot batch approval) and the individual set (everything that wants a
377
+ * human look — unusual / low-confidence items AND every gated item). Gated
378
+ * items are ALWAYS individual: the predicate excludes them, so they can never
379
+ * leak into the batch, and applyBatch would reject them anyway.
380
+ *
381
+ * Pure read helper — no writes, no I/O. Bulk triage uses it to decide what to
382
+ * offer as a batch vs. one by one; the operator still approves the batch.
383
+ * @param {Array} items - pending inbox items (e.g. from listPending())
384
+ * @returns {{signoff: Array, individual: Array}}
385
+ */
386
+ export function partitionForBatchSignoff(items) {
387
+ const signoff = [];
388
+ const individual = [];
389
+ for (const item of Array.isArray(items) ? items : []) {
390
+ if (isHighConfidenceSignoff(item)) signoff.push(item);
391
+ else individual.push(item);
392
+ }
393
+ return { signoff, individual };
394
+ }
395
+
241
396
  // --- Exports ---
242
397
 
243
398
  /**
@@ -265,6 +420,38 @@ export function createItem({
265
420
  desk = null,
266
421
  }) {
267
422
  ensureDir(QUEUE_DIR);
423
+ // qa-handoff merge_state is structural: a present-but-illegal token is
424
+ // rejected at the boundary (it can't silently read as 'merged'), and a legal
425
+ // one is canonicalized. Absent stays absent — readers default to 'merged'
426
+ // via normalizeMergeState (additive-field discipline, like desk/project_
427
+ // unresolved). Only qa-handoff items carry it; other categories are untouched.
428
+ if (category === QA_CATEGORY && evidence && evidence.merge_state !== undefined) {
429
+ const ms = normalizeMergeState(evidence.merge_state);
430
+ if (!ms) {
431
+ throw new Error(`createItem: illegal qa-handoff merge_state ${JSON.stringify(evidence.merge_state)} — must be one of: ${QA_MERGE_STATES.join(' | ')}`);
432
+ }
433
+ evidence = { ...evidence, merge_state: ms };
434
+ }
435
+ // A 'merged' handoff must reference work actually on main. merge_state
436
+ // replaced the old hand-flagged `merged_into: "PENDING — not yet merged"`
437
+ // antipattern, but a producer can still free-text a not-yet-merged marker
438
+ // into merged_into/merged_commit while leaving merge_state at its 'merged'
439
+ // default — handing off UNMERGED work the drain is then told to merge
440
+ // (field incident 2026-06-17). Reject it at the boundary: a pending marker
441
+ // means the merge has not happened, so the honest state is
442
+ // merge_state:'merge-pending' (which defers stage-2 dispatch), never a
443
+ // 'merged' handoff. Pure string check; the drain's Step 0 does the
444
+ // git-ancestry verification of merged_commit. (act:e859b3a3)
445
+ if (category === QA_CATEGORY && evidence
446
+ && normalizeMergeState(evidence.merge_state) === 'merged') {
447
+ const NOT_YET_MERGED = /\b(pending|unmerged|not[\s-]*yet[\s-]*merged|to[\s-]*be[\s-]*merged|will[\s-]*merge)\b/i;
448
+ for (const k of ['merged_into', 'merged_commit']) {
449
+ const v = evidence[k];
450
+ if (typeof v === 'string' && NOT_YET_MERGED.test(v)) {
451
+ throw new Error(`createItem: qa-handoff filed as 'merged' but evidence.${k} flags the merge as not yet done (${JSON.stringify(v.slice(0, 60))}) — merge the branch first, or file with merge_state:'merge-pending' (which defers stage-2 dispatch). A 'merged' handoff must reference work actually on main.`);
452
+ }
453
+ }
454
+ }
268
455
  const id = generateId();
269
456
  const item = {
270
457
  schema_version: 1,
@@ -326,6 +513,7 @@ export function resolveItem(id, { resolution, resolution_notes = null, resolutio
326
513
  item.resolution_type = resolution_type;
327
514
  item.resolution_notes = resolution_notes;
328
515
  atomicWrite(fp, item);
516
+ if (DISPATCHED_CATEGORIES.has(item.category)) clearDispatchEntries(item);
329
517
  return item;
330
518
  }
331
519
 
@@ -349,6 +537,7 @@ export function dismissItem(id, { notes = null, resolution_type = null } = {}) {
349
537
  item.resolution_type = resolution_type;
350
538
  item.resolution_notes = notes;
351
539
  atomicWrite(fp, item);
540
+ if (DISPATCHED_CATEGORIES.has(item.category)) clearDispatchEntries(item);
352
541
  return item;
353
542
  }
354
543
 
@@ -368,6 +557,7 @@ export function supersedeItem(id, { reason = null } = {}) {
368
557
  item.status = 'superseded';
369
558
  item.resolution_notes = reason;
370
559
  atomicWrite(fp, item);
560
+ if (DISPATCHED_CATEGORIES.has(item.category)) clearDispatchEntries(item);
371
561
  return item;
372
562
  }
373
563
 
@@ -386,9 +576,64 @@ export function expireItem(id) {
386
576
  item.status = 'expired';
387
577
  item.resolution_notes = 'Auto-expired by age policy';
388
578
  atomicWrite(fp, item);
579
+ if (DISPATCHED_CATEGORIES.has(item.category)) clearDispatchEntries(item);
389
580
  return item;
390
581
  }
391
582
 
583
+ /**
584
+ * Apply ONE disposition to a batch of ungated items — the single bulk path,
585
+ * used by /briefing's batch dispositions and /inbox's bulk triage. A batch is one
586
+ * operator decision applied to many items, so the typed reason is REQUIRED:
587
+ * it lands on every item as its audit trail.
588
+ *
589
+ * All-or-nothing pre-validation: throws BEFORE any write when (a) any id is
590
+ * unknown, (b) any item is in a GATED_CATEGORIES category — gated items never
591
+ * batch; each goes through its own gate — or (c) the typed reason is absent.
592
+ * A batch that would hit a gate fails whole, never half-applies.
593
+ * Non-pending items are not an error (another session may have raced the
594
+ * disposition); they are skipped and reported.
595
+ * @param {string[]} ids
596
+ * @param {object} params
597
+ * @param {'dismiss'|'resolve'} params.disposition
598
+ * @param {string} params.resolution_type - typed reason (e.g. 'stale',
599
+ * 'noise', 'captured-to-memory', 'acted-on') — required
600
+ * @param {string} params.notes - plain-language reason — required
601
+ * @param {string} [params.resolution] - resolve batches: the resolution
602
+ * value stamped on each item (defaults to resolution_type)
603
+ * @returns {{applied: string[], skipped_not_pending: string[]}}
604
+ */
605
+ export function applyBatch(ids, { disposition, resolution_type, notes, resolution = null } = {}) {
606
+ if (!Array.isArray(ids) || ids.length === 0) {
607
+ throw new Error('applyBatch: ids must be a non-empty array');
608
+ }
609
+ if (disposition !== 'dismiss' && disposition !== 'resolve') {
610
+ throw new Error("applyBatch: disposition must be 'dismiss' or 'resolve'");
611
+ }
612
+ if (typeof resolution_type !== 'string' || !resolution_type.trim()
613
+ || typeof notes !== 'string' || !notes.trim()) {
614
+ 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');
615
+ }
616
+ const items = ids.map((id) => {
617
+ const item = getItem(id);
618
+ if (!item) throw new Error(`applyBatch: unknown item ${id} — batches pre-validate whole; nothing was applied`);
619
+ return item;
620
+ });
621
+ const gated = items.filter((i) => GATED_CATEGORIES.has(i.category));
622
+ if (gated.length > 0) {
623
+ 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`);
624
+ }
625
+ const applied = [];
626
+ const skipped_not_pending = [];
627
+ for (const item of items) {
628
+ const result = disposition === 'dismiss'
629
+ ? dismissItem(item.id, { notes, resolution_type })
630
+ : resolveItem(item.id, { resolution: resolution ?? resolution_type, resolution_notes: notes, resolution_type });
631
+ if (result) applied.push(item.id);
632
+ else skipped_not_pending.push(item.id);
633
+ }
634
+ return { applied, skipped_not_pending };
635
+ }
636
+
392
637
  /**
393
638
  * List pending inbox items with optional filters.
394
639
  * @param {object} filters
@@ -425,6 +670,46 @@ export function listPending({ project, category, urgency, maxAge } = {}) {
425
670
  return items;
426
671
  }
427
672
 
673
+ /**
674
+ * List inbox items across ALL statuses with optional filters — the single
675
+ * source for "read non-pending items". Ring 2's private readAllQueueItems
676
+ * fork (which predated this helper) was deleted and now reads through this
677
+ * helper (act:3975348f).
678
+ * @param {object} filters
679
+ * @param {string} [filters.project] - exact match on item.project
680
+ * @param {string} [filters.category] - exact match on item.category
681
+ * @param {string[]} [filters.statuses] - keep items whose status is in the
682
+ * array (omit = all statuses)
683
+ * @param {string} [filters.since] - ISO date; keep items whose
684
+ * `resolved_at || filed_at` >= since (bounds corpus growth for dedup callers)
685
+ * @returns {Array} items sorted newest first by (resolved_at || filed_at)
686
+ */
687
+ export function listItems({ project, category, statuses, since } = {}) {
688
+ if (!existsSync(QUEUE_DIR)) return [];
689
+ const sinceMs = since ? new Date(since).getTime() : null;
690
+ const entries = readdirSync(QUEUE_DIR, { withFileTypes: true });
691
+ const items = [];
692
+ for (const entry of entries) {
693
+ if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
694
+ try {
695
+ const item = readItem(join(QUEUE_DIR, entry.name));
696
+ if (project && item.project !== project) continue;
697
+ if (category && item.category !== category) continue;
698
+ if (Array.isArray(statuses) && !statuses.includes(item.status)) continue;
699
+ if (sinceMs != null) {
700
+ const ts = new Date(item.resolved_at || item.filed_at).getTime();
701
+ if (!(ts >= sinceMs)) continue;
702
+ }
703
+ items.push(item);
704
+ } catch {
705
+ // Skip unparseable items
706
+ }
707
+ }
708
+ items.sort((a, b) =>
709
+ new Date(b.resolved_at || b.filed_at) - new Date(a.resolved_at || a.filed_at));
710
+ return items;
711
+ }
712
+
428
713
  /**
429
714
  * Get a single inbox item by id.
430
715
  * @param {string} id
@@ -490,6 +775,7 @@ export function runExpiry({ warnDays = 14, expireDays = 30 } = {}) {
490
775
  item.status = 'expired';
491
776
  item.resolution_notes = `Auto-expired after ${expireDays} days. If still relevant, re-file with updated context.`;
492
777
  atomicWrite(itemPath(item.id), item);
778
+ if (DISPATCHED_CATEGORIES.has(item.category)) clearDispatchEntries(item);
493
779
  expired.push(item);
494
780
  } else if (age >= warnMs) {
495
781
  warned.push(item);
@@ -498,3 +784,200 @@ export function runExpiry({ warnDays = 14, expireDays = 30 } = {}) {
498
784
 
499
785
  return { warned, expired };
500
786
  }
787
+
788
+ // --- CLI ---
789
+ //
790
+ // watchtower-queue.mjs is primarily a LIBRARY (every other caller `import`s it).
791
+ // It also exposes ONE operator-invoked subcommand — `resolve` — so a qa-handoff
792
+ // verdict can be stamped from a JSON file instead of an inline
793
+ // `node -e '<...>'` whose single-quoted string the shell breaks on apostrophes
794
+ // in justification text (that hazard hit the drain station EVERY time;
795
+ // act:00030e1d). The verdict file is the FULL resolveItem params object
796
+ // {resolution, resolution_type, resolution_notes, qa_verdict} — apostrophes
797
+ // live in resolution_notes AND qa_verdict.confessed_gap.deferred[].justification,
798
+ // so a verdict-only file would leave notes inline and the hazard would survive.
799
+ // The qa_verdict SHAPE itself is single-sourced in the qa-handoff skill's
800
+ // "The recipient gate"; this CLI adds no validation — it reuses resolveItem
801
+ // (and its validateQaVerdict gate) verbatim.
802
+ //
803
+ // Exit policy (operator-invoked → fail LOUD; the INVERSE of the hook-invoked
804
+ // watchtower-snapshot.mjs, which swallows everything with exit 0):
805
+ // 0 resolved (or resolved-but-could-not-read-back-to-confirm)
806
+ // 1 well-formed call could not proceed: item not found, item not pending,
807
+ // or the recipient gate rejected the verdict (item left pending)
808
+ // 2 malformed INVOCATION: bad args, missing/unreadable/non-JSON/non-object
809
+ // verdict file, or an unknown top-level key
810
+ // NOTE: this 1/2 mapping is the OPPOSITE of the sibling watchtower-lib.mjs CLI
811
+ // (there 1=usage, 2=operational). The taxonomy here follows the sysexits /
812
+ // argparse convention — 2 means the invocation itself is malformed — and the
813
+ // divergence from watchtower-lib is deliberate, not an oversight.
814
+
815
+ const RESOLVE_PARAM_KEYS = ['resolution', 'resolution_type', 'resolution_notes', 'qa_verdict'];
816
+
817
+ class UsageError extends Error {}
818
+
819
+ /**
820
+ * Parse `resolve <id> --verdict-file <path>` argv into {id, verdictFile}.
821
+ * Pure (no I/O) so it is unit-testable. Throws UsageError on any malformed
822
+ * form — a missing value, an id that looks like a flag, a repeated or unknown
823
+ * flag, or an extra positional — so the CLI maps every one to exit 2 rather
824
+ * than silently consuming it and failing later. Accepts both
825
+ * `--verdict-file <path>` and `--verdict-file=<path>`.
826
+ * @param {string[]} argv full process.argv ([node, script, 'resolve', ...])
827
+ * @returns {{id: string, verdictFile: string}}
828
+ */
829
+ export function parseResolveArgs(argv) {
830
+ const args = argv.slice(3); // drop [node, script, 'resolve']
831
+ let id = null;
832
+ let verdictFile = null;
833
+ for (let i = 0; i < args.length; i++) {
834
+ const a = args[i];
835
+ if (a === '--verdict-file' || a.startsWith('--verdict-file=')) {
836
+ if (verdictFile !== null) throw new UsageError('--verdict-file given more than once');
837
+ if (a.startsWith('--verdict-file=')) {
838
+ verdictFile = a.slice('--verdict-file='.length);
839
+ } else {
840
+ verdictFile = args[i + 1];
841
+ i++;
842
+ }
843
+ if (typeof verdictFile !== 'string' || verdictFile === '' || verdictFile.startsWith('-')) {
844
+ throw new UsageError('--verdict-file requires a path argument');
845
+ }
846
+ } else if (a.startsWith('-')) {
847
+ throw new UsageError(`unknown flag: ${a}`);
848
+ } else if (id === null) {
849
+ id = a;
850
+ } else {
851
+ throw new UsageError(`unexpected extra argument: ${a}`);
852
+ }
853
+ }
854
+ if (id === null || id.trim() === '') throw new UsageError('missing <id> (the inbox item id, e.g. dec-abcd1234)');
855
+ if (verdictFile === null) throw new UsageError('missing --verdict-file <path>');
856
+ return { id, verdictFile };
857
+ }
858
+
859
+ /**
860
+ * Run the `resolve` subcommand: read the verdict-params JSON file, call
861
+ * resolveItem, then read the item back from disk to confirm the stamp. Returns
862
+ * the process exit code per the policy above; never throws (each failure mode
863
+ * is mapped to a code with a distinct, non-misleading message).
864
+ * @param {string[]} argv full process.argv
865
+ * @returns {number} exit code
866
+ */
867
+ export function runResolveCli(argv) {
868
+ let id;
869
+ let verdictFile;
870
+ try {
871
+ ({ id, verdictFile } = parseResolveArgs(argv));
872
+ } catch (err) {
873
+ process.stderr.write(`resolve: ${err.message}\n`);
874
+ process.stderr.write('usage: watchtower-queue.mjs resolve <id> --verdict-file <path.json>\n');
875
+ return 2;
876
+ }
877
+
878
+ let raw;
879
+ try {
880
+ raw = readFileSync(verdictFile, 'utf8');
881
+ } catch (err) {
882
+ process.stderr.write(err.code === 'ENOENT'
883
+ ? `resolve: verdict file not found: ${verdictFile}\n`
884
+ : `resolve: cannot read verdict file ${verdictFile}: ${err.message}\n`);
885
+ return 2;
886
+ }
887
+
888
+ let params;
889
+ try {
890
+ params = JSON.parse(raw);
891
+ } catch (err) {
892
+ process.stderr.write(`resolve: invalid JSON in ${verdictFile}: ${err.message}\n`);
893
+ return 2;
894
+ }
895
+
896
+ // typeof null === 'object' AND typeof [] === 'object' — guard both explicitly,
897
+ // or an array/null file would reach resolveItem and mis-resolve (exit 1)
898
+ // instead of being caught here as a malformed invocation (exit 2).
899
+ if (params === null || typeof params !== 'object' || Array.isArray(params)) {
900
+ process.stderr.write(`resolve: ${verdictFile} must be a JSON object of resolve params {${RESOLVE_PARAM_KEYS.join(', ')}}\n`);
901
+ return 2;
902
+ }
903
+
904
+ // resolveItem destructures exactly the 4 RESOLVE_PARAM_KEYS and JSON.stringify
905
+ // drops undefined — so a typo'd key (e.g. resolution_note) would be SILENTLY
906
+ // dropped, leaving the item resolved with a missing note and no warning. The
907
+ // non-qa path has no gate at all. Reject unknown keys here so a typo fails
908
+ // loud instead of vanishing.
909
+ const unknown = Object.keys(params).filter((k) => !RESOLVE_PARAM_KEYS.includes(k));
910
+ if (unknown.length > 0) {
911
+ process.stderr.write(`resolve: unknown key(s) in ${verdictFile}: ${unknown.join(', ')} — allowed: ${RESOLVE_PARAM_KEYS.join(', ')} (an unrecognized key is silently dropped, so this is rejected)\n`);
912
+ return 2;
913
+ }
914
+
915
+ let item;
916
+ try {
917
+ item = resolveItem(id, params);
918
+ } catch (err) {
919
+ // Three distinct throw sources, and only the gate's message is safe to echo:
920
+ if (err.code === 'ENOENT') {
921
+ // missing item file — friendly, and never leak the absolute queue path.
922
+ process.stderr.write(`resolve: item ${id} not found\n`);
923
+ } else if (typeof err.message === 'string' && err.message.startsWith('qa-handoff gate:')) {
924
+ // recipient-gate rejection (gateError) — names the offending field and
925
+ // embeds NO item content or path, so surface it verbatim. The item is
926
+ // left pending (validateQaVerdict throws before the atomicWrite).
927
+ process.stderr.write(`resolve: ${err.message}\n`);
928
+ } else {
929
+ // anything else — a corrupt/wrong-schema item file (readItem) or a write
930
+ // failure (atomicWrite) — embeds the ABSOLUTE queue path in err.message.
931
+ // Do NOT echo it; name only the id and the error code.
932
+ process.stderr.write(`resolve: could not resolve item ${id} (${err.code || 'unexpected error'})\n`);
933
+ }
934
+ return 1;
935
+ }
936
+
937
+ if (item === null) {
938
+ let status = 'not pending';
939
+ try {
940
+ const cur = getItem(id);
941
+ if (cur && cur.status) status = cur.status;
942
+ } catch { /* best-effort status lookup for the message only */ }
943
+ process.stderr.write(`resolve: ${id} is not pending (status: ${status}) — an earlier stamp is intact, nothing to do\n`);
944
+ return 1;
945
+ }
946
+
947
+ // Verify the write by re-reading from disk, not trusting the in-memory return
948
+ // (verify-your-own-writes). A read-back failure must NOT read as a resolve
949
+ // failure — the stamp already landed (resolveItem's atomicWrite returned).
950
+ try {
951
+ const stored = getItem(id);
952
+ const token = stored && stored.qa_verdict && stored.qa_verdict.verdict
953
+ ? stored.qa_verdict.verdict
954
+ : (stored && stored.resolution) || '(resolved)';
955
+ const notes = stored && stored.resolution_notes ? 'present' : 'absent';
956
+ process.stdout.write(`resolved ${id} — verdict: ${token}; resolution_notes: ${notes}\n`);
957
+ } catch (err) {
958
+ process.stdout.write(`resolved ${id}, but could not read it back to confirm: ${err.message}\n`);
959
+ }
960
+ return 0;
961
+ }
962
+
963
+ // Entry guard — importing this module must NOT run the CLI (mirrors the ring
964
+ // scripts and watchtower-snapshot.mjs so tests can import the pure functions).
965
+ const isMain = (() => {
966
+ try {
967
+ return process.argv[1]
968
+ && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
969
+ } catch {
970
+ return false;
971
+ }
972
+ })();
973
+
974
+ if (isMain) {
975
+ const sub = process.argv[2];
976
+ if (sub === 'resolve') {
977
+ process.exit(runResolveCli(process.argv));
978
+ } else {
979
+ process.stderr.write(`watchtower-queue.mjs: unknown command ${sub ? `'${sub}'` : '(none)'}\n`);
980
+ process.stderr.write('usage: watchtower-queue.mjs resolve <id> --verdict-file <path.json>\n');
981
+ process.exit(2);
982
+ }
983
+ }