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.
- package/README.md +6 -10
- package/lib/cli.js +374 -91
- package/lib/copy.js +108 -6
- package/lib/db-setup.js +122 -17
- package/lib/engagement-server-setup.js +34 -9
- package/lib/metadata.js +51 -2
- package/lib/migrate-from-omega.js +13 -1
- package/lib/mux-setup.js +33 -9
- package/lib/settings-merge.js +52 -10
- package/lib/watchtower-setup.js +230 -0
- package/package.json +5 -1
- package/templates/cabinet/_cabinet-member-template.md +8 -3
- package/templates/cabinet/advisories-state-schema.md +34 -7
- package/templates/cabinet/checklist-stats-schema.md +15 -1
- package/templates/cabinet/composition-patterns.md +4 -3
- package/templates/cabinet/memory-lifecycle-contract.md +135 -0
- package/templates/cabinet/pib-db-access.md +13 -0
- package/templates/cabinet/skill-output-conventions.md +35 -1
- package/templates/cabinet/watchtower-contracts.md +503 -1
- package/templates/cabinet/worktree-invocation-contract.md +87 -0
- package/templates/engagement/OVERVIEW.md +12 -0
- package/templates/engagement/__tests__/engagement.test.mjs +177 -2
- package/templates/engagement/__tests__/invoice-doc.test.mjs +195 -0
- package/templates/engagement/engagement-preview.mjs +100 -0
- package/templates/engagement/engagement-schema.md +240 -0
- package/templates/engagement/engagement.mjs +391 -11
- package/templates/engagement/pib-db-patches/pib-db-lib.mjs +10 -1
- package/templates/engagement/pib-db-patches/pib-db-mcp-server.mjs +5 -44
- package/templates/engagement/pib-db-patches/pib-db.mjs +5 -2
- package/templates/hooks/action-completion-gate.sh +9 -3
- package/templates/hooks/memory-index-guard.sh +17 -11
- package/templates/hooks/watchtower-session-start.sh +24 -2
- package/templates/mcp/pib-db.json +1 -4
- package/templates/mux/__tests__/mux-fail-loud.fixture.sh +44 -0
- package/templates/mux/__tests__/station-liveness.fixture.sh +234 -0
- package/templates/mux/__tests__/station-liveness.test.mjs +47 -0
- package/templates/mux/bin/mux +289 -56
- package/templates/mux/config/help.txt +1 -0
- package/templates/rules/acknowledge-when-corrected.md +33 -0
- package/templates/rules/maintainability.md +11 -0
- package/templates/rules/memory-capture.md +26 -4
- package/templates/rules/plan-before-bulk-or-irreversible-actions.md +48 -0
- package/templates/rules/verify-before-asserting.md +79 -0
- package/templates/scripts/__tests__/advisor-pass.test.mjs +238 -0
- package/templates/scripts/__tests__/advisories.test.mjs +262 -0
- package/templates/scripts/__tests__/ahead-check-origin.test.mjs +355 -0
- package/templates/scripts/__tests__/batch-disposition.test.mjs +235 -0
- package/templates/scripts/__tests__/claude-churn-authored.test.mjs +90 -0
- package/templates/scripts/__tests__/cross-ring-reader.test.mjs +505 -0
- package/templates/scripts/__tests__/dx-captures-briefing.test.mjs +169 -0
- package/templates/scripts/__tests__/feedback-outbox-flush.test.mjs +232 -0
- package/templates/scripts/__tests__/hook-runner.test.mjs +247 -0
- package/templates/scripts/__tests__/narrative-corpus.test.mjs +247 -0
- package/templates/scripts/__tests__/phase-shim.test.mjs +112 -0
- package/templates/scripts/__tests__/qa-handoff-gate.test.mjs +68 -0
- package/templates/scripts/__tests__/qa-handoff-merge-state.test.mjs +162 -0
- package/templates/scripts/__tests__/resolve-cli.test.mjs +314 -0
- package/templates/scripts/__tests__/ring-state-ownership.test.mjs +228 -3
- package/templates/scripts/__tests__/ring1-content-detector.test.mjs +168 -0
- package/templates/scripts/__tests__/ring1-flagged-actions.test.mjs +135 -0
- package/templates/scripts/__tests__/ring1-script-drift.test.mjs +126 -0
- package/templates/scripts/__tests__/ring2-queue-reader.test.mjs +80 -0
- package/templates/scripts/__tests__/ring2-recall-canary.test.mjs +150 -0
- package/templates/scripts/__tests__/ring2-roster-review.test.mjs +311 -0
- package/templates/scripts/__tests__/ring2-thread-context.test.mjs +189 -0
- package/templates/scripts/__tests__/ring3-chunk-merge.test.mjs +122 -0
- package/templates/scripts/__tests__/ring3-close-lenses.test.mjs +359 -0
- package/templates/scripts/__tests__/ring3-dedup.test.mjs +467 -0
- package/templates/scripts/__tests__/ring3-memory-titles.test.mjs +187 -0
- package/templates/scripts/__tests__/ring3-novelty-rescue.test.mjs +148 -0
- package/templates/scripts/__tests__/ring3-recent-slice.test.mjs +101 -0
- package/templates/scripts/__tests__/ring4-reconcile.test.mjs +393 -0
- package/templates/scripts/__tests__/routine-dispatch.test.mjs +312 -0
- package/templates/scripts/__tests__/shared-thread-reader.test.mjs +187 -0
- package/templates/scripts/__tests__/suppression-ledger.test.mjs +148 -0
- package/templates/scripts/__tests__/verify-backfill.test.mjs +103 -0
- package/templates/scripts/__tests__/verify-coverage.test.mjs +80 -0
- package/templates/scripts/__tests__/watchtower-snapshot.test.mjs +261 -0
- package/templates/scripts/__tests__/watchtower-sync.test.mjs +311 -0
- package/templates/scripts/load-triage-history.js +5 -2
- package/templates/scripts/pib-db-mcp-server.mjs +5 -44
- package/templates/scripts/pib-db-path.mjs +61 -0
- package/templates/scripts/pib-db.mjs +5 -2
- package/templates/scripts/validate-memory.mjs +214 -16
- package/templates/scripts/watchtower-advisories.mjs +309 -0
- package/templates/scripts/watchtower-build-context.mjs +301 -18
- package/templates/scripts/watchtower-cross-ring-reader.mjs +700 -0
- package/templates/scripts/watchtower-hook-runner.mjs +422 -0
- package/templates/scripts/watchtower-lib.mjs +447 -2
- package/templates/scripts/watchtower-narrative-corpus.mjs +385 -0
- package/templates/scripts/watchtower-phase-shim.mjs +171 -0
- package/templates/scripts/watchtower-queue.mjs +484 -1
- package/templates/scripts/watchtower-ring1.mjs +533 -53
- package/templates/scripts/watchtower-ring2.mjs +787 -48
- package/templates/scripts/watchtower-ring3-close.mjs +1357 -99
- package/templates/scripts/watchtower-ring4-runner.sh +85 -0
- package/templates/scripts/watchtower-ring4.mjs +753 -0
- package/templates/scripts/watchtower-routines.mjs +358 -0
- package/templates/scripts/watchtower-snapshot.mjs +452 -0
- package/templates/scripts/watchtower-status.sh +1 -1
- package/templates/scripts/watchtower-sync.mjs +393 -0
- package/templates/skills/audit/SKILL.md +5 -1
- package/templates/skills/briefing/SKILL.md +584 -236
- package/templates/skills/cabinet-anthropic-insider/SKILL.md +14 -6
- package/templates/skills/cabinet-historian/SKILL.md +14 -11
- package/templates/skills/cabinet-process-therapist/SKILL.md +28 -0
- package/templates/skills/cabinet-security/SKILL.md +11 -0
- package/templates/skills/cabinet-system-advocate/SKILL.md +22 -21
- package/templates/skills/cabinet-user-advocate/SKILL.md +13 -7
- package/templates/skills/catch-up/SKILL.md +113 -0
- package/templates/skills/cc-publish/SKILL.md +148 -25
- package/templates/skills/cc-remember/SKILL.md +45 -0
- package/templates/skills/close/SKILL.md +107 -0
- package/templates/skills/collab-client/SKILL.md +22 -5
- package/templates/skills/collab-consultant/SKILL.md +110 -2
- package/templates/skills/debrief/SKILL.md +148 -16
- package/templates/skills/debrief-classic/SKILL.md +696 -0
- package/templates/skills/debrief-classic/calibration.md +44 -0
- package/templates/skills/debrief-classic/phases/audit-pattern-capture.md +78 -0
- package/templates/skills/debrief-classic/phases/auto-maintenance.md +48 -0
- package/templates/skills/debrief-classic/phases/checklist-feedback.md +123 -0
- package/templates/skills/debrief-classic/phases/close-work.md +163 -0
- package/templates/skills/debrief-classic/phases/health-checks.md +54 -0
- package/templates/skills/debrief-classic/phases/inventory.md +40 -0
- package/templates/skills/debrief-classic/phases/loose-ends.md +52 -0
- package/templates/skills/debrief-classic/phases/methodology-capture.md +223 -0
- package/templates/skills/debrief-classic/phases/qa-handoff-sweep.md +78 -0
- package/templates/skills/debrief-classic/phases/record-lessons.md +177 -0
- package/templates/skills/debrief-classic/phases/report.md +59 -0
- package/templates/skills/debrief-classic/phases/update-state.md +48 -0
- package/templates/skills/debrief-classic/phases/upstream-feedback.md +185 -0
- package/templates/skills/debrief-classic/phases/verify-coverage.md +101 -0
- package/templates/skills/execute/SKILL.md +77 -6
- package/templates/skills/execute/phases/commit-and-deploy.md +8 -0
- package/templates/skills/execute-group/SKILL.md +23 -26
- package/templates/skills/generate-plan-groups/SKILL.md +20 -0
- package/templates/skills/inbox/SKILL.md +124 -7
- package/templates/skills/memory/SKILL.md +22 -6
- package/templates/skills/orient/SKILL.md +140 -52
- package/templates/skills/orient-classic/SKILL.md +770 -0
- package/templates/skills/orient-classic/phases/auto-maintenance.md +52 -0
- package/templates/skills/orient-classic/phases/briefing.md +53 -0
- package/templates/skills/orient-classic/phases/cabinet.md +46 -0
- package/templates/skills/orient-classic/phases/checklist-status.md +54 -0
- package/templates/skills/orient-classic/phases/context.md +88 -0
- package/templates/skills/orient-classic/phases/data-sync.md +35 -0
- package/templates/skills/orient-classic/phases/deferred-check.md +55 -0
- package/templates/skills/orient-classic/phases/dx-captures.md +53 -0
- package/templates/skills/orient-classic/phases/health-checks.md +50 -0
- package/templates/skills/orient-classic/phases/verify-backfill.md +109 -0
- package/templates/skills/orient-classic/phases/work-scan.md +69 -0
- package/templates/skills/plan/SKILL.md +8 -0
- package/templates/skills/qa-drain/SKILL.md +157 -0
- package/templates/skills/qa-handoff/SKILL.md +287 -46
- package/templates/skills/session-handoff/SKILL.md +175 -6
- package/templates/skills/setup-accounts/SKILL.md +38 -16
- package/templates/skills/threads/SKILL.md +4 -0
- package/templates/skills/triage-audit/SKILL.md +6 -0
- package/templates/skills/validate/phases/validators.md +41 -0
- package/templates/skills/watchtower/SKILL.md +199 -2
- package/templates/verify-runtime/CONVENTIONS.md +9 -0
- package/templates/verify-runtime/README.md +37 -0
- package/templates/watchtower/config.json.template +10 -2
- package/templates/watchtower/queue/items/item.json.schema +1 -1
- 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
|
+
}
|