create-claude-cabinet 0.46.0 → 0.48.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 (189) hide show
  1. package/README.md +3 -6
  2. package/lib/CLAUDE.md +218 -0
  3. package/lib/cli.js +397 -382
  4. package/lib/copy.js +182 -6
  5. package/lib/db-setup.js +122 -17
  6. package/lib/engagement-server-setup.js +1 -17
  7. package/lib/engagement-setup.js +1 -1
  8. package/lib/installer-gate.js +135 -0
  9. package/lib/metadata.js +51 -2
  10. package/lib/modules.js +292 -0
  11. package/lib/mux-setup.js +1 -17
  12. package/lib/project-context.js +16 -96
  13. package/lib/settings-merge.js +194 -13
  14. package/lib/site-audit-setup.js +23 -7
  15. package/lib/verify-setup.js +20 -9
  16. package/lib/watchtower-setup.js +23 -7
  17. package/package.json +1 -1
  18. package/templates/CLAUDE.md +959 -0
  19. package/templates/briefing/_briefing-template.md +6 -11
  20. package/templates/cabinet/advisories-state-schema.md +1 -1
  21. package/templates/cabinet/checklist-stats-schema.md +15 -1
  22. package/templates/cabinet/eval-protocol.md +27 -10
  23. package/templates/cabinet/memory-lifecycle-contract.md +135 -0
  24. package/templates/cabinet/pib-db-access.md +13 -0
  25. package/templates/cabinet/watchtower-contracts.md +453 -0
  26. package/templates/cabinet/worktree-invocation-contract.md +111 -0
  27. package/templates/engagement/OVERVIEW.md +12 -0
  28. package/templates/engagement/__tests__/checklist-visibility.test.mjs +76 -0
  29. package/templates/engagement/__tests__/engagement.test.mjs +177 -2
  30. package/templates/engagement/__tests__/invoice-doc.test.mjs +195 -0
  31. package/templates/engagement/engagement-checklist.mjs +26 -2
  32. package/templates/engagement/engagement-preview.mjs +100 -0
  33. package/templates/engagement/engagement-schema.md +248 -6
  34. package/templates/engagement/engagement.mjs +391 -11
  35. package/templates/engagement/pib-db-patches/pib-db-lib.mjs +49 -9
  36. package/templates/engagement/pib-db-patches/pib-db-mcp-server.mjs +5 -44
  37. package/templates/engagement/pib-db-patches/pib-db-schema.sql +1 -1
  38. package/templates/engagement/pib-db-patches/pib-db.mjs +9 -3
  39. package/templates/engagement/sql-constants.mjs +9 -1
  40. package/templates/engagement-server/__tests__/cross-tenant-auth.test.mjs +87 -0
  41. package/templates/engagement-server/server.mjs +65 -40
  42. package/templates/hooks/action-completion-gate.sh +19 -3
  43. package/templates/hooks/action-quality-gate.sh +10 -0
  44. package/templates/hooks/memory-index-guard.sh +17 -11
  45. package/templates/hooks/skill-telemetry.sh +10 -5
  46. package/templates/hooks/skill-tool-telemetry.sh +11 -5
  47. package/templates/hooks/watchtower-session-start.sh +24 -2
  48. package/templates/mcp/pib-db.json +1 -4
  49. package/templates/mux/bin/mux +8 -1
  50. package/templates/mux/config/help.txt +1 -0
  51. package/templates/rules/acknowledge-when-corrected.md +33 -0
  52. package/templates/rules/enforcement-pipeline.md +0 -12
  53. package/templates/rules/maintainability.md +11 -0
  54. package/templates/rules/memory-capture.md +26 -4
  55. package/templates/rules/plan-before-bulk-or-irreversible-actions.md +48 -0
  56. package/templates/rules/verify-before-asserting.md +79 -0
  57. package/templates/scripts/__tests__/ahead-check-origin.test.mjs +355 -0
  58. package/templates/scripts/__tests__/api-usage-idle-gate.test.mjs +81 -0
  59. package/templates/scripts/__tests__/batch-disposition.test.mjs +98 -0
  60. package/templates/scripts/__tests__/bsql-loader-verify.test.mjs +47 -0
  61. package/templates/scripts/__tests__/claude-churn-authored.test.mjs +90 -0
  62. package/templates/scripts/__tests__/cross-ring-reader.test.mjs +505 -0
  63. package/templates/scripts/__tests__/dx-captures-briefing.test.mjs +169 -0
  64. package/templates/scripts/__tests__/hook-runner.test.mjs +247 -0
  65. package/templates/scripts/__tests__/inbox-assessment.test.mjs +341 -0
  66. package/templates/scripts/__tests__/memory-reachability.test.mjs +150 -0
  67. package/templates/scripts/__tests__/narrative-corpus.test.mjs +247 -0
  68. package/templates/scripts/__tests__/phase-shim.test.mjs +112 -0
  69. package/templates/scripts/__tests__/qa-handoff-merge-state.test.mjs +162 -0
  70. package/templates/scripts/__tests__/resolve-cli.test.mjs +314 -0
  71. package/templates/scripts/__tests__/ring-state-ownership.test.mjs +120 -0
  72. package/templates/scripts/__tests__/ring1-content-detector.test.mjs +168 -0
  73. package/templates/scripts/__tests__/ring1-flagged-actions.test.mjs +135 -0
  74. package/templates/scripts/__tests__/ring1-pib-error-surfacing.test.mjs +69 -0
  75. package/templates/scripts/__tests__/ring1-script-drift.test.mjs +126 -0
  76. package/templates/scripts/__tests__/ring1-stale-open-counts.test.mjs +143 -0
  77. package/templates/scripts/__tests__/ring2-queue-reader.test.mjs +80 -0
  78. package/templates/scripts/__tests__/ring2-recall-canary.test.mjs +150 -0
  79. package/templates/scripts/__tests__/ring2-roster-review.test.mjs +311 -0
  80. package/templates/scripts/__tests__/ring3-chunk-merge.test.mjs +122 -0
  81. package/templates/scripts/__tests__/ring3-close-lenses.test.mjs +359 -0
  82. package/templates/scripts/__tests__/ring3-dedup.test.mjs +92 -12
  83. package/templates/scripts/__tests__/ring3-memory-titles.test.mjs +187 -0
  84. package/templates/scripts/__tests__/ring3-novelty-rescue.test.mjs +148 -0
  85. package/templates/scripts/__tests__/ring3-recent-slice.test.mjs +101 -0
  86. package/templates/scripts/__tests__/ring3-thread-capture.test.mjs +213 -0
  87. package/templates/scripts/__tests__/ring4-reconcile.test.mjs +393 -0
  88. package/templates/scripts/__tests__/shared-thread-reader.test.mjs +187 -0
  89. package/templates/scripts/__tests__/suppression-ledger.test.mjs +148 -0
  90. package/templates/scripts/__tests__/verify-backfill.test.mjs +103 -0
  91. package/templates/scripts/__tests__/verify-coverage.test.mjs +80 -0
  92. package/templates/scripts/__tests__/watchtower-snapshot.test.mjs +261 -0
  93. package/templates/scripts/__tests__/watchtower-status-ring4.test.mjs +89 -0
  94. package/templates/scripts/__tests__/watchtower-sync.test.mjs +311 -0
  95. package/templates/scripts/audit-coherence-check.mjs +123 -0
  96. package/templates/scripts/audit-synth.mjs +117 -0
  97. package/templates/scripts/load-triage-history.js +5 -2
  98. package/templates/scripts/merge-findings.js +4 -1
  99. package/templates/scripts/patterns-scope-check.mjs +122 -0
  100. package/templates/scripts/pib-db-lib.mjs +61 -11
  101. package/templates/scripts/pib-db-mcp-server.mjs +7 -45
  102. package/templates/scripts/pib-db-path.mjs +61 -0
  103. package/templates/scripts/pib-db.mjs +7 -3
  104. package/templates/scripts/review-server.mjs +7 -12
  105. package/templates/scripts/triage-server.mjs +7 -8
  106. package/templates/scripts/validate-memory.mjs +214 -16
  107. package/templates/scripts/watchtower-advisories.mjs +7 -3
  108. package/templates/scripts/watchtower-build-context.mjs +192 -8
  109. package/templates/scripts/watchtower-cross-ring-reader.mjs +714 -0
  110. package/templates/scripts/watchtower-hook-runner.mjs +422 -0
  111. package/templates/scripts/watchtower-inbox-assessment.mjs +706 -0
  112. package/templates/scripts/watchtower-lib.mjs +555 -8
  113. package/templates/scripts/watchtower-narrative-corpus.mjs +385 -0
  114. package/templates/scripts/watchtower-phase-shim.mjs +171 -0
  115. package/templates/scripts/watchtower-queue.mjs +341 -3
  116. package/templates/scripts/watchtower-ring1.mjs +481 -81
  117. package/templates/scripts/watchtower-ring2.mjs +748 -80
  118. package/templates/scripts/watchtower-ring3-close.mjs +1147 -167
  119. package/templates/scripts/watchtower-ring4-runner.sh +85 -0
  120. package/templates/scripts/watchtower-ring4.mjs +753 -0
  121. package/templates/scripts/watchtower-routines.mjs +1 -1
  122. package/templates/scripts/watchtower-snapshot.mjs +452 -0
  123. package/templates/scripts/watchtower-status.sh +17 -0
  124. package/templates/scripts/watchtower-sync.mjs +393 -0
  125. package/templates/scripts/work-tracker-server.mjs +26 -22
  126. package/templates/skills/audit/SKILL.md +18 -5
  127. package/templates/skills/audit/phases/structural-checks.md +22 -0
  128. package/templates/skills/briefing/SKILL.md +289 -41
  129. package/templates/skills/cabinet-accessibility/SKILL.md +1 -1
  130. package/templates/skills/cabinet-process-therapist/SKILL.md +28 -0
  131. package/templates/skills/cabinet-security/SKILL.md +11 -0
  132. package/templates/skills/catch-up/SKILL.md +113 -0
  133. package/templates/skills/cc-publish/SKILL.md +89 -18
  134. package/templates/skills/cc-remember/SKILL.md +45 -0
  135. package/templates/skills/close/SKILL.md +107 -0
  136. package/templates/skills/collab-client/SKILL.md +22 -5
  137. package/templates/skills/collab-consultant/SKILL.md +110 -2
  138. package/templates/skills/debrief/SKILL.md +21 -4
  139. package/templates/skills/debrief/phases/audit-pattern-capture.md +13 -0
  140. package/templates/skills/debrief-classic/SKILL.md +696 -0
  141. package/templates/skills/debrief-classic/calibration.md +44 -0
  142. package/templates/skills/debrief-classic/phases/audit-pattern-capture.md +78 -0
  143. package/templates/skills/debrief-classic/phases/auto-maintenance.md +48 -0
  144. package/templates/skills/debrief-classic/phases/checklist-feedback.md +123 -0
  145. package/templates/skills/debrief-classic/phases/close-work.md +163 -0
  146. package/templates/skills/debrief-classic/phases/health-checks.md +54 -0
  147. package/templates/skills/debrief-classic/phases/inventory.md +40 -0
  148. package/templates/skills/debrief-classic/phases/loose-ends.md +52 -0
  149. package/templates/skills/debrief-classic/phases/methodology-capture.md +223 -0
  150. package/templates/skills/debrief-classic/phases/qa-handoff-sweep.md +78 -0
  151. package/templates/skills/debrief-classic/phases/record-lessons.md +177 -0
  152. package/templates/skills/debrief-classic/phases/report.md +59 -0
  153. package/templates/skills/debrief-classic/phases/update-state.md +48 -0
  154. package/templates/skills/debrief-classic/phases/upstream-feedback.md +185 -0
  155. package/templates/skills/debrief-classic/phases/verify-coverage.md +101 -0
  156. package/templates/skills/execute/SKILL.md +71 -6
  157. package/templates/skills/execute/phases/commit-and-deploy.md +8 -0
  158. package/templates/skills/execute-group/SKILL.md +23 -26
  159. package/templates/skills/generate-plan-groups/SKILL.md +20 -0
  160. package/templates/skills/inbox/SKILL.md +177 -31
  161. package/templates/skills/memory/SKILL.md +22 -6
  162. package/templates/skills/orient/SKILL.md +75 -5
  163. package/templates/skills/orient-classic/SKILL.md +770 -0
  164. package/templates/skills/orient-classic/phases/auto-maintenance.md +52 -0
  165. package/templates/skills/orient-classic/phases/briefing.md +53 -0
  166. package/templates/skills/orient-classic/phases/cabinet.md +46 -0
  167. package/templates/skills/orient-classic/phases/checklist-status.md +54 -0
  168. package/templates/skills/orient-classic/phases/context.md +88 -0
  169. package/templates/skills/orient-classic/phases/data-sync.md +35 -0
  170. package/templates/skills/orient-classic/phases/deferred-check.md +55 -0
  171. package/templates/skills/orient-classic/phases/dx-captures.md +53 -0
  172. package/templates/skills/orient-classic/phases/health-checks.md +50 -0
  173. package/templates/skills/orient-classic/phases/verify-backfill.md +109 -0
  174. package/templates/skills/orient-classic/phases/work-scan.md +69 -0
  175. package/templates/skills/pulse/SKILL.md +10 -8
  176. package/templates/skills/qa-drain/SKILL.md +38 -0
  177. package/templates/skills/qa-handoff/SKILL.md +287 -46
  178. package/templates/skills/setup-accounts/SKILL.md +38 -16
  179. package/templates/skills/spring-clean/SKILL.md +1 -1
  180. package/templates/skills/spring-clean/phases/execute-decisions.md +1 -1
  181. package/templates/skills/threads/SKILL.md +4 -0
  182. package/templates/skills/validate/phases/validators.md +127 -0
  183. package/templates/skills/watchtower/SKILL.md +174 -30
  184. package/templates/verify-runtime/CONVENTIONS.md +9 -0
  185. package/templates/verify-runtime/README.md +37 -0
  186. package/templates/watchtower/config.json.template +8 -2
  187. package/templates/workflows/deliberative-audit.js +258 -87
  188. package/templates/workflows/execute-group-complete.js +26 -3
  189. package/templates/workflows/execute-group-implement.js +16 -2
@@ -4,9 +4,10 @@
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, unlinkSync } 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');
@@ -93,6 +94,15 @@ const URGENCY_ORDER = { urgent: 0, normal: 1, low: 2 };
93
94
  // sin, a silent stamp with no coverage look, is impossible rather than
94
95
  // discouraged. Keep all qa-handoff domain knowledge fenced here; the CRUD
95
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.
96
106
 
97
107
  const QA_CATEGORY = 'qa-handoff';
98
108
 
@@ -129,6 +139,28 @@ export function normalizeQaVerdictToken(raw) {
129
139
  return null;
130
140
  }
131
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
+
132
164
  function gateError(item, message) {
133
165
  // Never echo resolution/notes content back — name fields only.
134
166
  return new Error(`qa-handoff gate: cannot close ${item.id} — ${message}`);
@@ -285,6 +317,82 @@ export function emitPatternPromotion({
285
317
  });
286
318
  }
287
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
+
288
396
  // --- Exports ---
289
397
 
290
398
  /**
@@ -312,6 +420,38 @@ export function createItem({
312
420
  desk = null,
313
421
  }) {
314
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
+ }
315
455
  const id = generateId();
316
456
  const item = {
317
457
  schema_version: 1,
@@ -532,8 +672,9 @@ export function listPending({ project, category, urgency, maxAge } = {}) {
532
672
 
533
673
  /**
534
674
  * 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.)
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).
537
678
  * @param {object} filters
538
679
  * @param {string} [filters.project] - exact match on item.project
539
680
  * @param {string} [filters.category] - exact match on item.category
@@ -643,3 +784,200 @@ export function runExpiry({ warnDays = 14, expireDays = 30 } = {}) {
643
784
 
644
785
  return { warned, expired };
645
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
+ }