create-claude-cabinet 0.48.0 → 0.49.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 (48) hide show
  1. package/lib/CLAUDE.md +22 -0
  2. package/lib/cli.js +100 -5
  3. package/lib/metadata.js +20 -0
  4. package/package.json +1 -1
  5. package/templates/CLAUDE.md +212 -12
  6. package/templates/cabinet/watchtower-contracts.md +85 -0
  7. package/templates/cabinet/worktree-invocation-contract.md +35 -0
  8. package/templates/engagement/pib-db-patches/pib-db-lib.mjs +53 -9
  9. package/templates/mux/__tests__/mux-fail-loud.fixture.sh +4 -5
  10. package/templates/mux/__tests__/worktree-lifecycle.fixture.sh +214 -0
  11. package/templates/mux/__tests__/worktree-lifecycle.test.mjs +44 -0
  12. package/templates/mux/__tests__/worktree-status-clean.fixture.sh +245 -0
  13. package/templates/mux/__tests__/worktree-status-clean.test.mjs +89 -0
  14. package/templates/mux/bin/mux +44 -28
  15. package/templates/mux/config/worktree-cleanup.sh +57 -12
  16. package/templates/mux/config/worktree-dirty-check.sh +72 -2
  17. package/templates/mux/config/worktree-session-health.sh +401 -17
  18. package/templates/scripts/__tests__/assessment-recalibration.test.mjs +387 -0
  19. package/templates/scripts/__tests__/batch-disposition.test.mjs +19 -0
  20. package/templates/scripts/__tests__/branch-diverged-reconcile.test.mjs +374 -0
  21. package/templates/scripts/__tests__/completion-review-reconcile.test.mjs +173 -0
  22. package/templates/scripts/__tests__/cross-ring-reader.test.mjs +12 -4
  23. package/templates/scripts/__tests__/detector-registry.test.mjs +152 -0
  24. package/templates/scripts/__tests__/draft-surfacing.test.mjs +433 -0
  25. package/templates/scripts/__tests__/generated-state-classification.test.mjs +228 -0
  26. package/templates/scripts/__tests__/mirror-parity.test.mjs +54 -0
  27. package/templates/scripts/__tests__/resolve-project-slug.test.mjs +186 -0
  28. package/templates/scripts/__tests__/ring3-attribution.test.mjs +267 -0
  29. package/templates/scripts/__tests__/ring3-completion-filter.test.mjs +176 -0
  30. package/templates/scripts/__tests__/ring3-coverage-debt.test.mjs +377 -0
  31. package/templates/scripts/__tests__/ring3-last-session-pointer.test.mjs +80 -0
  32. package/templates/scripts/audit-coherence-check.mjs +6 -2
  33. package/templates/scripts/load-triage-history.js +23 -2
  34. package/templates/scripts/merge-findings.js +9 -2
  35. package/templates/scripts/pib-db-lib.mjs +55 -7
  36. package/templates/scripts/watchtower-cross-ring-reader.mjs +20 -3
  37. package/templates/scripts/watchtower-inbox-assessment.mjs +186 -35
  38. package/templates/scripts/watchtower-lib.mjs +268 -5
  39. package/templates/scripts/watchtower-queue.mjs +197 -2
  40. package/templates/scripts/watchtower-ring1.mjs +490 -69
  41. package/templates/scripts/watchtower-ring2.mjs +175 -3
  42. package/templates/scripts/watchtower-ring3-close.mjs +519 -69
  43. package/templates/skills/audit/SKILL.md +6 -3
  44. package/templates/skills/cabinet-anthropic-insider/SKILL.md +29 -8
  45. package/templates/skills/cabinet-process-therapist/SKILL.md +45 -3
  46. package/templates/skills/inbox/SKILL.md +29 -1
  47. package/templates/skills/session-handoff/SKILL.md +6 -8
  48. package/templates/watchtower/config.json.template +2 -1
@@ -0,0 +1,152 @@
1
+ // Structural detector-symmetry test (act:4d11fd53, grp:wt-noise-immunity).
2
+ //
3
+ // Scans the THREE ring scripts' source text (read-only — house precedent:
4
+ // suppression-ledger's wiring assertion) for `category: '<literal>'` at
5
+ // createItem FILING sites, and asserts the set of filed categories exactly
6
+ // matches INBOX_DETECTOR_REGISTRY (watchtower-lib) — every filed category
7
+ // declares a reconciler or an explicit exemption, and no registry entry
8
+ // goes stale. A new detector shipped without a declared retraction path
9
+ // fails the suite, not the operator's trust: the 2026-07-12 drain found
10
+ // the same missing-retraction disease in four detectors.
11
+ //
12
+ // `category:` also appears at READ sites (listPending/listItems filters);
13
+ // each occurrence is classified by its NEAREST preceding call opener so
14
+ // query filters are never conflated with filing sites (CP1 finding). A
15
+ // count floor guards the parser against vacuous passes.
16
+
17
+ import { test } from 'node:test';
18
+ import assert from 'node:assert/strict';
19
+ import { readFileSync } from 'node:fs';
20
+ import { join, dirname, resolve } from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+
23
+ const lib = await import('../watchtower-lib.mjs');
24
+
25
+ const here = dirname(fileURLToPath(import.meta.url));
26
+ const scriptsDir = resolve(here, '..');
27
+ const RING_SCRIPTS = [
28
+ 'watchtower-ring1.mjs',
29
+ 'watchtower-ring2.mjs',
30
+ 'watchtower-ring3-close.mjs',
31
+ ];
32
+
33
+ // Call openers that can own a `category:` property, matched as
34
+ // `name({` (word-boundary + object-literal) so prose like "briefing
35
+ // file(s)" or longer identifiers (fileRosterFinding) never classify.
36
+ // Filing owners: createItem and the rings' injectable `file` seam
37
+ // (`const file = deps.file || createItem` — ring2:1727, ring3:2380);
38
+ // everything else is a read filter.
39
+ const OWNER_RE = /\b(createItem|listPending|listItems|listPendingItems|file)\s*\(\s*\{/g;
40
+ const FILING_OWNERS = new Set(['createItem', 'file']);
41
+
42
+ function classifySites(source, fileName) {
43
+ const owners = [];
44
+ let om;
45
+ while ((om = OWNER_RE.exec(source)) !== null) {
46
+ owners.push({ name: om[1], index: om.index });
47
+ }
48
+ const filing = new Map(); // category → count
49
+ const reads = new Map();
50
+ const unparseable = [];
51
+ // Every `category:` occurrence is examined, not only pre-quoted matches:
52
+ // a filing site written with a template literal, a variable, or any shape
53
+ // the census can't extract would otherwise EVADE the registry gate — the
54
+ // precise hole the contract claims closed (CP3). Both quote styles parse;
55
+ // anything else inside a filing owner fails the suite loudly.
56
+ const occ = /category\s*:/g;
57
+ let m;
58
+ while ((m = occ.exec(source)) !== null) {
59
+ let owner = null;
60
+ for (const o of owners) {
61
+ if (o.index < m.index) owner = o;
62
+ else break;
63
+ }
64
+ const isFiling = !!owner && FILING_OWNERS.has(owner.name);
65
+ const lit = source.slice(m.index, m.index + 200)
66
+ .match(/^category\s*:\s*(['"])([^'"\n]+)\1/);
67
+ if (!lit) {
68
+ if (isFiling) {
69
+ const line = source.slice(0, m.index).split('\n').length;
70
+ unparseable.push(`${fileName}:${line} (${owner.name})`);
71
+ }
72
+ continue; // dynamic categories at read sites are not our concern
73
+ }
74
+ const bucket = isFiling ? filing : reads;
75
+ bucket.set(lit[2], (bucket.get(lit[2]) || 0) + 1);
76
+ }
77
+ return { filing, reads, unparseable };
78
+ }
79
+
80
+ const allFiling = new Map();
81
+ const allReads = new Map();
82
+ const allUnparseable = [];
83
+ let totalFilingSites = 0;
84
+ for (const name of RING_SCRIPTS) {
85
+ const { filing, reads, unparseable } = classifySites(readFileSync(join(scriptsDir, name), 'utf8'), name);
86
+ for (const [cat, n] of filing) {
87
+ allFiling.set(cat, (allFiling.get(cat) || 0) + n);
88
+ totalFilingSites += n;
89
+ }
90
+ for (const [cat, n] of reads) allReads.set(cat, (allReads.get(cat) || 0) + n);
91
+ allUnparseable.push(...unparseable);
92
+ }
93
+
94
+ test('every filing-site category is a statically parseable string literal', () => {
95
+ // A category the census cannot read is a detector the registry cannot
96
+ // gate — refuse the shape rather than let it ship unregistered.
97
+ assert.deepEqual(allUnparseable, [],
98
+ `filing sites with non-literal category values (use a plain quoted string): ${allUnparseable.join(', ')}`);
99
+ });
100
+
101
+ test('parser sanity: a real filing-site census, not a vacuous pass', () => {
102
+ // 2026-07-13 census: 2 ring1 + 8 ring2 + 9 ring3 filing sites across 16
103
+ // distinct categories. Floors, not exact counts — categories may be added
104
+ // (with registry entries) without touching this test.
105
+ assert.ok(totalFilingSites >= 15,
106
+ `expected ≥15 createItem filing sites across the rings, parsed ${totalFilingSites}`);
107
+ assert.ok(allFiling.size >= 16,
108
+ `expected ≥16 distinct filed categories, parsed ${allFiling.size}`);
109
+ // Read filters exist and were NOT counted as filings (the conflation guard):
110
+ assert.ok(allReads.size >= 3,
111
+ `expected the known listPending/listItems category filters to classify as reads, parsed ${allReads.size}`);
112
+ });
113
+
114
+ test('every ring-filed category is registered with a reconciler or an explicit exemption', () => {
115
+ const unregistered = [...allFiling.keys()]
116
+ .filter(cat => !(cat in lib.INBOX_DETECTOR_REGISTRY));
117
+ assert.deepEqual(unregistered, [],
118
+ `unregistered inbox categories: ${unregistered.join(', ')} — a detector must not ship `
119
+ + 'without a declared retraction path or exemption (add it to INBOX_DETECTOR_REGISTRY '
120
+ + 'in watchtower-lib.mjs per the Detector Symmetry contract)');
121
+ });
122
+
123
+ test('no registry entry is stale — every registered category is actually filed by a ring', () => {
124
+ const stale = Object.keys(lib.INBOX_DETECTOR_REGISTRY)
125
+ .filter(cat => !allFiling.has(cat));
126
+ assert.deepEqual(stale, [],
127
+ `registry entries with no live filing site: ${stale.join(', ')} — remove or re-anchor them`);
128
+ });
129
+
130
+ test('every registry entry declares exactly one of reconciler | exempt, non-empty', () => {
131
+ for (const [cat, entry] of Object.entries(lib.INBOX_DETECTOR_REGISTRY)) {
132
+ const hasReconciler = typeof entry.reconciler === 'string' && entry.reconciler.trim().length > 0;
133
+ const hasExempt = typeof entry.exempt === 'string' && entry.exempt.trim().length > 0;
134
+ assert.ok(hasReconciler !== hasExempt,
135
+ `${cat}: must declare exactly one of reconciler|exempt (got reconciler=${hasReconciler}, exempt=${hasExempt})`);
136
+ }
137
+ });
138
+
139
+ test('the program\'s reconciled categories are registered as reconcilers, not exemptions', () => {
140
+ // The four in-flight fixes are the registry's founding entries — they
141
+ // must never silently degrade to exemptions.
142
+ for (const cat of ['branch-diverged', 'worktree-unmerged', 'completion-review']) {
143
+ assert.ok(lib.INBOX_DETECTOR_REGISTRY[cat]?.reconciler,
144
+ `${cat} must declare its reconciler`);
145
+ }
146
+ // coverage-warning's declared semantics are the standing-debt aggregation
147
+ // (act:e888dd63) — assert the registry entry actually says so.
148
+ assert.match(lib.INBOX_DETECTOR_REGISTRY['coverage-warning']?.exempt || '',
149
+ /standing-debt aggregation/i);
150
+ assert.match(lib.INBOX_DETECTOR_REGISTRY['coverage-warning']?.exempt || '',
151
+ /first_seen/);
152
+ });
@@ -0,0 +1,433 @@
1
+ // Draft surfacing intelligence — freshness + fold proposals (act:00051dca,
2
+ // Lane C of grp:wt-noise-immunity).
3
+ //
4
+ // Contract under test:
5
+ // F1: the fold recipe (unigram Jaccard >= FOLD_SIMILARITY_THRESHOLD over
6
+ // stopworded alphanumeric-run tokens of title+draft) surfaces EXACTLY
7
+ // the 2026-07-12 live fold candidates and nothing else.
8
+ // F2: the Ring 2 sweep annotates every surfaced pair RECIPROCALLY (the
9
+ // self-validating criterion) and never auto-dismisses anything.
10
+ // F3: freshness — a pending draft citing an action that is CLOSED in the
11
+ // item's project pib-db gets evidence.freshness {overtaken, cited} and
12
+ // is excluded from the high-confidence sign-off batch. The done
13
+ // predicate covers both close paths (completed=1 and the ungated
14
+ // status='done'). Tri-state discipline: fid-not-found, open action,
15
+ // soft-deleted action, and missing/unreadable db all mean UNKNOWN —
16
+ // no annotation, the draft stays batch-eligible.
17
+ // F4: annotateItemEvidence write discipline — pending fence checked at
18
+ // WRITE time (a sweep can never resurrect a resolved item), deep-equal
19
+ // values skip the write (idempotent re-sweeps), schema_version rides
20
+ // through (annotated items stay readable), and itemPath rejects
21
+ // traversal-shaped ids.
22
+ // F5: the sweep writes its positive-confirmation sidecar — "ran with zero
23
+ // annotations" is distinguishable from "never ran".
24
+ //
25
+ // FIXTURE PROVENANCE: the five draft texts below are sanitized copies of the
26
+ // live resolved items the 2026-07-12 full inbox read surfaced as real fold
27
+ // candidates — the Boolean#cast trio (dec-5f850429 / dec-b2dc5070 /
28
+ // dec-f3446e84) and the testimonials pair (dec-eb6dcdb4 / dec-dd944a5b).
29
+ // Sanitization is a 1:1 token rename (the client name → "Acme"), which
30
+ // preserves unigram-Jaccard values EXACTLY, verified against the live items
31
+ // at authoring time: 5f8↔b2d 0.246, 5f8↔f34 0.311, b2d↔f34 0.218 (below
32
+ // threshold — the trio connects through its hub), eb6↔dd9 0.600, all
33
+ // cross-topic pairs ≤ 0.02. Verbatim drafts are deliberately NOT committed:
34
+ // templates/scripts/__tests__/ ships in the public npm tarball.
35
+ //
36
+ // Hermetic: WATCHTOWER_DIR points at a temp dir set BEFORE the dynamic
37
+ // imports (the queue lib reads it into a module const at load).
38
+
39
+ import { test } from 'node:test';
40
+ import assert from 'node:assert/strict';
41
+ import {
42
+ mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync, statSync,
43
+ } from 'node:fs';
44
+ import { join } from 'node:path';
45
+ import { tmpdir } from 'node:os';
46
+ import { createRequire } from 'node:module';
47
+
48
+ const require = createRequire(import.meta.url);
49
+
50
+ const root = mkdtempSync(join(tmpdir(), 'draft-surfacing-'));
51
+ process.env.WATCHTOWER_DIR = root;
52
+ mkdirSync(join(root, 'queue', 'items'), { recursive: true });
53
+ writeFileSync(join(root, 'config.json'), '{}\n');
54
+
55
+ const q = await import('../watchtower-queue.mjs');
56
+ const r2 = await import('../watchtower-ring2.mjs');
57
+
58
+ test.after(() => rmSync(root, { recursive: true, force: true }));
59
+
60
+ // --- Sanitized fixture drafts (see FIXTURE PROVENANCE above) -----------------
61
+
62
+ const TRIO_A = {
63
+ ref: 'dec-5f850429',
64
+ title: "lesson: ActiveModel::Type::Boolean#cast is fail-open — use explicit allowlist for consent/legal/safety params",
65
+ draft: "# ActiveModel::Type::Boolean#cast is fail-open — use explicit allowlist for consent/legal/safety params\n\n"
66
+ + "ActiveModel::Type::Boolean#cast is fail-open: any string not in its explicit false-list (nil, '0', 'false', etc.) "
67
+ + "casts to true. This means esign_consent='yes-ish' or any junk string would mint a true consent value on a legal "
68
+ + "record. For consent, legal, or safety gates, use an explicit allowlist check (e.g., [true, 'true'].include?(value)) "
69
+ + "rather than relying on Boolean#cast. Caught live during CP3 sweep of Lane 1b before a consent-less-in-spirit "
70
+ + "signature could have been minted.",
71
+ };
72
+ const TRIO_B = {
73
+ ref: 'dec-b2dc5070',
74
+ title: "lesson: Rails Boolean#cast treats 'yes-ish' as true — consent gates need a strict allowlist",
75
+ draft: "# Rails Boolean#cast treats 'yes-ish' as true — consent gates need a strict allowlist\n\n"
76
+ + "Rails' Boolean#cast is too permissive for security-sensitive consent gates: it treats strings like 'yes-ish' as "
77
+ + "true. The e-sign consent gate was hardened to a strict [true, 'true'] allowlist after CP3 caught this fail-open. "
78
+ + "Any model attribute used as a security gate (not just display) should use a strict allowlist rather than relying "
79
+ + "on AR's cast.",
80
+ };
81
+ const TRIO_C = {
82
+ ref: 'dec-f3446e84',
83
+ title: "lesson: Boolean param cast: use strict allowlist not ActiveModel::Type::Boolean for legal-effect consent fields",
84
+ draft: "# Boolean param cast: use strict allowlist not ActiveModel::Type::Boolean for legal-effect consent fields\n\n"
85
+ + "For any param that gates a legal-effect action (e-sign consent, marketing consent), use a strict allowlist "
86
+ + "(true/false only) rather than ActiveModel::Type::Boolean#cast. The fail-open cast accepts truthy junk values "
87
+ + "('1', 'yes', 'true', etc.) and can mint a consent record from a malformed or replayed param. The allowlist "
88
+ + "pattern: `params[:field] == true || params[:field] === false` (or equivalent). Track any remaining fail-open "
89
+ + "cast sites as a follow-up action.",
90
+ };
91
+ const PAIR_A = {
92
+ ref: 'dec-eb6dcdb4',
93
+ title: 'lesson: Acme: Testimonial model is not MCP-editable — admin UI only',
94
+ draft: "# Acme: Testimonial model is not MCP-editable — admin UI only\n\n"
95
+ + "The Testimonial model was lifted from campaign landing-content into its own model and is only accessible via the "
96
+ + "admin UI (Testimonials page). There is no MCP tool or API endpoint for it. Do not tell the client they can edit "
97
+ + "testimonials via Claude — only via the admin panel. If Claude-editable testimonials are wanted, a small MCP-tool "
98
+ + "addition is needed first.",
99
+ };
100
+ const PAIR_B = {
101
+ ref: 'dec-dd944a5b',
102
+ title: 'lesson: testimonials are NOT editable via Claude MCP — admin UI only',
103
+ draft: "# testimonials are NOT editable via Claude MCP — admin UI only\n\n"
104
+ + "The Testimonial model has no MCP tool and no API endpoint — it was lifted out of campaign landing-content into "
105
+ + "its own model that only the admin UI touches (Testimonials page). Do not tell clients they can edit testimonials "
106
+ + "via Claude; say 'editable in the admin UI (Testimonials page).'",
107
+ };
108
+ const FIXTURES = [TRIO_A, TRIO_B, TRIO_C, PAIR_A, PAIR_B];
109
+
110
+ function queueDraft(project, { title, draft }, overrides = {}) {
111
+ return q.createItem({
112
+ project,
113
+ project_path: overrides.project_path || `/tmp/${project}`,
114
+ category: 'knowledge-extraction',
115
+ title,
116
+ summary: title,
117
+ context_anchor: 'session fixture',
118
+ draft_artifact: draft,
119
+ filed_by: 'ring3',
120
+ ...overrides,
121
+ });
122
+ }
123
+
124
+ function readStored(id) {
125
+ return JSON.parse(readFileSync(join(root, 'queue', 'items', `${id}.json`), 'utf8'));
126
+ }
127
+
128
+ // --- F1: the fold recipe surfaces exactly the live pairs ---------------------
129
+
130
+ test('F1: proposeFolds surfaces exactly the 2026-07-12 pairs — trio hub links + testimonials pair', () => {
131
+ const items = FIXTURES.map((f) => ({ id: f.ref, title: f.title, draft_artifact: f.draft }));
132
+ const pairs = q.proposeFolds(items);
133
+ const keys = pairs.map((p) => [p.a, p.b].sort().join('|')).sort();
134
+ assert.deepEqual(keys, [
135
+ 'dec-5f850429|dec-b2dc5070',
136
+ 'dec-5f850429|dec-f3446e84',
137
+ 'dec-dd944a5b|dec-eb6dcdb4',
138
+ ]);
139
+ for (const p of pairs) assert.ok(p.similarity >= q.FOLD_SIMILARITY_THRESHOLD);
140
+ // Below-threshold direction: the b2d↔f34 wording sits just under the line
141
+ // (0.218) and cross-topic pairs are noise-level — none of them surface.
142
+ assert.ok(!keys.includes('dec-b2dc5070|dec-f3446e84'));
143
+ assert.ok(!keys.some((k) => k.includes('dec-eb6dcdb4') && k.includes('dec-5f850429')));
144
+ });
145
+
146
+ test('F1b: short-text floor and empty inputs never propose folds (no NaN, no boilerplate match)', () => {
147
+ assert.equal(q.unigramJaccard(new Set(), new Set()), 0);
148
+ assert.equal(q.unigramJaccard(new Set(['a']), new Set()), 0);
149
+ // Two near-empty drafts sharing one token would score 1.0 — the
150
+ // FOLD_MIN_TOKENS floor keeps them out entirely.
151
+ const tiny = [
152
+ { id: 'dec-aaaa0001', title: 'fix', draft_artifact: 'testimonials' },
153
+ { id: 'dec-aaaa0002', title: 'fix', draft_artifact: 'testimonials' },
154
+ ];
155
+ assert.deepEqual(q.proposeFolds(tiny), []);
156
+ assert.deepEqual(q.proposeFolds(null), []);
157
+ assert.deepEqual(q.proposeFolds([{ id: 'x', title: null, draft_artifact: null }]), []);
158
+ });
159
+
160
+ // --- F2: sweep reciprocity + never-dismiss -----------------------------------
161
+
162
+ test('F2: sweep annotates every surfaced pair reciprocally; nothing is dismissed', () => {
163
+ const project = 'fold-proj';
164
+ const ids = {};
165
+ for (const f of FIXTURES) ids[f.ref] = queueDraft(project, f);
166
+
167
+ const summary = r2.runDraftAnnotationSweep(
168
+ { projects: { [project]: { path: join(root, 'no-such-project') } } },
169
+ { watchtowerDir: root },
170
+ );
171
+
172
+ // All five ride the fold graph: the trio through its hub, the pair directly.
173
+ const expectPartners = {
174
+ [ids[TRIO_A.ref]]: [ids[TRIO_B.ref], ids[TRIO_C.ref]].sort(),
175
+ [ids[TRIO_B.ref]]: [ids[TRIO_A.ref]],
176
+ [ids[TRIO_C.ref]]: [ids[TRIO_A.ref]],
177
+ [ids[PAIR_A.ref]]: [ids[PAIR_B.ref]],
178
+ [ids[PAIR_B.ref]]: [ids[PAIR_A.ref]],
179
+ };
180
+ for (const [id, partners] of Object.entries(expectPartners)) {
181
+ const stored = readStored(id);
182
+ assert.equal(stored.status, 'pending', 'annotation never changes status');
183
+ assert.deepEqual(stored.evidence.possible_duplicate_of, partners, `partners of ${id}`);
184
+ }
185
+ // Reciprocity as an invariant: every listed partner lists you back.
186
+ for (const [id, partners] of Object.entries(expectPartners)) {
187
+ for (const partner of partners) {
188
+ assert.ok(readStored(partner).evidence.possible_duplicate_of.includes(id));
189
+ }
190
+ }
191
+ assert.equal(summary.fold_annotated, 5);
192
+ // No pib-db for this project — freshness skipped LOUDLY, not silently.
193
+ assert.deepEqual(summary.skipped_projects, [project]);
194
+ assert.equal(summary.freshness_annotated, 0);
195
+
196
+ // Fold-annotated items are excluded from the sign-off batch.
197
+ const { signoff, individual } = q.partitionForBatchSignoff(
198
+ q.listPending({ project }));
199
+ assert.equal(signoff.length, 0);
200
+ assert.equal(individual.length, 5);
201
+ });
202
+
203
+ test('F2b: a second sweep over already-annotated items performs zero writes (idempotent)', () => {
204
+ const project = 'fold-proj'; // same items as F2
205
+ const files = q.listPending({ project }).map((i) => join(root, 'queue', 'items', `${i.id}.json`));
206
+ const before = files.map((f) => [f, statSync(f).mtimeMs, readFileSync(f, 'utf8')]);
207
+
208
+ const summary = r2.runDraftAnnotationSweep(
209
+ { projects: { [project]: { path: join(root, 'no-such-project') } } },
210
+ { watchtowerDir: root },
211
+ );
212
+ assert.equal(summary.fold_annotated, 0, 'unchanged annotations are not rewritten');
213
+ for (const [f, mtime, content] of before) {
214
+ assert.equal(statSync(f).mtimeMs, mtime, `untouched: ${f}`);
215
+ assert.equal(readFileSync(f, 'utf8'), content);
216
+ }
217
+ });
218
+
219
+ // --- F3: freshness against a fixture pib-db ----------------------------------
220
+
221
+ function makeProjectDb(dir, { withDeletedAt = true } = {}) {
222
+ mkdirSync(dir, { recursive: true });
223
+ const Database = require('better-sqlite3');
224
+ const db = new Database(join(dir, 'pib.db'));
225
+ db.exec(`CREATE TABLE actions (
226
+ fid TEXT PRIMARY KEY, text TEXT, status TEXT DEFAULT 'open',
227
+ completed INTEGER DEFAULT 0, completed_at TEXT${withDeletedAt ? ', deleted_at TEXT' : ''}
228
+ )`);
229
+ return db;
230
+ }
231
+
232
+ const openFixtureDb = (projectPath) => {
233
+ const Database = require('better-sqlite3');
234
+ const p = join(projectPath, 'pib.db');
235
+ if (!existsSync(p)) return null;
236
+ return new Database(p, { readonly: true });
237
+ };
238
+
239
+ test('F3: a draft citing a since-closed action is annotated and leaves the sign-off batch', () => {
240
+ const project = 'fresh-proj';
241
+ const projDir = join(root, 'projects', project);
242
+ const db = makeProjectDb(projDir);
243
+ db.prepare("INSERT INTO actions (fid, status, completed, completed_at) VALUES ('act:11112222', 'done', 1, '2026-07-10')").run();
244
+ db.prepare("INSERT INTO actions (fid, status, completed) VALUES ('act:33334444', 'open', 0)").run();
245
+ db.close();
246
+
247
+ const overtaken = queueDraft(project, {
248
+ title: 'lesson: the cross-ring reader still exits nonzero on an empty portfolio',
249
+ draft: 'The reader exits 1 when config has no projects — tracked as act:11112222; workaround is a guard in the caller until that action lands.',
250
+ }, { project_path: projDir });
251
+ const freshOpen = queueDraft(project, {
252
+ title: 'lesson: keep the parity test binding when adding join keys',
253
+ draft: 'Join keys must stay parity-tested against the resolver — see act:33334444 for the open follow-up work.',
254
+ }, { project_path: projDir });
255
+ const noCitations = queueDraft(project, {
256
+ title: 'lesson: plain drafts with no citations ride the batch untouched',
257
+ draft: 'A lesson body citing no work items at all.',
258
+ }, { project_path: projDir });
259
+ const unknownFid = queueDraft(project, {
260
+ title: 'lesson: a citation the db has never heard of stays unknown',
261
+ draft: 'References act:99990000 which exists in no actions table anywhere.',
262
+ }, { project_path: projDir });
263
+
264
+ const summary = r2.runDraftAnnotationSweep(
265
+ { projects: { [project]: { path: projDir } } },
266
+ { watchtowerDir: root, openDb: openFixtureDb },
267
+ );
268
+ assert.equal(summary.freshness_annotated, 1);
269
+ assert.deepEqual(summary.skipped_projects, []);
270
+
271
+ const stored = readStored(overtaken);
272
+ assert.equal(stored.status, 'pending', 'never auto-dismissed');
273
+ assert.deepEqual(stored.evidence.freshness, {
274
+ overtaken: true,
275
+ cited: [{ fid: 'act:11112222', closed_at: '2026-07-10' }],
276
+ });
277
+ // Tri-state: open action, no citations, unknown fid ⇒ NO annotation.
278
+ for (const id of [freshOpen, noCitations, unknownFid]) {
279
+ assert.equal(readStored(id).evidence.freshness, undefined, `no annotation on ${id}`);
280
+ }
281
+
282
+ const { signoff, individual } = q.partitionForBatchSignoff(q.listPending({ project }));
283
+ assert.ok(individual.some((i) => i.id === overtaken), 'overtaken draft demoted to individual');
284
+ assert.deepEqual(signoff.map((i) => i.id).sort(), [freshOpen, noCitations, unknownFid].sort(),
285
+ 'unannotated drafts stay batch-eligible');
286
+ });
287
+
288
+ test('F3b: the ungated close path (status=done, completed=0) still reads as closed', () => {
289
+ const project = 'ungated-proj';
290
+ const projDir = join(root, 'projects', project);
291
+ const db = makeProjectDb(projDir);
292
+ db.prepare("INSERT INTO actions (fid, status, completed) VALUES ('act:55556666', 'done', 0)").run();
293
+ db.close();
294
+ const id = queueDraft(project, {
295
+ title: 'lesson: cites work closed through the ungated status flip',
296
+ draft: 'That behavior changed under act:55556666 and this draft may be overtaken by it.',
297
+ }, { project_path: projDir });
298
+ r2.runDraftAnnotationSweep(
299
+ { projects: { [project]: { path: projDir } } },
300
+ { watchtowerDir: root, openDb: openFixtureDb },
301
+ );
302
+ const fresh = readStored(id).evidence.freshness;
303
+ assert.equal(fresh.overtaken, true);
304
+ assert.deepEqual(fresh.cited, [{ fid: 'act:55556666', closed_at: null }]);
305
+ });
306
+
307
+ test('F3c: a soft-deleted action is unknown, not closed; an old schema without deleted_at still works', () => {
308
+ // deleted_at set ⇒ no annotation.
309
+ const pA = 'deleted-proj';
310
+ const dirA = join(root, 'projects', pA);
311
+ const dbA = makeProjectDb(dirA);
312
+ dbA.prepare("INSERT INTO actions (fid, status, completed, completed_at, deleted_at) VALUES ('act:77778888', 'done', 1, '2026-07-01', '2026-07-02')").run();
313
+ dbA.close();
314
+ const idA = queueDraft(pA, {
315
+ title: 'lesson: cites a soft-deleted action',
316
+ draft: 'Cites act:77778888 which was closed and then deleted.',
317
+ }, { project_path: dirA });
318
+
319
+ // Older consumer schema without the deleted_at column ⇒ fallback query runs.
320
+ const pB = 'oldschema-proj';
321
+ const dirB = join(root, 'projects', pB);
322
+ const dbB = makeProjectDb(dirB, { withDeletedAt: false });
323
+ dbB.prepare("INSERT INTO actions (fid, status, completed, completed_at) VALUES ('act:aaaa1111', 'done', 1, '2026-06-30')").run();
324
+ dbB.close();
325
+ const idB = queueDraft(pB, {
326
+ title: 'lesson: cites closed work in an old-schema db',
327
+ draft: 'Cites act:aaaa1111 living in a db predating soft deletion.',
328
+ }, { project_path: dirB });
329
+
330
+ const summary = r2.runDraftAnnotationSweep(
331
+ { projects: { [pA]: { path: dirA }, [pB]: { path: dirB } } },
332
+ { watchtowerDir: root, openDb: openFixtureDb },
333
+ );
334
+ assert.equal(readStored(idA).evidence.freshness, undefined, 'soft-deleted ⇒ unknown');
335
+ assert.equal(readStored(idB).evidence.freshness.overtaken, true, 'old schema ⇒ fallback query');
336
+ assert.equal(summary.freshness_annotated, 1);
337
+ });
338
+
339
+ test('F3d: extractCitedActFids — unique, ordered, capped, shape-validated', () => {
340
+ assert.deepEqual(q.extractCitedActFids('see act:12345678 and act:12345678 then act:90abcdef'),
341
+ ['act:12345678', 'act:90abcdef']);
342
+ // Malformed fids never match: non-hex, too short, and too long (\b after
343
+ // {8} rejects a 9th hex char) all yield nothing — the regex IS the shape
344
+ // validation for everything that later reaches a SQL parameter.
345
+ assert.deepEqual(q.extractCitedActFids('act:XYZ act:1234 act:123456789 react:12345678'), []);
346
+ assert.deepEqual(q.extractCitedActFids(''), []);
347
+ assert.deepEqual(q.extractCitedActFids(null), []);
348
+ const many = Array.from({ length: 80 }, (_, i) => `act:${String(10000000 + i)}`).join(' ');
349
+ assert.equal(q.extractCitedActFids(many).length, 50, 'capped at 50 per draft');
350
+ });
351
+
352
+ // --- F4: annotate write discipline -------------------------------------------
353
+
354
+ test('F4: annotate-after-resolve is a skip — a sweep can never resurrect a resolved item', () => {
355
+ const project = 'race-proj';
356
+ const id = queueDraft(project, { title: 'lesson: race fixture', draft: 'body text for the race fixture case' });
357
+ q.resolveItem(id, { resolution: 'captured', resolution_type: 'captured-to-memory', resolution_notes: 'raced the sweep' });
358
+ const res = q.annotateItemEvidence(id, { possible_duplicate_of: ['dec-beefcafe'] });
359
+ assert.equal(res, null);
360
+ const stored = readStored(id);
361
+ assert.equal(stored.status, 'resolved', 'resolution stamp intact');
362
+ assert.equal(stored.resolution_type, 'captured-to-memory');
363
+ assert.equal(stored.evidence.possible_duplicate_of, undefined);
364
+ });
365
+
366
+ test('F4b: annotated items survive the round trip — schema_version preserved, still listed', () => {
367
+ const project = 'roundtrip-proj';
368
+ const id = queueDraft(project, { title: 'lesson: round trip fixture', draft: 'body text for the round trip case' });
369
+ const res = q.annotateItemEvidence(id, { freshness: { overtaken: true, cited: [{ fid: 'act:0000ffff', closed_at: null }] } });
370
+ assert.equal(res.changed, true);
371
+ const stored = readStored(id);
372
+ assert.equal(stored.schema_version, 1);
373
+ const listed = q.listPending({ project });
374
+ assert.equal(listed.length, 1, 'annotated item still readable through readItem/listPending');
375
+ assert.equal(listed[0].evidence.freshness.overtaken, true);
376
+ // Unchanged re-annotation: no write.
377
+ const again = q.annotateItemEvidence(id, { freshness: { overtaken: true, cited: [{ fid: 'act:0000ffff', closed_at: null }] } });
378
+ assert.equal(again.changed, false);
379
+ });
380
+
381
+ test('F4c: itemPath rejects traversal-shaped ids at every entry', () => {
382
+ for (const evil of ['../escape', 'a/b', 'a\\b', '.hidden', '', null, 42]) {
383
+ assert.throws(() => q.annotateItemEvidence(evil, {}), /illegal item id/);
384
+ assert.throws(() => q.getItem(evil), /illegal item id/);
385
+ }
386
+ });
387
+
388
+ // --- F4d: sweep error arms ----------------------------------------------------
389
+
390
+ test('F4d: a throwing listPending is counted and skipped — one bad project never kills the sweep', () => {
391
+ const good = 'errarm-good';
392
+ const id = queueDraft(good, { title: 'lesson: healthy sibling project draft', draft: 'a normal draft body for the healthy sibling project' });
393
+ const summary = r2.runDraftAnnotationSweep(
394
+ { projects: { 'errarm-bad': { path: '/tmp/x' }, [good]: { path: join(root, 'no-db') } } },
395
+ {
396
+ watchtowerDir: root,
397
+ listPendingFn: (f) => {
398
+ if (f.project === 'errarm-bad') throw new Error('boom');
399
+ return q.listPending(f);
400
+ },
401
+ },
402
+ );
403
+ assert.equal(summary.errors, 1, 'the throw is counted, not swallowed silently');
404
+ assert.equal(summary.items_scanned, 1, 'the healthy project is still swept');
405
+ assert.equal(readStored(id).status, 'pending');
406
+ });
407
+
408
+ test('F4e: drafts filed under a project outside config are counted as unscanned, never invisible', () => {
409
+ const summary = r2.runDraftAnnotationSweep(
410
+ { projects: {} },
411
+ { watchtowerDir: root },
412
+ );
413
+ // Every pending draft queued by earlier tests belongs to a project absent
414
+ // from this empty config — the sweep must SAY so.
415
+ assert.ok(summary.unscanned_items >= 1, `unscanned_items visible (got ${summary.unscanned_items})`);
416
+ assert.equal(summary.items_scanned, 0);
417
+ });
418
+
419
+ // --- F5: positive confirmation sidecar ---------------------------------------
420
+
421
+ test('F5: every sweep writes the health sidecar — zero-annotation runs are visible', () => {
422
+ const sidecarPath = join(root, 'state', 'draft-annotations-health.json');
423
+ const summary = r2.runDraftAnnotationSweep(
424
+ { projects: { 'empty-proj': { path: join(root, 'nowhere') } } },
425
+ { watchtowerDir: root, nowIso: () => '2026-07-13T00:00:00.000Z' },
426
+ );
427
+ assert.equal(summary.items_scanned, 0);
428
+ assert.ok(existsSync(sidecarPath));
429
+ const sidecar = JSON.parse(readFileSync(sidecarPath, 'utf8'));
430
+ assert.equal(sidecar.schema_version, 1);
431
+ assert.equal(sidecar.last_run, '2026-07-13T00:00:00.000Z');
432
+ assert.equal(sidecar.items_scanned, 0);
433
+ });