create-claude-cabinet 0.44.0 → 0.45.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 (63) hide show
  1. package/README.md +5 -0
  2. package/lib/cli.js +51 -6
  3. package/lib/copy.js +56 -10
  4. package/lib/mux-setup.js +1 -0
  5. package/package.json +1 -1
  6. package/templates/cabinet/checklist-stats-schema.md +104 -0
  7. package/templates/cabinet/checkpoint-protocol.md +17 -5
  8. package/templates/cabinet/qa-dimensions-template.yaml +7 -0
  9. package/templates/cabinet/watchtower-contracts.md +38 -0
  10. package/templates/engagement/pib-db-patches/pib-db-lib.mjs +4 -1
  11. package/templates/hooks/action-completion-gate.sh +17 -0
  12. package/templates/hooks/watchtower-session-start.sh +80 -5
  13. package/templates/mux/__tests__/claude-carveout.fixture.sh +136 -0
  14. package/templates/mux/__tests__/claude-carveout.test.mjs +38 -0
  15. package/templates/mux/__tests__/mux-fail-loud.fixture.sh +254 -0
  16. package/templates/mux/__tests__/mux-fail-loud.test.mjs +41 -0
  17. package/templates/mux/__tests__/worktree-dirty-check.fixture.sh +184 -0
  18. package/templates/mux/__tests__/worktree-dirty-check.test.mjs +35 -0
  19. package/templates/mux/bin/mux +212 -60
  20. package/templates/mux/config/worktree-cleanup.sh +55 -9
  21. package/templates/mux/config/worktree-dirty-check.sh +128 -0
  22. package/templates/mux/config/worktree-session-health.sh +62 -35
  23. package/templates/scripts/__tests__/qa-handoff-aging.e2e.test.mjs +108 -0
  24. package/templates/scripts/__tests__/qa-handoff-gate.test.mjs +335 -0
  25. package/templates/scripts/__tests__/resolve-project.test.mjs +144 -0
  26. package/templates/scripts/__tests__/ring-state-ownership.test.mjs +228 -0
  27. package/templates/scripts/pib-db-lib.mjs +4 -1
  28. package/templates/scripts/pib-db.mjs +4 -1
  29. package/templates/scripts/validate-memory.mjs +6 -2
  30. package/templates/scripts/watchtower-build-context.mjs +12 -8
  31. package/templates/scripts/watchtower-lib.mjs +265 -2
  32. package/templates/scripts/watchtower-migrate-keys.mjs +305 -0
  33. package/templates/scripts/watchtower-queue.mjs +226 -1
  34. package/templates/scripts/watchtower-ring1.mjs +19 -3
  35. package/templates/scripts/watchtower-ring2.mjs +4 -2
  36. package/templates/scripts/watchtower-ring3-close.mjs +92 -88
  37. package/templates/skills/audit/SKILL.md +25 -6
  38. package/templates/skills/audit/phases/checklist-pruning.md +108 -0
  39. package/templates/skills/briefing/SKILL.md +12 -1
  40. package/templates/skills/cabinet/SKILL.md +2 -2
  41. package/templates/skills/collab-consultant/SKILL.md +1 -1
  42. package/templates/skills/debrief/SKILL.md +33 -3
  43. package/templates/skills/debrief/phases/checklist-feedback.md +10 -3
  44. package/templates/skills/debrief/phases/qa-handoff-sweep.md +78 -0
  45. package/templates/skills/engagement-create/SKILL.md +1 -1
  46. package/templates/skills/engagement-help/SKILL.md +1 -1
  47. package/templates/skills/execute/SKILL.md +1 -1
  48. package/templates/skills/execute/phases/post-impl-checklist.md +18 -0
  49. package/templates/skills/execute-group/SKILL.md +76 -24
  50. package/templates/skills/inbox/SKILL.md +30 -7
  51. package/templates/skills/orient/SKILL.md +100 -6
  52. package/templates/skills/orient/phases/checklist-status.md +12 -0
  53. package/templates/skills/plan/SKILL.md +14 -6
  54. package/templates/skills/qa-handoff/SKILL.md +132 -5
  55. package/templates/skills/session-handoff/SKILL.md +165 -0
  56. package/templates/skills/setup-accounts/SKILL.md +1 -1
  57. package/templates/skills/unwrap/SKILL.md +1 -1
  58. package/templates/skills/verify/SKILL.md +2 -2
  59. package/templates/skills/watchtower/SKILL.md +19 -1
  60. package/templates/watchtower/queue/items/item.json.schema +9 -0
  61. package/templates/workflows/deliberative-audit.js +3 -0
  62. package/templates/workflows/execute-group-complete.js +93 -16
  63. package/templates/workflows/execute-group-implement.js +164 -19
@@ -0,0 +1,305 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Watchtower project-key migration — one-time repair for the phantom-key era.
4
+ //
5
+ // Before the canonical resolver landed, Ring 3 filed everything under
6
+ // basename(cwd) — for mux worktree sessions that's a phantom key like
7
+ // "cabinet-continue" that /inbox and the rings never look up — and the mux
8
+ // pane-close filer keyed items by desk name ("cabinet" vs the config key
9
+ // "claude-cabinet"). The damage lives in THREE stores that must move
10
+ // together, with one shared resolution, or they end up inconsistently
11
+ // half-right (worse than consistently wrong):
12
+ //
13
+ // 1. queue/items/*.json — the `project` field (ALL statuses, not just
14
+ // pending: threads reference terminal items)
15
+ // 2. state/projects/<slug>/ — per-phantom-slug session dirs, MERGED into
16
+ // the config-slug dir (collisions suffixed)
17
+ // 3. state/threads/*.json — sessions[].project slug values
18
+ //
19
+ // Resolution, per phantom key: (a) any live project_path resolves via the
20
+ // canonical resolver (git walks a worktree back to its main repo); (b) an
21
+ // alias map for deleted worktrees, where no tool can derive the project
22
+ // ("maginnis-*" shares no prefix with "claudeconsult-maginnis" — the
23
+ // operator has to say it); (c) neither → the items get project_unresolved
24
+ // and land in the residue report. The dry-run recomputes everything from
25
+ // the files — it never asserts prior counts.
26
+ //
27
+ // Safety: dry-run is the default; --apply backs up all three stores first;
28
+ // re-running --apply rewrites 0 items (idempotent); items already keyed to
29
+ // a config project are never touched.
30
+ //
31
+ // Usage:
32
+ // watchtower-migrate-keys.mjs dry-run report
33
+ // watchtower-migrate-keys.mjs --apply backup, then rewrite
34
+ // watchtower-migrate-keys.mjs --alias maginnis-=claudeconsult-maginnis
35
+ // (repeatable; prefix=key)
36
+
37
+ import {
38
+ readFileSync, readdirSync, existsSync, mkdirSync, renameSync,
39
+ cpSync, statSync, rmdirSync,
40
+ } from 'fs';
41
+ import { join, basename } from 'path';
42
+ import {
43
+ getWatchtowerDir, loadConfig, slugify, atomicWrite,
44
+ resolveProjectIdentity,
45
+ } from './watchtower-lib.mjs';
46
+
47
+ const WATCHTOWER_DIR = getWatchtowerDir();
48
+ const QUEUE_DIR = join(WATCHTOWER_DIR, 'queue', 'items');
49
+ const PROJECTS_DIR = join(WATCHTOWER_DIR, 'state', 'projects');
50
+ const THREADS_DIR = join(WATCHTOWER_DIR, 'state', 'threads');
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Alias map — operator-confirmed prefix → config key, for phantom keys whose
54
+ // worktrees no longer exist on disk (nothing mechanical can recover those).
55
+ // Extend via --alias prefix=key. Longest matching prefix wins.
56
+ // ---------------------------------------------------------------------------
57
+ const DEFAULT_ALIASES = {
58
+ 'cabinet-': 'claude-cabinet',
59
+ 'maginnis-': 'claudeconsult-maginnis',
60
+ 'flow-': 'flow',
61
+ };
62
+
63
+ function parseArgs() {
64
+ const args = process.argv.slice(2);
65
+ const parsed = { apply: false, aliases: { ...DEFAULT_ALIASES } };
66
+ for (let i = 0; i < args.length; i++) {
67
+ if (args[i] === '--apply') parsed.apply = true;
68
+ else if (args[i] === '--alias' && args[i + 1]) {
69
+ const [prefix, key] = args[++i].split('=');
70
+ if (prefix && key) parsed.aliases[prefix] = key;
71
+ } else if (args[i] === '--help' || args[i] === '-h') {
72
+ console.log('usage: watchtower-migrate-keys.mjs [--apply] [--alias prefix=configKey]...');
73
+ process.exit(0);
74
+ }
75
+ }
76
+ return parsed;
77
+ }
78
+
79
+ function readJSON(fp) {
80
+ try {
81
+ return JSON.parse(readFileSync(fp, 'utf8'));
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
87
+ function aliasTarget(phantomKey, aliases, configKeys) {
88
+ let best = null;
89
+ for (const [prefix, key] of Object.entries(aliases)) {
90
+ if (phantomKey.startsWith(prefix) && configKeys.has(key)) {
91
+ if (!best || prefix.length > best.prefix.length) best = { prefix, key };
92
+ }
93
+ }
94
+ return best?.key || null;
95
+ }
96
+
97
+ function main() {
98
+ const { apply, aliases } = parseArgs();
99
+ const config = loadConfig();
100
+ const configKeys = new Set(Object.keys(config.projects || {}));
101
+ const configSlugs = new Map(
102
+ [...configKeys].map(k => [slugify(k), k])
103
+ );
104
+
105
+ // --- Pass 0: scan queue items, build the phantom-key → target mapping ---
106
+ // Strictly *.json — 430 legacy extension-less dec-* files share this dir.
107
+ const itemFiles = existsSync(QUEUE_DIR)
108
+ ? readdirSync(QUEUE_DIR).filter(f => f.endsWith('.json'))
109
+ : [];
110
+ const items = [];
111
+ for (const f of itemFiles) {
112
+ const item = readJSON(join(QUEUE_DIR, f));
113
+ if (item && item.project) items.push({ file: f, item });
114
+ }
115
+
116
+ // mapping: phantomName -> { name, slug, path } | null (unresolvable)
117
+ const mapping = new Map();
118
+ const ensureMapped = (phantomKey, projectPath) => {
119
+ if (configKeys.has(phantomKey)) return; // already a real key — untouched
120
+ const existing = mapping.get(phantomKey);
121
+ if (existing) return;
122
+ // (a) live path: the resolver walks worktrees back to their main repo
123
+ if (projectPath) {
124
+ const id = resolveProjectIdentity(projectPath, config);
125
+ if (id?.registered) {
126
+ mapping.set(phantomKey, id);
127
+ return;
128
+ }
129
+ }
130
+ // (b) operator alias for dead worktrees
131
+ const aliased = aliasTarget(phantomKey, aliases, configKeys);
132
+ if (aliased) {
133
+ const path = config.projects[aliased]?.path || null;
134
+ mapping.set(phantomKey, { name: aliased, slug: slugify(aliased), path });
135
+ return;
136
+ }
137
+ // (c) unresolvable — recorded so all three stores treat it identically
138
+ mapping.set(phantomKey, null);
139
+ };
140
+
141
+ for (const { item } of items) ensureMapped(item.project, item.project_path);
142
+
143
+ // Phantom slugs can also appear in stores without a matching queue item
144
+ // (state dirs, thread sessions). Map those through the same rules.
145
+ const threadFiles = existsSync(THREADS_DIR)
146
+ ? readdirSync(THREADS_DIR).filter(f => f.endsWith('.json'))
147
+ : [];
148
+ const threads = threadFiles
149
+ .map(f => ({ file: f, thread: readJSON(join(THREADS_DIR, f)) }))
150
+ .filter(t => t.thread);
151
+ for (const { thread } of threads) {
152
+ for (const s of thread.sessions || []) {
153
+ if (s.project && !configSlugs.has(s.project)) ensureMapped(s.project, null);
154
+ }
155
+ }
156
+ const stateDirs = existsSync(PROJECTS_DIR)
157
+ ? readdirSync(PROJECTS_DIR).filter(d => {
158
+ try { return statSync(join(PROJECTS_DIR, d)).isDirectory(); } catch { return false; }
159
+ })
160
+ : [];
161
+ for (const d of stateDirs) {
162
+ if (!configSlugs.has(d)) ensureMapped(d, null);
163
+ }
164
+
165
+ // --- Report: recomputed ground truth, grouped by filed_by and phantom key ---
166
+ const planned = { items: [], threads: [], dirs: [], residue: [] };
167
+ const byFiler = {};
168
+ const byKey = {};
169
+
170
+ for (const { file, item } of items) {
171
+ if (configKeys.has(item.project)) continue; // already correct: untouched
172
+ const target = mapping.get(item.project);
173
+ if (target) {
174
+ planned.items.push({ file, item, target });
175
+ byFiler[item.filed_by || '?'] = (byFiler[item.filed_by || '?'] || 0) + 1;
176
+ byKey[`${item.project} → ${target.name}`] =
177
+ (byKey[`${item.project} → ${target.name}`] || 0) + 1;
178
+ } else if (!item.project_unresolved) {
179
+ planned.residue.push({ file, item });
180
+ }
181
+ }
182
+
183
+ for (const { file, thread } of threads) {
184
+ const rewrites = (thread.sessions || []).filter(s =>
185
+ s.project && !configSlugs.has(s.project) && mapping.get(s.project)
186
+ ).length;
187
+ const unresolvable = (thread.sessions || []).filter(s =>
188
+ s.project && !configSlugs.has(s.project) && !mapping.get(s.project) && !s.project_unresolved
189
+ ).length;
190
+ if (rewrites || unresolvable) planned.threads.push({ file, thread, rewrites, unresolvable });
191
+ }
192
+
193
+ for (const d of stateDirs) {
194
+ if (configSlugs.has(d)) continue;
195
+ const target = mapping.get(d);
196
+ if (target) planned.dirs.push({ dir: d, target });
197
+ }
198
+
199
+ console.log(`Watchtower project-key migration — ${apply ? 'APPLY' : 'dry-run'}`);
200
+ console.log(`Config keys: ${[...configKeys].join(', ')}\n`);
201
+ console.log(`Queue items scanned: ${items.length} (${itemFiles.length} .json files)`);
202
+ console.log(`Items to re-key: ${planned.items.length}`);
203
+ for (const [k, n] of Object.entries(byKey).sort((a, b) => b[1] - a[1])) {
204
+ console.log(` ${k}: ${n}`);
205
+ }
206
+ console.log('By filer (cross-registry desk renames listed separately from ring3 basenames):');
207
+ for (const [k, n] of Object.entries(byFiler)) console.log(` ${k}: ${n}`);
208
+ console.log(`Thread files to rewrite: ${planned.threads.length} (of ${threads.length})`);
209
+ console.log(`State dirs to merge: ${planned.dirs.length}${planned.dirs.length ? ' — ' + planned.dirs.map(d => `${d.dir} → ${d.target.slug}`).join(', ') : ''}`);
210
+ const unresolvableDirs = stateDirs.filter(d => !configSlugs.has(d) && !mapping.get(d));
211
+ if (unresolvableDirs.length) {
212
+ console.log(`State dirs unresolvable: ${unresolvableDirs.length} — left in place: ${unresolvableDirs.join(', ')}`);
213
+ }
214
+ if (planned.residue.length) {
215
+ console.log(`\nRESIDUE — unresolvable, will be flagged project_unresolved (add --alias to map):`);
216
+ for (const { item } of planned.residue) {
217
+ console.log(` [${item.status}] ${item.project} (${item.project_path || 'no path'}) — ${item.title?.slice(0, 60)}`);
218
+ }
219
+ }
220
+
221
+ if (!apply) {
222
+ console.log('\nDry-run only. Re-run with --apply to migrate (a backup is taken first).');
223
+ return;
224
+ }
225
+
226
+ // --- Backup all three stores before any write ---
227
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
228
+ const backupDir = join(WATCHTOWER_DIR, `migration-backup-${stamp}`);
229
+ mkdirSync(backupDir, { recursive: true });
230
+ for (const [src, name] of [[QUEUE_DIR, 'queue-items'], [PROJECTS_DIR, 'state-projects'], [THREADS_DIR, 'state-threads']]) {
231
+ if (existsSync(src)) cpSync(src, join(backupDir, name), { recursive: true });
232
+ }
233
+ console.log(`\nBackup: ${backupDir}`);
234
+
235
+ // --- Store 1: queue items ---
236
+ let rewritten = 0;
237
+ for (const { file, item, target } of planned.items) {
238
+ item.project = target.name;
239
+ if (target.path) item.project_path = target.path;
240
+ delete item.project_unresolved;
241
+ atomicWrite(join(QUEUE_DIR, file), item);
242
+ rewritten++;
243
+ }
244
+ let flagged = 0;
245
+ for (const { file, item } of planned.residue) {
246
+ item.project_unresolved = true;
247
+ atomicWrite(join(QUEUE_DIR, file), item);
248
+ flagged++;
249
+ }
250
+
251
+ // --- Store 2: thread files (same mapping, slug values) ---
252
+ let threadsRewritten = 0;
253
+ for (const { file, thread } of planned.threads) {
254
+ for (const s of thread.sessions || []) {
255
+ if (!s.project || configSlugs.has(s.project)) continue;
256
+ const target = mapping.get(s.project);
257
+ if (target) {
258
+ s.project = target.slug;
259
+ delete s.project_unresolved;
260
+ } else if (!s.project_unresolved) {
261
+ s.project_unresolved = true;
262
+ }
263
+ }
264
+ atomicWrite(join(THREADS_DIR, file), thread);
265
+ threadsRewritten++;
266
+ }
267
+
268
+ // --- Store 3: state/projects dir merges (collision → keep both, suffix) ---
269
+ let dirsMerged = 0;
270
+ for (const { dir, target } of planned.dirs) {
271
+ const srcDir = join(PROJECTS_DIR, dir);
272
+ const dstDir = join(PROJECTS_DIR, target.slug);
273
+ const walk = (rel) => {
274
+ const abs = join(srcDir, rel);
275
+ for (const entry of readdirSync(abs, { withFileTypes: true })) {
276
+ const childRel = join(rel, entry.name);
277
+ if (entry.isDirectory()) {
278
+ mkdirSync(join(dstDir, childRel), { recursive: true });
279
+ walk(childRel);
280
+ try { rmdirSync(join(srcDir, childRel)); } catch { /* not empty */ }
281
+ } else {
282
+ let dest = join(dstDir, childRel);
283
+ if (existsSync(dest)) {
284
+ const dot = entry.name.lastIndexOf('.');
285
+ const suffixed = dot > 0
286
+ ? `${entry.name.slice(0, dot)}.from-${dir}${entry.name.slice(dot)}`
287
+ : `${entry.name}.from-${dir}`;
288
+ dest = join(dstDir, join(childRel, '..'), suffixed);
289
+ }
290
+ mkdirSync(join(dest, '..'), { recursive: true });
291
+ renameSync(join(srcDir, childRel), dest);
292
+ }
293
+ }
294
+ };
295
+ mkdirSync(dstDir, { recursive: true });
296
+ walk('');
297
+ try { rmdirSync(srcDir); } catch { /* leftovers stay; report below */ }
298
+ dirsMerged++;
299
+ }
300
+
301
+ console.log(`Applied: ${rewritten} items re-keyed, ${flagged} flagged unresolved, ${threadsRewritten} thread files rewritten, ${dirsMerged} state dirs merged.`);
302
+ console.log('Verify idempotency: re-run with --apply — it should rewrite 0 items.');
303
+ }
304
+
305
+ main();
@@ -45,6 +45,199 @@ function generateId() {
45
45
  // Urgency sort order: urgent < normal < low (urgent first)
46
46
  const URGENCY_ORDER = { urgent: 0, normal: 1, low: 2 };
47
47
 
48
+ // --- qa-handoff category contract (the staff-QA recipient gate) ---
49
+ //
50
+ // A qa-handoff item cannot leave the queue silently: resolution requires a
51
+ // structured, field-validated qa_verdict; dismissal/supersession require a
52
+ // typed reason; expiry never applies. The gate's playbook, tiers, and the
53
+ // verdict shape are defined ONCE in the qa-handoff skill (SKILL.md, "The
54
+ // recipient gate") — this section validates the SHAPE at the API so the one
55
+ // sin, a silent stamp with no coverage look, is impossible rather than
56
+ // discouraged. Keep all qa-handoff domain knowledge fenced here; the CRUD
57
+ // functions below only call into it.
58
+
59
+ const QA_CATEGORY = 'qa-handoff';
60
+ const QA_TERMINAL_VERDICTS = ['runtime-verified', 'blocked'];
61
+ // The parameterized token. Canonical form uses U+00B7 (·); input tolerates
62
+ // common separator drift (-, –, —, *, •) because sessions retype labels.
63
+ const QA_GAPS_TOKEN_RE = /^verified\s*[·•*\-–—]\s*(\d+)\s+gaps?\s+filed$/;
64
+ const QA_COVERAGE_ASSESSMENTS = ['adequate', 'extended', 'gap-filed'];
65
+
66
+ /**
67
+ * Normalize a qa-handoff verdict token to canonical form.
68
+ * Returns 'runtime-verified' | 'blocked' | 'verified · N gaps filed' (N >= 1),
69
+ * or null when the input is not a legal token (including bare 'verified').
70
+ * @param {string} raw
71
+ * @returns {string|null}
72
+ */
73
+ export function normalizeQaVerdictToken(raw) {
74
+ if (typeof raw !== 'string') return null;
75
+ const t = raw.normalize('NFKC').trim().replace(/\s+/g, ' ');
76
+ if (QA_TERMINAL_VERDICTS.includes(t)) return t;
77
+ const m = t.match(QA_GAPS_TOKEN_RE);
78
+ if (m) {
79
+ const n = parseInt(m[1], 10);
80
+ if (Number.isSafeInteger(n) && n >= 1) return `verified · ${n} gaps filed`;
81
+ }
82
+ return null;
83
+ }
84
+
85
+ function gateError(item, message) {
86
+ // Never echo resolution/notes content back — name fields only.
87
+ return new Error(`qa-handoff gate: cannot close ${item.id} — ${message}`);
88
+ }
89
+
90
+ /**
91
+ * Validate the structured verdict for a qa-handoff item. Throws naming the
92
+ * missing/invalid field; returns the canonical verdict token on success.
93
+ * Validates the RESOLUTION shape only — legacy items (no risk_surface /
94
+ * tier_hint, absent or non-array could_not_verify) stay resolvable.
95
+ * @param {object} item
96
+ * @param {object} qa_verdict
97
+ * @returns {string} canonical verdict token
98
+ */
99
+ export function validateQaVerdict(item, qa_verdict) {
100
+ if (!qa_verdict || typeof qa_verdict !== 'object' || Array.isArray(qa_verdict)) {
101
+ throw gateError(item, "missing structured qa_verdict object (pass qa_verdict to resolveItem; shape: see 'The recipient gate' in the qa-handoff skill)");
102
+ }
103
+ if (typeof qa_verdict.commit_tested !== 'string' || !qa_verdict.commit_tested.trim()) {
104
+ throw gateError(item, 'qa_verdict.commit_tested is required (the main commit the QA ran against)');
105
+ }
106
+ if (qa_verdict.tier !== 'narrow' && qa_verdict.tier !== 'full') {
107
+ throw gateError(item, "qa_verdict.tier is required ('narrow' | 'full')");
108
+ }
109
+
110
+ const posture = qa_verdict.coverage_posture;
111
+ if (!posture || typeof posture !== 'object' || Array.isArray(posture)) {
112
+ throw gateError(item, 'qa_verdict.coverage_posture is required (the coverage look, separate from check results)');
113
+ }
114
+ if (!QA_COVERAGE_ASSESSMENTS.includes(posture.assessment)) {
115
+ throw gateError(item, `qa_verdict.coverage_posture.assessment must be one of: ${QA_COVERAGE_ASSESSMENTS.join(' | ')}`);
116
+ }
117
+ if (posture.assessment === 'gap-filed') {
118
+ const filed = posture.gap_filed;
119
+ if (!Array.isArray(filed) || filed.length === 0
120
+ || filed.some((g) => !g || typeof g.filed_as !== 'string' || !g.filed_as.trim())) {
121
+ throw gateError(item, "coverage_posture.assessment is 'gap-filed' but coverage_posture.gap_filed is not a non-empty array of {gap, filed_as}");
122
+ }
123
+ }
124
+
125
+ const verdict = normalizeQaVerdictToken(qa_verdict.verdict);
126
+ if (!verdict) {
127
+ throw gateError(item, "qa_verdict.verdict is not a legal token — use 'runtime-verified', 'blocked', or 'verified · N gaps filed' (N >= 1; bare 'verified' is illegal: the label may not out-run its substance)");
128
+ }
129
+
130
+ // The gaps-bearing label must match its substance, and a clean label may
131
+ // not hide filed gaps. ('blocked' + filed_gaps is deliberately legal — a
132
+ // blocked QA can still file follow-ups; the block itself is the headline.)
133
+ const gapsMatch = verdict.match(QA_GAPS_TOKEN_RE);
134
+ const filedGaps = Array.isArray(qa_verdict.filed_gaps) ? qa_verdict.filed_gaps : [];
135
+ if (gapsMatch) {
136
+ const n = parseInt(gapsMatch[1], 10);
137
+ if (filedGaps.length !== n
138
+ || filedGaps.some((g) => !g || typeof g.filed_as !== 'string' || !g.filed_as.trim())) {
139
+ throw gateError(item, `verdict claims ${n} gaps filed but qa_verdict.filed_gaps has ${filedGaps.length} entries with a filed_as fid — the count in the label must equal the gaps actually filed`);
140
+ }
141
+ } else if (verdict === 'runtime-verified' && filedGaps.length > 0) {
142
+ throw gateError(item, `verdict 'runtime-verified' with ${filedGaps.length} filed_gaps — use 'verified · ${filedGaps.length} gaps filed'`);
143
+ }
144
+
145
+ // Confessed gaps cannot be laundered: every could_not_verify entry must be
146
+ // discharged as fixed-in-session or an explicitly typed deferral. Absent /
147
+ // non-array / empty confession lists demand nothing (legacy compatibility).
148
+ const confessed = Array.isArray(item.evidence?.could_not_verify)
149
+ ? item.evidence.could_not_verify : [];
150
+ if (confessed.length > 0) {
151
+ const cg = qa_verdict.confessed_gap;
152
+ if (!cg || typeof cg !== 'object' || Array.isArray(cg)) {
153
+ throw gateError(item, `this handoff confessed ${confessed.length} could_not_verify entr${confessed.length === 1 ? 'y' : 'ies'} — qa_verdict.confessed_gap {written_and_run, deferred} is required to discharge them`);
154
+ }
155
+ const written = Array.isArray(cg.written_and_run) ? cg.written_and_run : [];
156
+ const deferred = Array.isArray(cg.deferred) ? cg.deferred : [];
157
+ written.forEach((w, i) => {
158
+ if (typeof w !== 'string' || !w.trim()) {
159
+ throw gateError(item, `confessed_gap.written_and_run[${i}] must be a non-empty string naming the test written and run`);
160
+ }
161
+ });
162
+ deferred.forEach((d, i) => {
163
+ if (!d || typeof d !== 'object'
164
+ || typeof d.gap !== 'string' || !d.gap.trim()
165
+ || typeof d.filed_as !== 'string' || !d.filed_as.trim()
166
+ || typeof d.justification !== 'string' || !d.justification.trim()) {
167
+ throw gateError(item, `confessed_gap.deferred[${i}] must be a typed deferral {gap, filed_as, justification} — a confessed gap is fixed in-session or explicitly deferred, never folded into 'filed'`);
168
+ }
169
+ });
170
+ if (written.length + deferred.length < confessed.length) {
171
+ throw gateError(item, `${confessed.length} confessed could_not_verify entr${confessed.length === 1 ? 'y' : 'ies'} but only ${written.length + deferred.length} dispositioned (written_and_run + typed deferrals) — every confessed gap must be discharged`);
172
+ }
173
+ }
174
+
175
+ return verdict;
176
+ }
177
+
178
+ /**
179
+ * Emit a pattern-promotion inbox item from a qa-gate class sweep.
180
+ * Threshold-gated (default >= 3 instances), deduplicated against pending
181
+ * pattern-promotion items by (source_item_id, failure_class), and refuses
182
+ * to file into a directory that is not a real watchtower install.
183
+ * @param {object} params
184
+ * @returns {string|null} The item id (existing id when deduplicated),
185
+ * or null when instance_count is below threshold.
186
+ */
187
+ export function emitPatternPromotion({
188
+ project,
189
+ project_path,
190
+ source_item_id,
191
+ failure_class,
192
+ instance_count,
193
+ pattern_text,
194
+ population = null,
195
+ desk = null,
196
+ threshold = 3,
197
+ }) {
198
+ if (typeof failure_class !== 'string' || !failure_class.trim()) {
199
+ throw new Error('emitPatternPromotion: failure_class is required');
200
+ }
201
+ if (typeof source_item_id !== 'string' || !source_item_id.trim()) {
202
+ throw new Error('emitPatternPromotion: source_item_id is required (the qa-handoff item the sweep ran for — it is half the dedup key)');
203
+ }
204
+ if (!Number.isInteger(threshold) || threshold < 1) {
205
+ throw new Error('emitPatternPromotion: threshold must be a positive integer');
206
+ }
207
+ if (!Number.isInteger(instance_count) || instance_count < threshold) return null;
208
+ if (!existsSync(join(WATCHTOWER_DIR, 'config.json'))) {
209
+ throw new Error(`emitPatternPromotion: no watchtower install at ${WATCHTOWER_DIR} (config.json missing) — refusing to file into a phantom queue; record the promotion candidate in the stamped verdict instead`);
210
+ }
211
+ const existing = listPending({ category: 'pattern-promotion' }).find(
212
+ (i) => i.evidence?.source_item_id === source_item_id
213
+ && i.evidence?.failure_class === failure_class,
214
+ );
215
+ if (existing) return existing.id;
216
+ return createItem({
217
+ project,
218
+ project_path,
219
+ category: 'pattern-promotion',
220
+ urgency: 'normal',
221
+ title: `Pattern promotion: ${failure_class}`,
222
+ summary: `qa-gate class sweep found ${instance_count} instances${population ? ` (${population})` : ''} — recurring failure class, promotion candidate`,
223
+ context_anchor: `qa-handoff item ${source_item_id}`,
224
+ evidence: {
225
+ source: 'qa-gate-sweep',
226
+ source_item_id,
227
+ failure_class,
228
+ instance_count,
229
+ population,
230
+ pattern_text,
231
+ },
232
+ options: [
233
+ { value: 'write', label: 'Write pattern', description: 'Capture to the project pattern dir for later promotion review' },
234
+ { value: 'dismiss', label: 'Dismiss', description: 'Not a recurring class worth capturing' },
235
+ ],
236
+ filed_by: 'qa-gate',
237
+ desk,
238
+ });
239
+ }
240
+
48
241
  // --- Exports ---
49
242
 
50
243
  /**
@@ -68,6 +261,8 @@ export function createItem({
68
261
  plan_fid = null,
69
262
  thread_ids = [],
70
263
  confidence = null,
264
+ project_unresolved = false,
265
+ desk = null,
71
266
  }) {
72
267
  ensureDir(QUEUE_DIR);
73
268
  const id = generateId();
@@ -76,6 +271,11 @@ export function createItem({
76
271
  id,
77
272
  project,
78
273
  project_path,
274
+ // Additive fields, present only when meaningful (older readers and items
275
+ // omit them): project_unresolved marks a basename-fallback identity,
276
+ // desk preserves the mux desk name as display metadata.
277
+ ...(project_unresolved ? { project_unresolved: true } : {}),
278
+ ...(desk ? { desk } : {}),
79
279
  filed_at: new Date().toISOString(),
80
280
  filed_by,
81
281
  status: 'pending',
@@ -104,14 +304,22 @@ export function createItem({
104
304
 
105
305
  /**
106
306
  * Resolve an inbox item.
307
+ * For qa-handoff items, a structured `qa_verdict` is REQUIRED and validated
308
+ * (validateQaVerdict) — invalid shapes THROW naming the missing field; the
309
+ * bare-null return remains reserved for "not pending". The validated verdict
310
+ * is stamped onto the item with its token canonicalized.
107
311
  * @param {string} id
108
312
  * @param {object} params
109
313
  * @returns {object} The updated item
110
314
  */
111
- export function resolveItem(id, { resolution, resolution_notes = null, resolution_type = null }) {
315
+ export function resolveItem(id, { resolution, resolution_notes = null, resolution_type = null, qa_verdict = null }) {
112
316
  const fp = itemPath(id);
113
317
  const item = readItem(fp);
114
318
  if (item.status !== 'pending') return null;
319
+ if (item.category === QA_CATEGORY) {
320
+ const verdict = validateQaVerdict(item, qa_verdict);
321
+ item.qa_verdict = { ...qa_verdict, verdict };
322
+ }
115
323
  item.status = 'resolved';
116
324
  item.resolved_at = new Date().toISOString();
117
325
  item.resolution = resolution;
@@ -131,6 +339,11 @@ export function dismissItem(id, { notes = null, resolution_type = null } = {}) {
131
339
  const fp = itemPath(id);
132
340
  const item = readItem(fp);
133
341
  if (item.status !== 'pending') return null;
342
+ if (item.category === QA_CATEGORY
343
+ && (typeof resolution_type !== 'string' || !resolution_type.trim()
344
+ || typeof notes !== 'string' || !notes.trim())) {
345
+ throw gateError(item, 'dismissing a qa-handoff item requires a typed resolution_type AND notes naming why post-merge QA is being waived — dismissal is an audited escape hatch, not a bypass of the recipient gate');
346
+ }
134
347
  item.status = 'dismissed';
135
348
  item.resolved_at = new Date().toISOString();
136
349
  item.resolution_type = resolution_type;
@@ -149,6 +362,9 @@ export function supersedeItem(id, { reason = null } = {}) {
149
362
  const fp = itemPath(id);
150
363
  const item = readItem(fp);
151
364
  if (item.status !== 'pending') return null;
365
+ if (item.category === QA_CATEGORY && (typeof reason !== 'string' || !reason.trim())) {
366
+ throw gateError(item, 'superseding a qa-handoff item requires a reason naming what replaces it (e.g. the newer handoff covering the same merge)');
367
+ }
152
368
  item.status = 'superseded';
153
369
  item.resolution_notes = reason;
154
370
  atomicWrite(fp, item);
@@ -164,6 +380,9 @@ export function expireItem(id) {
164
380
  const fp = itemPath(id);
165
381
  const item = readItem(fp);
166
382
  if (item.status !== 'pending') return null;
383
+ if (item.category === QA_CATEGORY) {
384
+ throw gateError(item, 'qa-handoff items never expire — QA debt stays surfaced until a stamped verdict resolves it (or a typed dismissal waives it)');
385
+ }
167
386
  item.status = 'expired';
168
387
  item.resolution_notes = 'Auto-expired by age policy';
169
388
  atomicWrite(fp, item);
@@ -261,6 +480,12 @@ export function runExpiry({ warnDays = 14, expireDays = 30 } = {}) {
261
480
 
262
481
  for (const item of pending) {
263
482
  const age = now - new Date(item.filed_at).getTime();
483
+ // qa-handoff items never auto-expire: an expired handoff is exactly the
484
+ // silent QA gap the recipient gate forbids. They warn (by item) instead.
485
+ if (item.category === QA_CATEGORY) {
486
+ if (age >= warnMs) warned.push(item);
487
+ continue;
488
+ }
264
489
  if (age >= expireMs) {
265
490
  item.status = 'expired';
266
491
  item.resolution_notes = `Auto-expired after ${expireDays} days. If still relevant, re-file with updated context.`;
@@ -26,6 +26,7 @@ import { homedir } from 'os';
26
26
  import {
27
27
  atomicWrite, loadConfig, slugify, log as _log, logError as _logError,
28
28
  getWatchtowerDir, createItem, listPending, resolveItem, loadBetterSqlite3,
29
+ preserveRing3LastSession,
29
30
  } from './watchtower-lib.mjs';
30
31
 
31
32
  const WATCHTOWER_DIR = getWatchtowerDir();
@@ -917,11 +918,26 @@ function main() {
917
918
  const summary = assembleSummary(projectStates, config);
918
919
  atomicWrite(join(stateDir, 'summary.md'), summary);
919
920
 
920
- // Write per-project state files
921
+ // Write per-project state files.
922
+ // Section ownership (watchtower-contracts.md §Project State Section
923
+ // Ownership): Ring 3 owns "## Last Session" once it has authored a rich
924
+ // session summary there (marked by its `_<date> (<session-id>)_`
925
+ // attribution line). Ring 1 rebuilds every OTHER section from scratch,
926
+ // but must carry a Ring 3-authored Last Session forward verbatim —
927
+ // otherwise this rebuild deterministically clobbers Ring 3's summary
928
+ // within one cron tick.
921
929
  for (const ps of projectStates) {
922
930
  const slug = slugify(ps.name);
923
- const projectMd = assembleProjectState(ps);
924
- atomicWrite(join(projectsDir, `${slug}.md`), projectMd);
931
+ const statePath = join(projectsDir, `${slug}.md`);
932
+ let projectMd = assembleProjectState(ps);
933
+ if (existsSync(statePath)) {
934
+ try {
935
+ projectMd = preserveRing3LastSession(projectMd, readFileSync(statePath, 'utf8'));
936
+ } catch (e) {
937
+ logError(`could not merge existing state for ${slug}: ${e.message} — writing fresh`);
938
+ }
939
+ }
940
+ atomicWrite(statePath, projectMd);
925
941
  }
926
942
 
927
943
  log(`collected state for ${projectStates.length} project(s)`);
@@ -479,8 +479,10 @@ async function escalateQueueItems() {
479
479
  const ageMs = now - new Date(item.filed_at).getTime();
480
480
  const ageDays = ageMs / (24 * 60 * 60 * 1000);
481
481
 
482
- if (ageDays >= ESCALATION_EXPIRE_DAYS) {
483
- // 30+ days: expire
482
+ if (ageDays >= ESCALATION_EXPIRE_DAYS && item.category !== 'qa-handoff') {
483
+ // 30+ days: expire. qa-handoff items are exempt — they never expire
484
+ // (recipient-gate contract: expireItem THROWS on them); they fall
485
+ // through to the [AGING] path below and stay surfaced as QA debt.
484
486
  expireItem(item.id);
485
487
  expired++;
486
488
  log(`Fast: expired queue item ${item.id} (${Math.floor(ageDays)}d old)`);