create-claude-cabinet 0.51.0 → 0.52.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 (64) hide show
  1. package/README.md +35 -28
  2. package/lib/modules.js +5 -4
  3. package/package.json +1 -1
  4. package/templates/CLAUDE.md +32 -11
  5. package/templates/EXTENSIONS.md +7 -0
  6. package/templates/README.md +3 -3
  7. package/templates/briefing/_briefing-template.md +1 -1
  8. package/templates/briefing/_briefing-work-tracking-template.md +3 -3
  9. package/templates/cabinet/_cabinet-member-template.md +10 -7
  10. package/templates/cabinet/memory-lifecycle-contract.md +16 -0
  11. package/templates/cabinet/pib-db-access.md +1 -1
  12. package/templates/cabinet/pib-db-triggers.md +20 -16
  13. package/templates/cabinet/watchtower-contracts.md +119 -0
  14. package/templates/engagement/pib-db-patches/pib-db-mcp-server.mjs +2 -2
  15. package/templates/memory/patterns/_pattern-template.md +1 -1
  16. package/templates/rules/enforcement-pipeline.md +2 -2
  17. package/templates/rules/memory-capture.md +2 -2
  18. package/templates/scripts/__tests__/absorption-no-prune.test.mjs +89 -0
  19. package/templates/scripts/__tests__/assessment-recalibration.test.mjs +0 -2
  20. package/templates/scripts/__tests__/batch-disposition.test.mjs +7 -9
  21. package/templates/scripts/__tests__/draft-surfacing.test.mjs +46 -162
  22. package/templates/scripts/__tests__/extraction-classification.test.mjs +219 -0
  23. package/templates/scripts/__tests__/fixtures/read-pass-2026-07-14.json +325 -0
  24. package/templates/scripts/__tests__/fold-similarity.test.mjs +290 -0
  25. package/templates/scripts/__tests__/hold-verb.test.mjs +149 -0
  26. package/templates/scripts/__tests__/reach1-inbox-relations.test.mjs +274 -0
  27. package/templates/scripts/__tests__/reach2-significance.test.mjs +300 -0
  28. package/templates/scripts/__tests__/read-pass-fixture.test.mjs +71 -0
  29. package/templates/scripts/__tests__/ring3-outage-backfill.test.mjs +156 -0
  30. package/templates/scripts/memory-consolidation-scan.mjs +149 -0
  31. package/templates/scripts/pib-db-mcp-server.mjs +2 -2
  32. package/templates/scripts/watchtower-build-context.mjs +33 -1
  33. package/templates/scripts/watchtower-lib.mjs +167 -1
  34. package/templates/scripts/watchtower-queue.mjs +192 -53
  35. package/templates/scripts/watchtower-ring1.mjs +120 -2
  36. package/templates/scripts/watchtower-ring2.mjs +49 -112
  37. package/templates/scripts/watchtower-ring3-close.mjs +728 -38
  38. package/templates/skills/audit/SKILL.md +5 -5
  39. package/templates/skills/cabinet-system-advocate/SKILL.md +2 -2
  40. package/templates/skills/cabinet-workflow-cop/SKILL.md +5 -3
  41. package/templates/skills/cc-feedback/SKILL.md +4 -4
  42. package/templates/skills/cc-remember/SKILL.md +4 -4
  43. package/templates/skills/close/SKILL.md +63 -9
  44. package/templates/skills/debrief/SKILL.md +14 -8
  45. package/templates/skills/debrief/phases/qa-handoff-sweep.md +12 -9
  46. package/templates/skills/dx-feedback/SKILL.md +1 -1
  47. package/templates/skills/inbox/SKILL.md +85 -16
  48. package/templates/skills/memory/SKILL.md +1 -1
  49. package/templates/skills/onboard/SKILL.md +42 -33
  50. package/templates/skills/onboard/phases/detect-state.md +3 -2
  51. package/templates/skills/onboard/phases/generate-briefing.md +3 -2
  52. package/templates/skills/onboard/phases/generate-session-loop.md +70 -37
  53. package/templates/skills/onboard/phases/interview.md +17 -12
  54. package/templates/skills/onboard/phases/modularity-menu.md +33 -19
  55. package/templates/skills/onboard/phases/options.md +3 -1
  56. package/templates/skills/onboard/phases/post-onboard-audit.md +17 -11
  57. package/templates/skills/onboard/phases/summary.md +15 -12
  58. package/templates/skills/onboard/phases/work-tracking.md +14 -8
  59. package/templates/skills/orient/SKILL.md +7 -5
  60. package/templates/skills/pulse/SKILL.md +23 -18
  61. package/templates/skills/seed/phases/build-member.md +5 -3
  62. package/templates/skills/session-handoff/SKILL.md +72 -45
  63. package/templates/skills/spring-clean/SKILL.md +15 -13
  64. package/templates/skills/triage-audit/SKILL.md +3 -3
@@ -17,12 +17,12 @@ write it to memory without asking whether to save it.
17
17
  `/cc-remember` ensures:
18
18
  - The new memory lands at `~/.claude/projects/<slug>/memory/<slug>.md`
19
19
  - `MEMORY.md` gets a new entry in the "Curated entries (hand-authored)"
20
- section, so next session's `/orient` can find the memory
20
+ section, so the next session can find the memory
21
21
  - Filename collisions are handled (suffix with date if needed)
22
22
  - The write is atomic (temp + rename — safe under concurrent sessions)
23
23
 
24
24
  Direct flat-`.md` writes work but skip the indexing step. The memory
25
- becomes invisible to `/orient` until you manually update `MEMORY.md`.
25
+ becomes invisible to the next session until you manually update `MEMORY.md`.
26
26
  The `memory-index-guard` PostToolUse hook will flag this when it
27
27
  happens, but it's better to avoid the issue: **use `/cc-remember`**.
28
28
 
@@ -0,0 +1,89 @@
1
+ // absorbThreadSessions (act:6f93a1f1, N4 of grp:retro-remeaning) — the
2
+ // age-only session-record pruning gate (ABSORPTION_SESSION_AGE_DAYS = 90)
3
+ // is RETIRED. Structural contract under test:
4
+ // A1: an active thread's session record survives a slow-tier run, even when
5
+ // the session is far older than the old 90-day cutoff (the deleted
6
+ // constant no longer exists to prune it).
7
+ // A2: dormant-marking is UNCHANGED — a thread untouched for 30+ days still
8
+ // flips to 'dormant'.
9
+ // A3: the retired constant is gone from the module's exports/source.
10
+ //
11
+ // Hermetic per qa-handoff-gate.test.mjs: WATCHTOWER_DIR points at a temp dir,
12
+ // set BEFORE the dynamic import. No Anthropic calls — absorbThreadSessions
13
+ // touches only the filesystem.
14
+
15
+ import { test } from 'node:test';
16
+ import assert from 'node:assert/strict';
17
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs';
18
+ import { join } from 'node:path';
19
+ import { tmpdir } from 'node:os';
20
+
21
+ const root = mkdtempSync(join(tmpdir(), 'absorption-no-prune-'));
22
+ process.env.WATCHTOWER_DIR = root;
23
+
24
+ const ring2 = await import('../watchtower-ring2.mjs');
25
+ const { absorbThreadSessions, DORMANT_THREAD_AGE_DAYS } = ring2;
26
+
27
+ test.after(() => rmSync(root, { recursive: true, force: true }));
28
+
29
+ function isoDaysAgo(days) {
30
+ return new Date(Date.now() - days * 86400000).toISOString();
31
+ }
32
+
33
+ test('A1: an active thread carrying a >90-day-old session record survives a slow-tier run', async () => {
34
+ const threadsDir = join(root, 'state', 'threads');
35
+ mkdirSync(threadsDir, { recursive: true });
36
+
37
+ const thread = {
38
+ schema_version: 2,
39
+ thread: 'fixture-thread',
40
+ display_name: 'Fixture thread',
41
+ cursor_history: [{ date: isoDaysAgo(2), session_id: 'sess-recent', cursor: { what: 'x', where_left_off: 'y', open_questions: [] } }],
42
+ // Fixture must pin active + recent last_updated, or this hits the
43
+ // dormant branch and the survival assertion is vacuous.
44
+ sessions: [{ id: 'sess-old', project: 'fixture-proj', date: isoDaysAgo(200) }],
45
+ related_fids: [],
46
+ last_updated: isoDaysAgo(2),
47
+ status: 'active',
48
+ };
49
+ writeFileSync(join(threadsDir, 'fixture-thread.json'), JSON.stringify(thread, null, 2));
50
+
51
+ const sessionsDir = join(root, 'state', 'projects', 'fixture-proj', 'sessions');
52
+ mkdirSync(sessionsDir, { recursive: true });
53
+ const sessionFilePath = join(sessionsDir, `${isoDaysAgo(200).slice(0, 10)}-sess-old.md`);
54
+ writeFileSync(sessionFilePath, '# Session sess-old\n\nDate: ' + isoDaysAgo(200) + '\n\n- did a thing\n');
55
+
56
+ await absorbThreadSessions();
57
+
58
+ assert.ok(existsSync(sessionFilePath), 'the 200-day-old session record must survive — age-only pruning is retired');
59
+ const stored = JSON.parse(readFileSync(join(threadsDir, 'fixture-thread.json'), 'utf8'));
60
+ assert.equal(stored.status, 'active', 'a recently-updated thread must stay active, not be pruned into dormant');
61
+ });
62
+
63
+ test('A2: dormant-marking is unchanged — a thread untouched 30+ days flips to dormant', async () => {
64
+ const threadsDir = join(root, 'state', 'threads');
65
+ mkdirSync(threadsDir, { recursive: true });
66
+
67
+ const thread = {
68
+ schema_version: 2,
69
+ thread: 'stale-thread',
70
+ display_name: 'Stale thread',
71
+ cursor_history: [],
72
+ sessions: [],
73
+ related_fids: [],
74
+ last_updated: isoDaysAgo(DORMANT_THREAD_AGE_DAYS + 5),
75
+ status: 'active',
76
+ };
77
+ writeFileSync(join(threadsDir, 'stale-thread.json'), JSON.stringify(thread, null, 2));
78
+
79
+ await absorbThreadSessions();
80
+
81
+ const stored = JSON.parse(readFileSync(join(threadsDir, 'stale-thread.json'), 'utf8'));
82
+ assert.equal(stored.status, 'dormant');
83
+ });
84
+
85
+ test('A3: the retired ABSORPTION_SESSION_AGE_DAYS constant is gone', () => {
86
+ assert.equal(ring2.ABSORPTION_SESSION_AGE_DAYS, undefined);
87
+ const src = readFileSync(new URL('../watchtower-ring2.mjs', import.meta.url), 'utf8');
88
+ assert.ok(!src.includes('ABSORPTION_SESSION_AGE_DAYS'));
89
+ });
@@ -373,9 +373,7 @@ test('R8: the draft-sweep sidecar has a reader — assessRingsAlive surfaces its
373
373
  last_run: daysAgo(2),
374
374
  projects_scanned: 3,
375
375
  items_scanned: 41,
376
- freshness_annotated: 2,
377
376
  fold_annotated: 4,
378
- skipped_projects: [],
379
377
  unscanned_items: 0,
380
378
  errors: 0,
381
379
  }));
@@ -213,22 +213,20 @@ test('S8: partitionForBatchSignoff tolerates non-array input', () => {
213
213
  });
214
214
 
215
215
  test('S8b: surfacing annotations demote to individual — by semantic value, never field presence', () => {
216
- // An overtaken draft (cites since-closed work) is not a clean sign-off.
217
- assert.equal(q.isHighConfidenceSignoff(signoffItem({
218
- evidence: { freshness: { overtaken: true, cited: [{ fid: 'act:12345678', closed_at: '2026-07-10' }] } },
219
- })), false);
220
216
  // A fold candidate wants the human to review the pair together.
221
217
  assert.equal(q.isHighConfidenceSignoff(signoffItem({
222
218
  evidence: { possible_duplicate_of: ['dec-aaaa1111'] },
223
219
  })), false);
224
- // Presence alone must NOT demote: a benign "checked, still fresh" stamp or
225
- // an empty fold list keeps the item batch-eligible.
226
- assert.equal(q.isHighConfidenceSignoff(signoffItem({
227
- evidence: { freshness: { overtaken: false, cited: [] } },
228
- })), true);
220
+ // Presence alone must NOT demote: an empty fold list keeps the item
221
+ // batch-eligible. (The sibling freshness/overtaken demotion was RETIRED
222
+ // act:471dd701, N2 of grp:retro-remeaning — see draft-surfacing.test.mjs.)
229
223
  assert.equal(q.isHighConfidenceSignoff(signoffItem({
230
224
  evidence: { possible_duplicate_of: [] },
231
225
  })), true);
226
+ // A held item (holdItem) is deliberate retention, not a batch nod.
227
+ assert.equal(q.isHighConfidenceSignoff(signoffItem({
228
+ evidence: { held: { reason: 'specimen', held_at: '2026-07-20T00:00:00.000Z' } },
229
+ })), false);
232
230
  });
233
231
 
234
232
  test('S9: the batch set feeds applyBatch cleanly — real queued sign-off items resolve in one call', () => {
@@ -1,19 +1,13 @@
1
- // Draft surfacing intelligence — freshness + fold proposals (act:00051dca,
2
- // Lane C of grp:wt-noise-immunity).
1
+ // Draft surfacing intelligence — fold proposals (act:00051dca, Lane C of
2
+ // grp:wt-noise-immunity).
3
3
  //
4
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.
5
+ // F1: the fold recipe (overlap coefficient >= FOLD_SIMILARITY_THRESHOLD,
6
+ // dual-scored over title-alone AND title+draft, stopworded
7
+ // alphanumeric-run tokens) surfaces EXACTLY the live fold candidates
8
+ // and nothing else.
8
9
  // F2: the Ring 2 sweep annotates every surfaced pair RECIPROCALLY (the
9
10
  // 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
11
  // F4: annotateItemEvidence write discipline — pending fence checked at
18
12
  // WRITE time (a sweep can never resurrect a resolved item), deep-equal
19
13
  // values skip the write (idempotent re-sweeps), schema_version rides
@@ -22,16 +16,34 @@
22
16
  // F5: the sweep writes its positive-confirmation sidecar — "ran with zero
23
17
  // annotations" is distinguishable from "never ran".
24
18
  //
19
+ // The sibling F3 freshness suite (a draft citing a since-closed act: fid,
20
+ // demoted as "possibly overtaken") was RETIRED alongside the feature
21
+ // (act:471dd701, N2 of grp:retro-remeaning) — measured against the
22
+ // 2026-07-14 read-pass answer key, it fired on 62-of-66 TRUE drafts and
23
+ // 70-of-84 flags were false by construction. extractCitedActFids (its sole
24
+ // caller) was deleted with it.
25
+ //
25
26
  // FIXTURE PROVENANCE: the five draft texts below are sanitized copies of the
26
27
  // live resolved items the 2026-07-12 full inbox read surfaced as real fold
27
28
  // candidates — the Boolean#cast trio (dec-5f850429 / dec-b2dc5070 /
28
29
  // dec-f3446e84) and the testimonials pair (dec-eb6dcdb4 / dec-dd944a5b).
29
30
  // 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), eb6dd9 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.
31
+ // preserves similarity values EXACTLY under any token-overlap metric.
32
+ //
33
+ // unigram-Jaccard values (act:00051dca calibration): 5f8b2d 0.246,
34
+ // 5f8↔f34 0.311, b2d↔f34 0.218 (below the 0.22 threshold the trio
35
+ // connected only through its hub), eb6↔dd9 0.600, all cross-topic pairs
36
+ // ≤0.02.
37
+ //
38
+ // overlap-coefficient values, dual-scored (act:421a8ab2, N5 of
39
+ // grp:retro-remeaning — computed at authoring time over these exact fixture
40
+ // strings): 5f8↔b2d title 0.333 / combined 0.483, 5f8↔f34 title 0.636 /
41
+ // combined 0.500, b2d↔f34 title 0.455 / combined 0.414 (BOTH now above
42
+ // threshold — the trio is a fully connected triangle, not hub-only; this is
43
+ // the intended effect of the fix, not a regression), eb6↔dd9 title 0.600 /
44
+ // combined 0.857, all cross-topic pairs ≤0.048 (the noise ceiling holds
45
+ // under the new metric too). Verbatim drafts are deliberately NOT
46
+ // committed: templates/scripts/__tests__/ ships in the public npm tarball.
35
47
  //
36
48
  // Hermetic: WATCHTOWER_DIR points at a temp dir set BEFORE the dynamic
37
49
  // imports (the queue lib reads it into a module const at load).
@@ -43,9 +55,6 @@ import {
43
55
  } from 'node:fs';
44
56
  import { join } from 'node:path';
45
57
  import { tmpdir } from 'node:os';
46
- import { createRequire } from 'node:module';
47
-
48
- const require = createRequire(import.meta.url);
49
58
 
50
59
  const root = mkdtempSync(join(tmpdir(), 'draft-surfacing-'));
51
60
  process.env.WATCHTOWER_DIR = root;
@@ -127,20 +136,25 @@ function readStored(id) {
127
136
 
128
137
  // --- F1: the fold recipe surfaces exactly the live pairs ---------------------
129
138
 
130
- test('F1: proposeFolds surfaces exactly the 2026-07-12 pairs trio hub links + testimonials pair', () => {
139
+ test('F1: proposeFolds surfaces the FULL trio triangle (not just the hub) plus the testimonials pair', () => {
131
140
  const items = FIXTURES.map((f) => ({ id: f.ref, title: f.title, draft_artifact: f.draft }));
132
141
  const pairs = q.proposeFolds(items);
133
142
  const keys = pairs.map((p) => [p.a, p.b].sort().join('|')).sort();
143
+ // Under Jaccard (act:00051dca), b2d↔f34 sat just under threshold (0.218)
144
+ // and the trio connected only through its hub. The overlap-coefficient +
145
+ // dual-scoring fix (act:421a8ab2) surfaces it too — a fully connected
146
+ // triangle, the fix's intended effect, not a regression.
134
147
  assert.deepEqual(keys, [
135
148
  'dec-5f850429|dec-b2dc5070',
136
149
  'dec-5f850429|dec-f3446e84',
150
+ 'dec-b2dc5070|dec-f3446e84',
137
151
  'dec-dd944a5b|dec-eb6dcdb4',
138
152
  ]);
139
153
  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'));
154
+ // Cross-topic pairs stay below threshold under the new metric too (noise
155
+ // ceiling ≤0.048 see the fixture-provenance header for the full table).
143
156
  assert.ok(!keys.some((k) => k.includes('dec-eb6dcdb4') && k.includes('dec-5f850429')));
157
+ assert.ok(!keys.some((k) => k.includes('dec-dd944a5b') && k.includes('dec-b2dc5070')));
144
158
  });
145
159
 
146
160
  test('F1b: short-text floor and empty inputs never propose folds (no NaN, no boilerplate match)', () => {
@@ -169,11 +183,12 @@ test('F2: sweep annotates every surfaced pair reciprocally; nothing is dismissed
169
183
  { watchtowerDir: root },
170
184
  );
171
185
 
172
- // All five ride the fold graph: the trio through its hub, the pair directly.
186
+ // All five ride the fold graph: the trio is now a fully connected triangle
187
+ // (act:421a8ab2), the pair directly.
173
188
  const expectPartners = {
174
189
  [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]],
190
+ [ids[TRIO_B.ref]]: [ids[TRIO_A.ref], ids[TRIO_C.ref]].sort(),
191
+ [ids[TRIO_C.ref]]: [ids[TRIO_A.ref], ids[TRIO_B.ref]].sort(),
177
192
  [ids[PAIR_A.ref]]: [ids[PAIR_B.ref]],
178
193
  [ids[PAIR_B.ref]]: [ids[PAIR_A.ref]],
179
194
  };
@@ -189,9 +204,6 @@ test('F2: sweep annotates every surfaced pair reciprocally; nothing is dismissed
189
204
  }
190
205
  }
191
206
  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
207
 
196
208
  // Fold-annotated items are excluded from the sign-off batch.
197
209
  const { signoff, individual } = q.partitionForBatchSignoff(
@@ -216,138 +228,10 @@ test('F2b: a second sweep over already-annotated items performs zero writes (ide
216
228
  }
217
229
  });
218
230
 
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
- });
231
+ // F3 (freshness against a fixture pib-db) and F3d (extractCitedActFids) were
232
+ // RETIRED alongside the feature — act:471dd701, N2 of grp:retro-remeaning.
233
+ // See templates/scripts/__tests__/extraction-classification.test.mjs for
234
+ // their replacement (derivable/subject/unclassifiable).
351
235
 
352
236
  // --- F4: annotate write discipline -------------------------------------------
353
237
 
@@ -0,0 +1,219 @@
1
+ // Extraction classification (act:471dd701, N2 of grp:retro-remeaning) — break
2
+ // the type⇒durability weld, add derivable/subject, delete the broken
3
+ // freshness flag, unify the four-axis vocabulary.
4
+ //
5
+ // Contract under test:
6
+ // C1: the extraction prompt carries no "lessons don't decay" axiom.
7
+ // C2: the prompt's JSON schema literal matches the exported
8
+ // KNOWLEDGE_EXTRACTION_TYPES / KNOWLEDGE_EXTRACTION_HOMES arrays —
9
+ // one source of truth, not a re-spelled doc.
10
+ // C3: a derivable item (the "prod is at commit X" / "the census run
11
+ // already executed" shape) is routed to home:'derivation', never
12
+ // written to memory, and is ALWAYS filed — never dropped.
13
+ // C4: a durable lesson (no derivable claim) is routed to home:'memory'
14
+ // with a draft_artifact, unaffected by the derivable path.
15
+ // C5: an unclassifiable item is filed with its required reason — model
16
+ // uncertainty is visible, never a forced type/home guess, and it is
17
+ // never silently dropped either.
18
+ // C6: createItem's boundary check rejects an illegal type/home token, an
19
+ // unclassifiable item missing its reason, and a derivable:true item
20
+ // missing its derivation — mirroring the merge_state precedent.
21
+ //
22
+ // Hermetic: WATCHTOWER_DIR points at a temp dir, set BEFORE the dynamic
23
+ // imports. No live Anthropic calls — callFn is injected throughout.
24
+
25
+ import { test } from 'node:test';
26
+ import assert from 'node:assert/strict';
27
+ import { mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs';
28
+ import { join } from 'node:path';
29
+ import { tmpdir } from 'node:os';
30
+
31
+ const root = mkdtempSync(join(tmpdir(), 'extraction-classification-'));
32
+ process.env.WATCHTOWER_DIR = root;
33
+ mkdirSync(join(root, 'queue', 'items'), { recursive: true });
34
+ mkdirSync(join(root, 'state', 'threads'), { recursive: true });
35
+
36
+ const q = await import('../watchtower-queue.mjs');
37
+ const lib = await import('../watchtower-lib.mjs');
38
+ const r3 = await import('../watchtower-ring3-close.mjs');
39
+
40
+ test.after(() => rmSync(root, { recursive: true, force: true }));
41
+
42
+ const itemFiles = () => readdirSync(join(root, 'queue', 'items')).filter((f) => f.endsWith('.json'));
43
+ const readItems = () => itemFiles().map((f) => JSON.parse(readFileSync(join(root, 'queue', 'items', f), 'utf8')));
44
+ const clearQueue = () => { for (const f of itemFiles()) rmSync(join(root, 'queue', 'items', f)); };
45
+ const project = () => ({ name: 'classify-proj', path: join(tmpdir(), 'no-such-proj'), slug: 'classify-proj' });
46
+
47
+ function capturingCall(response) {
48
+ let capturedSystemPrompt = null;
49
+ const callFn = async (systemPrompt) => {
50
+ capturedSystemPrompt = systemPrompt;
51
+ return JSON.stringify(response);
52
+ };
53
+ return { callFn, getPrompt: () => capturedSystemPrompt };
54
+ }
55
+
56
+ // --- C1/C2: the prompt itself ---
57
+
58
+ test('C1: the extraction prompt carries no "lessons don\'t decay" axiom', async () => {
59
+ clearQueue();
60
+ const { callFn, getPrompt } = capturingCall([]);
61
+ await r3.decisionExtraction('transcript', project(), 'sess-prompt', '/t/x.jsonl', [], { callFn });
62
+ const prompt = getPrompt();
63
+ assert.ok(prompt, 'systemPrompt was captured');
64
+ assert.ok(!/does not decay/i.test(prompt), 'the retired axiom must be gone');
65
+ assert.ok(!/durable knowledge — their value/i.test(prompt));
66
+ });
67
+
68
+ test('C2: the prompt\'s JSON schema literal matches the exported four-axis vocabulary', async () => {
69
+ clearQueue();
70
+ const { callFn, getPrompt } = capturingCall([]);
71
+ await r3.decisionExtraction('transcript', project(), 'sess-schema', '/t/x.jsonl', [], { callFn });
72
+ const prompt = getPrompt();
73
+ const typesLiteral = lib.KNOWLEDGE_EXTRACTION_TYPES.join('|');
74
+ const homesLiteral = lib.KNOWLEDGE_EXTRACTION_HOMES.join('|');
75
+ assert.ok(prompt.includes(`"type":"${typesLiteral}"`),
76
+ `prompt type schema must read exactly "${typesLiteral}"`);
77
+ assert.ok(prompt.includes(`"home":"${homesLiteral}"`),
78
+ `prompt home schema must read exactly "${homesLiteral}"`);
79
+ // The taxonomy is present in prose too — not just the trailing JSON literal.
80
+ assert.ok(/DERIVABLE/.test(prompt));
81
+ assert.ok(/SUBJECT/.test(prompt));
82
+ assert.ok(/UNCLASSIFIABLE/.test(prompt));
83
+ });
84
+
85
+ // --- C3: derivable facts — the two deploy-shaped drafts ---
86
+
87
+ test('C3: two derivable ("deploy state") drafts are both routed to derivation, never memory, never dropped', async () => {
88
+ clearQueue();
89
+ const callFn = async () => JSON.stringify([
90
+ {
91
+ type: 'lesson', home: 'derivation', urgency: 'normal',
92
+ title: 'prod is at commit 35a5d15', content: 'Prod was observed at 35a5d15 during this session.',
93
+ derivable: true, derivation: 'git log --oneline -1 on the deploy remote, or check the deploy log',
94
+ subject: 'classify-proj', covered_by: 'none',
95
+ },
96
+ {
97
+ type: 'lesson', home: 'derivation', urgency: 'normal',
98
+ title: 'the census run already executed', content: 'The arb-forum census run was confirmed complete this session.',
99
+ derivable: true, derivation: "check the run's pib-db action status or the run log",
100
+ subject: 'classify-proj', covered_by: 'none',
101
+ },
102
+ ]);
103
+ const r = await r3.decisionExtraction('transcript', project(), 'sess-derivable', '/t/x.jsonl', [], { callFn });
104
+ assert.equal(r.queued, 2, 'both derivable items are ALWAYS filed — never dropped');
105
+
106
+ const items = readItems();
107
+ assert.equal(items.length, 2);
108
+ for (const item of items) {
109
+ assert.equal(item.evidence.home, 'derivation', 'routed to derivation, not memory');
110
+ assert.equal(item.evidence.derivable, true);
111
+ assert.ok(typeof item.evidence.derivation === 'string' && item.evidence.derivation.trim().length > 0);
112
+ assert.equal(item.draft_artifact, null, 'a derivable fact is never written as a memory draft');
113
+ assert.ok(item.summary.includes('Derivation:'), 'the recomputation instruction is visible to the human');
114
+ assert.deepEqual(item.options.map((o) => o.key).sort(), ['acknowledge', 'dismiss']);
115
+ }
116
+ });
117
+
118
+ // --- C4: a durable lesson is unaffected ---
119
+
120
+ test('C4: a durable lesson (no derivable claim) still routes to memory with a draft', async () => {
121
+ clearQueue();
122
+ const callFn = async () => JSON.stringify([{
123
+ type: 'lesson', home: 'memory', urgency: 'normal',
124
+ title: 'a partial JSONB update silently destroys omitted keys',
125
+ content: 'Updating a JSONB column with a partial payload replaces the whole value — keys omitted from the payload are destroyed, not merged. Always read-merge-write.',
126
+ subject: 'classify-proj', covered_by: 'none',
127
+ }]);
128
+ const r = await r3.decisionExtraction('transcript', project(), 'sess-durable', '/t/x.jsonl', [], { callFn });
129
+ assert.equal(r.queued, 1);
130
+ const [item] = readItems();
131
+ assert.equal(item.evidence.home, 'memory');
132
+ assert.equal(item.evidence.derivable, undefined);
133
+ assert.ok(item.draft_artifact && item.draft_artifact.includes('partial JSONB update'));
134
+ assert.deepEqual(item.options.map((o) => o.key), ['write', 'edit', 'dismiss']);
135
+ });
136
+
137
+ // --- C5: unclassifiable ---
138
+
139
+ test('C5: an unclassifiable item is filed with its required reason — never a forced guess, never dropped', async () => {
140
+ clearQueue();
141
+ const callFn = async () => JSON.stringify([{
142
+ type: 'unclassifiable',
143
+ title: 'something worth flagging that fits no existing bucket',
144
+ content: 'A genuinely ambiguous observation the model cannot confidently type or home.',
145
+ unclassifiable_reason: 'not clearly a lesson, decision, or preference — and no home fits either',
146
+ covered_by: 'none',
147
+ }]);
148
+ const r = await r3.decisionExtraction('transcript', project(), 'sess-unclassifiable', '/t/x.jsonl', [], { callFn });
149
+ assert.equal(r.queued, 1, 'unclassifiable is filed, not dropped');
150
+ const [item] = readItems();
151
+ assert.equal(item.evidence.type, 'unclassifiable');
152
+ assert.equal(item.evidence.unclassifiable_reason, 'not clearly a lesson, decision, or preference — and no home fits either');
153
+ assert.equal(item.evidence.home, undefined, 'home is not forced for an unclassifiable item');
154
+ assert.deepEqual(item.options.map((o) => o.key).sort(), ['dismiss', 'triage']);
155
+ });
156
+
157
+ // --- C6: createItem boundary check ---
158
+
159
+ test('C6: createItem rejects an illegal knowledge-extraction type or home', () => {
160
+ assert.throws(
161
+ () => q.createItem({
162
+ project: 'boundary-proj', project_path: '/tmp/boundary-proj', category: 'knowledge-extraction',
163
+ title: 'x', summary: 'x', context_anchor: 'x', evidence: { type: 'nonsense-type' },
164
+ }),
165
+ /illegal knowledge-extraction type/,
166
+ );
167
+ assert.throws(
168
+ () => q.createItem({
169
+ project: 'boundary-proj', project_path: '/tmp/boundary-proj', category: 'knowledge-extraction',
170
+ title: 'x', summary: 'x', context_anchor: 'x', evidence: { type: 'lesson', home: 'nonsense-home' },
171
+ }),
172
+ /illegal knowledge-extraction home/,
173
+ );
174
+ });
175
+
176
+ test('C6b: createItem rejects an unclassifiable item missing its reason', () => {
177
+ assert.throws(
178
+ () => q.createItem({
179
+ project: 'boundary-proj', project_path: '/tmp/boundary-proj', category: 'knowledge-extraction',
180
+ title: 'x', summary: 'x', context_anchor: 'x', evidence: { type: 'unclassifiable' },
181
+ }),
182
+ /unclassifiable_reason/,
183
+ );
184
+ assert.throws(
185
+ () => q.createItem({
186
+ project: 'boundary-proj', project_path: '/tmp/boundary-proj', category: 'knowledge-extraction',
187
+ title: 'x', summary: 'x', context_anchor: 'x', evidence: { type: 'unclassifiable', unclassifiable_reason: ' ' },
188
+ }),
189
+ /unclassifiable_reason/,
190
+ );
191
+ });
192
+
193
+ test('C6c: createItem rejects derivable:true without a derivation', () => {
194
+ assert.throws(
195
+ () => q.createItem({
196
+ project: 'boundary-proj', project_path: '/tmp/boundary-proj', category: 'knowledge-extraction',
197
+ title: 'x', summary: 'x', context_anchor: 'x',
198
+ evidence: { type: 'lesson', home: 'derivation', derivable: true },
199
+ }),
200
+ /derivable:true requires evidence\.derivation/,
201
+ );
202
+ });
203
+
204
+ test('C6d: home is NOT validated for an unclassifiable item — the model is not asked to guess one', () => {
205
+ // Present-but-off-vocabulary home alongside unclassifiable must not throw:
206
+ // the boundary check skips home validation entirely in this branch.
207
+ assert.doesNotThrow(() => q.createItem({
208
+ project: 'boundary-proj', project_path: '/tmp/boundary-proj', category: 'knowledge-extraction',
209
+ title: 'x', summary: 'x', context_anchor: 'x',
210
+ evidence: { type: 'unclassifiable', unclassifiable_reason: 'genuinely unclear', home: 'whatever' },
211
+ }));
212
+ });
213
+
214
+ test('C6e: KNOWLEDGE_EXTRACTION_TYPES/HOMES are exported consistently from both queue and lib', () => {
215
+ assert.deepEqual(q.KNOWLEDGE_EXTRACTION_TYPES, lib.KNOWLEDGE_EXTRACTION_TYPES);
216
+ assert.deepEqual(q.KNOWLEDGE_EXTRACTION_HOMES, lib.KNOWLEDGE_EXTRACTION_HOMES);
217
+ assert.ok(q.KNOWLEDGE_EXTRACTION_TYPES.includes('unclassifiable'));
218
+ assert.ok(q.KNOWLEDGE_EXTRACTION_HOMES.includes('derivation'));
219
+ });