create-claude-cabinet 0.45.0 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/README.md +6 -10
  2. package/lib/cli.js +374 -91
  3. package/lib/copy.js +108 -6
  4. package/lib/db-setup.js +122 -17
  5. package/lib/engagement-server-setup.js +34 -9
  6. package/lib/metadata.js +51 -2
  7. package/lib/migrate-from-omega.js +13 -1
  8. package/lib/mux-setup.js +33 -9
  9. package/lib/settings-merge.js +52 -10
  10. package/lib/watchtower-setup.js +230 -0
  11. package/package.json +5 -1
  12. package/templates/cabinet/_cabinet-member-template.md +8 -3
  13. package/templates/cabinet/advisories-state-schema.md +34 -7
  14. package/templates/cabinet/checklist-stats-schema.md +15 -1
  15. package/templates/cabinet/composition-patterns.md +4 -3
  16. package/templates/cabinet/memory-lifecycle-contract.md +135 -0
  17. package/templates/cabinet/pib-db-access.md +13 -0
  18. package/templates/cabinet/skill-output-conventions.md +35 -1
  19. package/templates/cabinet/watchtower-contracts.md +503 -1
  20. package/templates/cabinet/worktree-invocation-contract.md +87 -0
  21. package/templates/engagement/OVERVIEW.md +12 -0
  22. package/templates/engagement/__tests__/engagement.test.mjs +177 -2
  23. package/templates/engagement/__tests__/invoice-doc.test.mjs +195 -0
  24. package/templates/engagement/engagement-preview.mjs +100 -0
  25. package/templates/engagement/engagement-schema.md +240 -0
  26. package/templates/engagement/engagement.mjs +391 -11
  27. package/templates/engagement/pib-db-patches/pib-db-lib.mjs +10 -1
  28. package/templates/engagement/pib-db-patches/pib-db-mcp-server.mjs +5 -44
  29. package/templates/engagement/pib-db-patches/pib-db.mjs +5 -2
  30. package/templates/hooks/action-completion-gate.sh +9 -3
  31. package/templates/hooks/memory-index-guard.sh +17 -11
  32. package/templates/hooks/watchtower-session-start.sh +24 -2
  33. package/templates/mcp/pib-db.json +1 -4
  34. package/templates/mux/__tests__/mux-fail-loud.fixture.sh +44 -0
  35. package/templates/mux/__tests__/station-liveness.fixture.sh +234 -0
  36. package/templates/mux/__tests__/station-liveness.test.mjs +47 -0
  37. package/templates/mux/bin/mux +289 -56
  38. package/templates/mux/config/help.txt +1 -0
  39. package/templates/rules/acknowledge-when-corrected.md +33 -0
  40. package/templates/rules/maintainability.md +11 -0
  41. package/templates/rules/memory-capture.md +26 -4
  42. package/templates/rules/plan-before-bulk-or-irreversible-actions.md +48 -0
  43. package/templates/rules/verify-before-asserting.md +79 -0
  44. package/templates/scripts/__tests__/advisor-pass.test.mjs +238 -0
  45. package/templates/scripts/__tests__/advisories.test.mjs +262 -0
  46. package/templates/scripts/__tests__/ahead-check-origin.test.mjs +355 -0
  47. package/templates/scripts/__tests__/batch-disposition.test.mjs +235 -0
  48. package/templates/scripts/__tests__/claude-churn-authored.test.mjs +90 -0
  49. package/templates/scripts/__tests__/cross-ring-reader.test.mjs +505 -0
  50. package/templates/scripts/__tests__/dx-captures-briefing.test.mjs +169 -0
  51. package/templates/scripts/__tests__/feedback-outbox-flush.test.mjs +232 -0
  52. package/templates/scripts/__tests__/hook-runner.test.mjs +247 -0
  53. package/templates/scripts/__tests__/narrative-corpus.test.mjs +247 -0
  54. package/templates/scripts/__tests__/phase-shim.test.mjs +112 -0
  55. package/templates/scripts/__tests__/qa-handoff-gate.test.mjs +68 -0
  56. package/templates/scripts/__tests__/qa-handoff-merge-state.test.mjs +162 -0
  57. package/templates/scripts/__tests__/resolve-cli.test.mjs +314 -0
  58. package/templates/scripts/__tests__/ring-state-ownership.test.mjs +228 -3
  59. package/templates/scripts/__tests__/ring1-content-detector.test.mjs +168 -0
  60. package/templates/scripts/__tests__/ring1-flagged-actions.test.mjs +135 -0
  61. package/templates/scripts/__tests__/ring1-script-drift.test.mjs +126 -0
  62. package/templates/scripts/__tests__/ring2-queue-reader.test.mjs +80 -0
  63. package/templates/scripts/__tests__/ring2-recall-canary.test.mjs +150 -0
  64. package/templates/scripts/__tests__/ring2-roster-review.test.mjs +311 -0
  65. package/templates/scripts/__tests__/ring2-thread-context.test.mjs +189 -0
  66. package/templates/scripts/__tests__/ring3-chunk-merge.test.mjs +122 -0
  67. package/templates/scripts/__tests__/ring3-close-lenses.test.mjs +359 -0
  68. package/templates/scripts/__tests__/ring3-dedup.test.mjs +467 -0
  69. package/templates/scripts/__tests__/ring3-memory-titles.test.mjs +187 -0
  70. package/templates/scripts/__tests__/ring3-novelty-rescue.test.mjs +148 -0
  71. package/templates/scripts/__tests__/ring3-recent-slice.test.mjs +101 -0
  72. package/templates/scripts/__tests__/ring4-reconcile.test.mjs +393 -0
  73. package/templates/scripts/__tests__/routine-dispatch.test.mjs +312 -0
  74. package/templates/scripts/__tests__/shared-thread-reader.test.mjs +187 -0
  75. package/templates/scripts/__tests__/suppression-ledger.test.mjs +148 -0
  76. package/templates/scripts/__tests__/verify-backfill.test.mjs +103 -0
  77. package/templates/scripts/__tests__/verify-coverage.test.mjs +80 -0
  78. package/templates/scripts/__tests__/watchtower-snapshot.test.mjs +261 -0
  79. package/templates/scripts/__tests__/watchtower-sync.test.mjs +311 -0
  80. package/templates/scripts/load-triage-history.js +5 -2
  81. package/templates/scripts/pib-db-mcp-server.mjs +5 -44
  82. package/templates/scripts/pib-db-path.mjs +61 -0
  83. package/templates/scripts/pib-db.mjs +5 -2
  84. package/templates/scripts/validate-memory.mjs +214 -16
  85. package/templates/scripts/watchtower-advisories.mjs +309 -0
  86. package/templates/scripts/watchtower-build-context.mjs +301 -18
  87. package/templates/scripts/watchtower-cross-ring-reader.mjs +700 -0
  88. package/templates/scripts/watchtower-hook-runner.mjs +422 -0
  89. package/templates/scripts/watchtower-lib.mjs +447 -2
  90. package/templates/scripts/watchtower-narrative-corpus.mjs +385 -0
  91. package/templates/scripts/watchtower-phase-shim.mjs +171 -0
  92. package/templates/scripts/watchtower-queue.mjs +484 -1
  93. package/templates/scripts/watchtower-ring1.mjs +533 -53
  94. package/templates/scripts/watchtower-ring2.mjs +787 -48
  95. package/templates/scripts/watchtower-ring3-close.mjs +1357 -99
  96. package/templates/scripts/watchtower-ring4-runner.sh +85 -0
  97. package/templates/scripts/watchtower-ring4.mjs +753 -0
  98. package/templates/scripts/watchtower-routines.mjs +358 -0
  99. package/templates/scripts/watchtower-snapshot.mjs +452 -0
  100. package/templates/scripts/watchtower-status.sh +1 -1
  101. package/templates/scripts/watchtower-sync.mjs +393 -0
  102. package/templates/skills/audit/SKILL.md +5 -1
  103. package/templates/skills/briefing/SKILL.md +584 -236
  104. package/templates/skills/cabinet-anthropic-insider/SKILL.md +14 -6
  105. package/templates/skills/cabinet-historian/SKILL.md +14 -11
  106. package/templates/skills/cabinet-process-therapist/SKILL.md +28 -0
  107. package/templates/skills/cabinet-security/SKILL.md +11 -0
  108. package/templates/skills/cabinet-system-advocate/SKILL.md +22 -21
  109. package/templates/skills/cabinet-user-advocate/SKILL.md +13 -7
  110. package/templates/skills/catch-up/SKILL.md +113 -0
  111. package/templates/skills/cc-publish/SKILL.md +148 -25
  112. package/templates/skills/cc-remember/SKILL.md +45 -0
  113. package/templates/skills/close/SKILL.md +107 -0
  114. package/templates/skills/collab-client/SKILL.md +22 -5
  115. package/templates/skills/collab-consultant/SKILL.md +110 -2
  116. package/templates/skills/debrief/SKILL.md +148 -16
  117. package/templates/skills/debrief-classic/SKILL.md +696 -0
  118. package/templates/skills/debrief-classic/calibration.md +44 -0
  119. package/templates/skills/debrief-classic/phases/audit-pattern-capture.md +78 -0
  120. package/templates/skills/debrief-classic/phases/auto-maintenance.md +48 -0
  121. package/templates/skills/debrief-classic/phases/checklist-feedback.md +123 -0
  122. package/templates/skills/debrief-classic/phases/close-work.md +163 -0
  123. package/templates/skills/debrief-classic/phases/health-checks.md +54 -0
  124. package/templates/skills/debrief-classic/phases/inventory.md +40 -0
  125. package/templates/skills/debrief-classic/phases/loose-ends.md +52 -0
  126. package/templates/skills/debrief-classic/phases/methodology-capture.md +223 -0
  127. package/templates/skills/debrief-classic/phases/qa-handoff-sweep.md +78 -0
  128. package/templates/skills/debrief-classic/phases/record-lessons.md +177 -0
  129. package/templates/skills/debrief-classic/phases/report.md +59 -0
  130. package/templates/skills/debrief-classic/phases/update-state.md +48 -0
  131. package/templates/skills/debrief-classic/phases/upstream-feedback.md +185 -0
  132. package/templates/skills/debrief-classic/phases/verify-coverage.md +101 -0
  133. package/templates/skills/execute/SKILL.md +77 -6
  134. package/templates/skills/execute/phases/commit-and-deploy.md +8 -0
  135. package/templates/skills/execute-group/SKILL.md +23 -26
  136. package/templates/skills/generate-plan-groups/SKILL.md +20 -0
  137. package/templates/skills/inbox/SKILL.md +124 -7
  138. package/templates/skills/memory/SKILL.md +22 -6
  139. package/templates/skills/orient/SKILL.md +140 -52
  140. package/templates/skills/orient-classic/SKILL.md +770 -0
  141. package/templates/skills/orient-classic/phases/auto-maintenance.md +52 -0
  142. package/templates/skills/orient-classic/phases/briefing.md +53 -0
  143. package/templates/skills/orient-classic/phases/cabinet.md +46 -0
  144. package/templates/skills/orient-classic/phases/checklist-status.md +54 -0
  145. package/templates/skills/orient-classic/phases/context.md +88 -0
  146. package/templates/skills/orient-classic/phases/data-sync.md +35 -0
  147. package/templates/skills/orient-classic/phases/deferred-check.md +55 -0
  148. package/templates/skills/orient-classic/phases/dx-captures.md +53 -0
  149. package/templates/skills/orient-classic/phases/health-checks.md +50 -0
  150. package/templates/skills/orient-classic/phases/verify-backfill.md +109 -0
  151. package/templates/skills/orient-classic/phases/work-scan.md +69 -0
  152. package/templates/skills/plan/SKILL.md +8 -0
  153. package/templates/skills/qa-drain/SKILL.md +157 -0
  154. package/templates/skills/qa-handoff/SKILL.md +287 -46
  155. package/templates/skills/session-handoff/SKILL.md +175 -6
  156. package/templates/skills/setup-accounts/SKILL.md +38 -16
  157. package/templates/skills/threads/SKILL.md +4 -0
  158. package/templates/skills/triage-audit/SKILL.md +6 -0
  159. package/templates/skills/validate/phases/validators.md +41 -0
  160. package/templates/skills/watchtower/SKILL.md +199 -2
  161. package/templates/verify-runtime/CONVENTIONS.md +9 -0
  162. package/templates/verify-runtime/README.md +37 -0
  163. package/templates/watchtower/config.json.template +10 -2
  164. package/templates/watchtower/queue/items/item.json.schema +1 -1
  165. package/templates/workflows/execute-group-complete.js +10 -1
@@ -14,7 +14,15 @@
14
14
  // 2e: Quality pattern capture — recurring patterns from any session
15
15
  // 2f: Methodology capture — detect new skills/conventions
16
16
  // 2g: Upstream friction — CC friction detection
17
- // 2h: Feedback outbox flushdeliver pending feedback items
17
+ // 2h: (removed 2026-06-12, act:6c3a4763feedback delivery is Ring 1's
18
+ // flushFeedbackOutbox in watchtower-lib.mjs; see tombstone below)
19
+ // 2m: Session advisor pass — re-homed standing advisors, transcript-fed
20
+ // 2n: Raised-but-unhandled lens — loose ends (promises/side-issues/
21
+ // open-questions) neither done nor filed → inbox (act:4ff2cfb3)
22
+ // 2o: Skill-candidate lens — repeated manual procedure → "make a skill?"
23
+ // inbox item (act:4ff2cfb3)
24
+ // 2p: Checklist-catch detection — a surfaced check that caught a real bug
25
+ // → checklist-stats.json (act:4ff2cfb3)
18
26
  // 2i: Session auto-naming — generate descriptive session name
19
27
  // 2j: Consumer hooks — ring3-close-post hooks
20
28
  // 2k: Signal Ring 2 — write fast-trigger lock
@@ -24,18 +32,26 @@
24
32
 
25
33
  import {
26
34
  readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync,
27
- renameSync, statSync,
35
+ renameSync, statSync, realpathSync,
28
36
  } from 'fs';
29
37
  import { join, basename, dirname } from 'path';
30
38
  import { execSync } from 'child_process';
31
39
  import { createRequire } from 'module';
32
40
  import { homedir } from 'os';
41
+ import { pathToFileURL } from 'url';
33
42
  import {
34
43
  atomicWrite, loadConfig, slugify,
35
44
  log as _log, logError as _logError,
36
45
  getWatchtowerDir, createItem, listPending, loadBetterSqlite3,
37
- updateThreadFile, currentCursor, resolveProjectIdentity,
46
+ updateThreadFile, currentCursor, resolveProjectIdentity, VERIFY_UI_PATHS,
47
+ projectThreadCursorLines, authoredClaudeDirs, claudeChurnIsDisposable,
48
+ buildLastSessionBlock, upsertLastSessionSection, recordSuppression,
49
+ recentSlice,
38
50
  } from './watchtower-lib.mjs';
51
+ // Direct queue import (precedent: ring2 imports expireItem this way) —
52
+ // watchtower-lib deliberately not extended for this (lane separation).
53
+ import { listItems } from './watchtower-queue.mjs';
54
+ import { runRoutinePass } from './watchtower-routines.mjs';
39
55
 
40
56
  const require = createRequire(import.meta.url);
41
57
 
@@ -45,6 +61,55 @@ const CLAUDE_HOME = join(homedir(), '.claude');
45
61
  const CONSUMER_HOOK_TIMEOUT_MS = 120_000;
46
62
  const MODEL = 'claude-sonnet-4-6';
47
63
 
64
+ // --- Transcript-budget constants (M2 recall fix, act:edd79e15) ---
65
+ // The single-call input budget for the knowledge-critical passes (extraction
66
+ // 2d, advisor 2m, lenses 2n/2o/2p). preprocessTranscript caps the compressed
67
+ // transcript at ~100K tokens (~400K chars); feeding the most-recent
68
+ // SINGLE_CALL_TRANSCRIPT_BUDGET chars through recentSlice covers essentially
69
+ // every real session in one sonnet call (200K-token context). The old
70
+ // `slice(0, 50000)` read the OLDEST ~12.5K tokens and dropped late-session
71
+ // lessons — the M2 bug. Sessions whose compressed transcript exceeds this
72
+ // budget are the only ones Phase B's bounded chunk-and-merge (act:edd79e15
73
+ // pt2) handles; preprocessing's pre-truncation means that is the largest
74
+ // untruncated sessions only.
75
+ const SINGLE_CALL_TRANSCRIPT_BUDGET = 300_000;
76
+ // Completion detection (Phase 2c) stays deliberately recency-biased: a missed
77
+ // completion is recoverable next session, a missed lesson is not. It routes
78
+ // through recentSlice too — the recency bias is now an explicit CHOICE, not
79
+ // an accidental front-slice.
80
+ const COMPLETION_TRANSCRIPT_BUDGET = 50_000;
81
+ // M2 Phase B (act:edd79e15 pt2): when a compressed transcript exceeds the
82
+ // single-call budget, extraction chunks it into OVERLAPPING windows so a lesson
83
+ // straddling a boundary survives in at least one chunk. Bounded fallback only —
84
+ // preprocessTranscript already caps `compressed` at ~100K tokens (~400K chars),
85
+ // so with a 300K budget this fires only for the largest untruncated sessions
86
+ // (2 windows); MAX_EXTRACTION_CHUNKS is a defensive cap (a future preprocessing
87
+ // change can't make this unbounded). When the cap WOULD truncate, the most-
88
+ // recent windows are kept and a notice is logged (no silent truncation). Note:
89
+ // a >100K-token raw session already lost its FRONT to preprocessing before
90
+ // chunking — "full transcript" is false there; the cap is honest about it.
91
+ const PHASE_B_OVERLAP = 30_000; // chars carried between adjacent windows
92
+ const MAX_EXTRACTION_CHUNKS = 4;
93
+
94
+ // --- Dedup tuning constants (Phase 2c/2d noise reduction) ---
95
+ // How far back resolved/dismissed inbox items count as a dedup corpus.
96
+ // Older dismissals no longer suppress — re-surfacing once a quarter is
97
+ // acceptable; the friction loop this kills is week-scale re-filing.
98
+ const RESOLUTION_CORPUS_DAYS = 90;
99
+ // Meaningful-token overlap required to suppress an extraction against ANY
100
+ // corpus (M1a parity, act:f8e7bd0a). One threshold for all four passes —
101
+ // memory, thread-cursor, pending, resolved, dismissed — so no pass can drift
102
+ // into being the weakest link. The dismissed pass used to suppress on 2-token
103
+ // overlap (boundary-man's #2 over-suppression risk); parity raised it to 3.
104
+ // "Meaningful" = post-stopword (the type-word prefix every title carries does
105
+ // not count). The short-title floor in isDuplicate guarantees a candidate has
106
+ // >=3 meaningful tokens before any pass can fire.
107
+ const OVERLAP_THRESHOLD = 3;
108
+ // A completion-review item resolved/dismissed within this window suppresses
109
+ // re-filing for the same fid (a dismissed "is this done?" must not re-file
110
+ // next session while the action is still open).
111
+ const COMPLETION_REVIEW_DEDUP_DAYS = 14;
112
+
48
113
  // ---------------------------------------------------------------------------
49
114
  // CLI argument parsing
50
115
  // ---------------------------------------------------------------------------
@@ -128,16 +193,20 @@ function worktreeCheck(cwd, project) {
128
193
  const aheadLog = safeExec(`git log --oneline ${mergeBase}..HEAD`, { cwd });
129
194
  const aheadCount = aheadLog ? aheadLog.split('\n').filter(l => l.trim()).length : 0;
130
195
 
131
- // Exclude CC/mux session artifacts (.claude/, .mcp.json) and
196
+ // Exclude CC/mux session artifacts (.claude/ infra, .mcp.json) and
132
197
  // node_modules — untracked in every mux worktree; counting it
133
198
  // produced false "unmerged work" alarms for fully-merged branches.
199
+ // Authored .claude/ subtrees (plans, methodology, rules, …) ARE real work
200
+ // and are re-included per the canonical exclusion contract (act:e91fdfcf,
201
+ // claudeChurnIsDisposable in watchtower-lib).
134
202
  // safeExec trims output, which can shift porcelain column offsets —
135
203
  // match artifact patterns anywhere in the line instead
136
204
  const uncommitted = safeExec('git status --porcelain', { cwd });
205
+ const authoredDirs = authoredClaudeDirs(cwd, safeExec);
137
206
  const uncommittedCount = uncommitted
138
207
  ? uncommitted.split('\n').filter(l => {
139
208
  if (!l.trim()) return false;
140
- if (/\s\.claude$/.test(l) || /\s\.claude\//.test(l)) return false;
209
+ if (claudeChurnIsDisposable(l, authoredDirs)) return false;
141
210
  if (/\s\.mcp\.json$/.test(l)) return false;
142
211
  if (/\snode_modules$/.test(l) || /\snode_modules\//.test(l)) return false;
143
212
  return true;
@@ -341,6 +410,11 @@ async function sessionSummary(compressed, projectSlug, sessionId) {
341
410
  const systemPrompt = `You are a session summarizer. Given a Claude Code session transcript, produce exactly 3-5 bullet points summarizing what was accomplished. Be specific about file changes, decisions made, and problems solved. Output ONLY the bullet points, one per line, starting with "- ".`;
342
411
 
343
412
  const response = await claudeCall(systemPrompt, compressed);
413
+ // The COMPLETE model bullet set — the single source for BOTH the per-session
414
+ // record and the inline "## Last Session" block. Never sliced or truncated
415
+ // for the inline section (act:ac119994): the inline block was historically a
416
+ // lossy subset of the per-session file, so both now derive from this one
417
+ // string via buildLastSessionBlock.
344
418
  const bullets = response.trim();
345
419
 
346
420
  // Write per-session file to state/projects/<slug>/sessions/<date>-<session-id>.md
@@ -352,23 +426,22 @@ async function sessionSummary(compressed, projectSlug, sessionId) {
352
426
  const content = `# Session ${sessionId}\n\nDate: ${new Date().toISOString()}\n\n${bullets}\n`;
353
427
  atomicWrite(sessionFile, content);
354
428
 
355
- // Also update the project-level state file with the latest session pointer
429
+ // Update the project-level state file's "## Last Session" with the SAME
430
+ // complete bullet set. Written UNCONDITIONALLY — the old `existsSync` gate
431
+ // silently dropped the inline section whenever the project-state file didn't
432
+ // exist yet (a fresh project, or before Ring 1's first rebuild), leaving the
433
+ // per-session file current while the inline block stayed empty/stale and
434
+ // readers rendered a truncated summary (act:ac119994). buildLastSessionBlock
435
+ // is the single source of the block format; upsertLastSessionSection reuses
436
+ // the line-anchored splice preserveRing3LastSession reads with, and the
437
+ // attribution line it emits is the ownership marker Ring 1's rebuild keys on
438
+ // (preserveRing3LastSession carries this section forward verbatim).
356
439
  const projectStatePath = join(WATCHTOWER_DIR, 'state', 'projects', `${projectSlug}.md`);
357
- if (existsSync(projectStatePath)) {
358
- let stateContent = readFileSync(projectStatePath, 'utf8');
359
- const sessionHeader = '## Last Session';
360
- const headerIdx = stateContent.indexOf(sessionHeader);
361
- const replacement = `${sessionHeader}\n_${date} (${sessionId})_\n${bullets}\n`;
362
-
363
- if (headerIdx >= 0) {
364
- const afterHeader = stateContent.indexOf('\n## ', headerIdx + sessionHeader.length);
365
- const endIdx = afterHeader > 0 ? afterHeader : stateContent.length;
366
- stateContent = stateContent.slice(0, headerIdx) + replacement + stateContent.slice(endIdx);
367
- } else {
368
- stateContent = stateContent.trimEnd() + `\n\n${replacement}`;
369
- }
370
- atomicWrite(projectStatePath, stateContent);
371
- }
440
+ const existingState = existsSync(projectStatePath)
441
+ ? readFileSync(projectStatePath, 'utf8')
442
+ : '';
443
+ const block = buildLastSessionBlock({ date, sessionId, bullets });
444
+ atomicWrite(projectStatePath, upsertLastSessionSection(existingState, block));
372
445
 
373
446
  log(`Phase 2b: Summary written to ${projectSlug}/sessions/${date}-${sessionId}.md`);
374
447
  return bullets;
@@ -563,7 +636,7 @@ async function workItemClosure(compressed, project, threadIds = []) {
563
636
  Output JSON array: [{"fid":"act:XXXXXXXX","confidence":"high|medium|low|none","evidence":"brief reason"}]
564
637
  Output ONLY the JSON array, no other text.`;
565
638
 
566
- const userMessage = `Open actions:\n${actionList}\n\nSession transcript:\n${compressed.slice(0, 50000)}`;
639
+ const userMessage = `Open actions:\n${actionList}\n\nSession transcript:\n${recentSlice(compressed, COMPLETION_TRANSCRIPT_BUDGET)}`;
567
640
 
568
641
  let evaluations = [];
569
642
  try {
@@ -580,10 +653,49 @@ Output ONLY the JSON array, no other text.`;
580
653
  }
581
654
 
582
655
  let queued = 0;
656
+ let skipped = 0;
657
+
658
+ // Emit guards (act:ec508dbe): one prepared status re-check statement,
659
+ // reused across evaluations, and a per-fid dedup corpus built once from
660
+ // pending ∪ recently-closed completion-review items. Both setups FAIL OPEN
661
+ // (distinct logError, empty guard) — never wholesale suppression.
662
+ let statusStmt = null;
663
+ try {
664
+ statusStmt = db.prepare(
665
+ 'SELECT status FROM actions WHERE fid = ? AND deleted_at IS NULL');
666
+ } catch (e) {
667
+ logError(`Phase 2c: could not prepare emit-time status re-check (${e.message}) — failing open`);
668
+ }
669
+ let existingCompletionItems = [];
670
+ try {
671
+ const since = new Date(
672
+ Date.now() - COMPLETION_REVIEW_DEDUP_DAYS * 24 * 60 * 60 * 1000).toISOString();
673
+ existingCompletionItems = [
674
+ ...listPending({ project: project.name, category: 'completion-review' }),
675
+ ...listItems({
676
+ project: project.name,
677
+ category: 'completion-review',
678
+ statuses: ['resolved', 'dismissed'],
679
+ since,
680
+ }),
681
+ ];
682
+ } catch (e) {
683
+ logError(`Phase 2c: could not load existing completion-review items (${e.message}) — failing open`);
684
+ }
583
685
 
584
686
  for (const evalItem of evaluations) {
585
687
  if (!evalItem.fid || evalItem.confidence === 'none') continue;
586
688
 
689
+ const guard = completionReviewEmitGuard(evalItem.fid, {
690
+ statusStmt,
691
+ existingItems: existingCompletionItems,
692
+ });
693
+ if (!guard.emit) {
694
+ skipped++;
695
+ log(`Phase 2c: skip ${evalItem.fid} — ${guard.reason}`);
696
+ continue;
697
+ }
698
+
587
699
  const urgencyMap = { high: 'urgent', medium: 'normal', low: 'low' };
588
700
  try {
589
701
  createItem({
@@ -620,7 +732,7 @@ Output ONLY the JSON array, no other text.`;
620
732
  }
621
733
 
622
734
  db.close();
623
- log(`Phase 2c: ${queued} completion candidates queued for review`);
735
+ log(`Phase 2c: ${queued} completion candidates queued for review${skipped ? ` (${skipped} skipped by emit guards)` : ''}`);
624
736
  return { closed: 0, queued };
625
737
  }
626
738
 
@@ -628,56 +740,402 @@ Output ONLY the JSON array, no other text.`;
628
740
  // Dedup helpers — check memory index and pending inbox before filing
629
741
  // ---------------------------------------------------------------------------
630
742
 
631
- function loadMemoryIndex(projectPath) {
743
+ // parseMemoryTitles — extract the dedup corpus from MEMORY.md content.
744
+ //
745
+ // The M1a fix (act:f8e7bd0a). The old `loadMemoryIndex` returned each whole
746
+ // `- ` index line lowercased, and isDuplicate ran substring containment over
747
+ // it — so the median-133-char (max 755) em-dash DESCRIPTION TAIL on every line
748
+ // was the suppression sponge: a novel lesson sharing 3 generic words with some
749
+ // entry's prose tail got killed. The fix is to match only the TITLE segment.
750
+ //
751
+ // Per `- ` line: pull every `[Title](target)` markdown link (multi-link lines
752
+ // are common — "2026-06-16 sessions: [A](a.md), [B](b.md)" — take ALL, not the
753
+ // first). Lines with NO link are scaffolding, not entries — topic-file headers
754
+ // (`- **decisions.md** (56), ...`) and region/glob pointers (`- region
755
+ // \`session_summary_*.md\` → ...`) — and are DROPPED, never prose-matched
756
+ // (fail-direction: an unparsed entry is invisible to dedup ⇒ re-proposal
757
+ // noise, the SAFE direction, never an over-match).
758
+ function parseMemoryTitles(content) {
759
+ const titles = [];
760
+ for (const raw of content.split('\n')) {
761
+ const line = raw.trim();
762
+ if (!line.startsWith('- ')) continue;
763
+ const body = line.slice(2).trim();
764
+ // Region/glob pointer line — a backtick-glob standing for a class of
765
+ // files, not an entry. Explicitly excluded (it carries no link anyway).
766
+ if (/^region\b/i.test(body)) continue;
767
+ // Capture title AND target; only a link to a memory FILE (`.md`) counts as
768
+ // an entry. An inline `[text](https://…)` link inside a description tail is
769
+ // NOT an entry — accepting it would re-inject the tail-as-suppression-
770
+ // sponge bug M1a removes (boundary-man: latent until a tail adds a link).
771
+ const linkRe = /\[([^\]]+)\]\(([^)]*)\)/g;
772
+ let m;
773
+ while ((m = linkRe.exec(body)) !== null) {
774
+ if (!/\.md$/i.test((m[2] || '').trim())) continue;
775
+ const t = m[1].trim().toLowerCase();
776
+ if (t) titles.push(t);
777
+ }
778
+ // No memory-file link ⇒ header/bare scaffolding ⇒ dropped (never prose-
779
+ // fallback; an unparsed entry is invisible to dedup, the SAFE direction).
780
+ }
781
+ return titles;
782
+ }
783
+
784
+ function loadMemoryTitles(projectPath) {
632
785
  const encoded = projectPath.replace(/\//g, '-');
633
786
  const memDir = join(homedir(), '.claude', 'projects', encoded, 'memory');
634
787
  const indexPath = join(memDir, 'MEMORY.md');
635
788
  if (!existsSync(indexPath)) return [];
636
789
  try {
637
- const content = readFileSync(indexPath, 'utf8');
638
- return content.split('\n')
639
- .filter(line => line.startsWith('- '))
640
- .map(line => line.toLowerCase());
790
+ return parseMemoryTitles(readFileSync(indexPath, 'utf8'));
641
791
  } catch { return []; }
642
792
  }
643
793
 
644
794
  function tokenize(text) {
795
+ // Coerce non-strings to no-tokens. This is the single choke point for every
796
+ // token operation — a null/number corpus entry (a title-less inbox item is
797
+ // storable) must contribute ZERO overlap, never throw. Before this guard a
798
+ // single malformed pending title threw out of isDuplicate (outside the
799
+ // per-item try) and swallowed the WHOLE extraction batch — the exact
800
+ // invisible knowledge-drop this program exists to prevent (boundary-man).
801
+ if (typeof text !== 'string') return [];
645
802
  return text.toLowerCase()
646
803
  .replace(/[^a-z0-9 ]/g, ' ')
647
804
  .split(/\s+/)
648
805
  .filter(w => w.length > 3);
649
806
  }
650
807
 
651
- function isDuplicate(title, content, memoryLines, pendingTitles) {
652
- const titleTokens = tokenize(title);
653
- const contentTokens = tokenize(content).slice(0, 10);
808
+ // STOPWORDS tokens that donate guaranteed overlap and must NOT count toward
809
+ // suppression (M1a, applied IDENTICALLY to both sides of every overlap and
810
+ // across all passes). Two classes: (1) the CC extraction TYPE/category words —
811
+ // every title is `${type}: ${title}` (line ~995), and lens titles are prefixed
812
+ // `unhandled:`/`skill-candidate:`/`methodology:` — so the prefix is a 100%
813
+ // donor; (2) high-frequency English fillers (the tokenizer already drops
814
+ // len<=3, so only longer fillers need listing). Domain/action words are
815
+ // deliberately NOT here — they are the meaningful signal. (`tokenize` lowercases
816
+ // and strips punctuation, so `skill-candidate` arrives as `skill`+`candidate`.)
817
+ const STOPWORDS = new Set([
818
+ // type / category prefixes (every extraction title is `${type}: ...`;
819
+ // lens titles are `unhandled:`/`skill-candidate:`/`methodology:`)
820
+ 'lesson', 'lessons', 'decision', 'decisions', 'constraint', 'constraints',
821
+ 'preference', 'preferences', 'feedback', 'pattern', 'patterns',
822
+ 'methodology', 'candidate', 'skill', 'unhandled', 'advisor', 'finding',
823
+ 'session', 'sessions', 'summary', 'summaries', 'memory',
824
+ // session-advisor member-name fragments (Phase 2m titles are
825
+ // `${shortName}: ...` — system-advocate/user-advocate/anthropic-insider/
826
+ // historian; `advocate` is shared by two members, the worst donor)
827
+ 'advocate', 'historian', 'insider', 'anthropic',
828
+ // generic English fillers (len>3)
829
+ 'with', 'that', 'this', 'from', 'into', 'when', 'what', 'which', 'their',
830
+ 'there', 'these', 'those', 'then', 'than', 'they', 'them', 'have', 'will',
831
+ 'would', 'should', 'could', 'about', 'after', 'before', 'being', 'been',
832
+ 'were', 'your', 'yours', 'over', 'under', 'also', 'just', 'like', 'does',
833
+ 'must', 'only', 'every', 'some', 'more', 'most', 'much', 'such', 'very',
834
+ 'onto', 'upon', 'each', 'both', 'here', 'where', 'while', 'because',
835
+ 'against', 'through', 'still', 'into', 'them',
836
+ ]);
837
+
838
+ function meaningfulTokens(text) {
839
+ return tokenize(text).filter(t => !STOPWORDS.has(t));
840
+ }
841
+
842
+ // isDuplicate — meaningful-token overlap dedup across all corpora (M1a).
843
+ //
844
+ // FIVE title/prose corpora, all matched the SAME way — whole-token overlap of
845
+ // post-stopword "meaningful" tokens, threshold OVERLAP_THRESHOLD:
846
+ // memoryTitles — parsed MEMORY.md titles (NO description tail; the M1a fix)
847
+ // threadCursorLines — active thread-cursor prose ("what the system already
848
+ // knows you're working on"); a restatement is noise. Now
849
+ // whole-token overlap, NOT substring — leaving it on
850
+ // `line.includes` would re-introduce the exact
851
+ // over-suppression bug M1a kills. CALIBRATION NOTE: each
852
+ // cursor line is long prose (~9-19 meaningful tokens), so
853
+ // 3-token overlap is structurally looser here than over
854
+ // short titles. This is strictly tighter than the old
855
+ // substring pass, and a DELIBERATE choice the M5
856
+ // suppression-ledger canary is built to measure — if the
857
+ // ledger shows thread-cursor over-suppression, raise its
858
+ // threshold then, with data (don't pre-tune blind).
859
+ // pendingTitles / resolvedTitles / dismissedTitles — inbox-item titles.
860
+ //
861
+ // SHORT-TITLE FLOOR (boundary-man #1): a candidate whose title+content cannot
862
+ // muster >=3 meaningful tokens is NEVER suppressed — terseness must not lower
863
+ // the bar so a high-value 2-word constraint dies on one shared token. The flat
864
+ // OVERLAP_THRESHOLD (no Math.min lowering) enforces it. NOTE the floor is on
865
+ // title ∪ content[:10]: production passes `item.content`, so a terse title
866
+ // with rich content can clear the floor (and its content tokens then
867
+ // participate in overlap) — by design (the ITEM, not just the title, is what
868
+ // must be novel); the floor's hard guarantee is for genuinely contentless
869
+ // terse items.
870
+ //
871
+ // The trailing options object is the DESIGNATED GROWTH POINT for new corpora.
872
+ // Returns false, or a truthy { corpus, match } — callers log one line per
873
+ // suppression AND append a structured ledger record (recordSuppression).
874
+ function isDuplicate(title, content, memoryTitles, pendingTitles,
875
+ { resolvedTitles = [], dismissedTitles = [], threadCursorLines = [] } = {}) {
876
+ const titleTokens = meaningfulTokens(title);
877
+ const contentTokens = meaningfulTokens(content).slice(0, 10);
654
878
  const allTokens = [...new Set([...titleTokens, ...contentTokens])];
655
- if (allTokens.length === 0) return false;
879
+ // Short-title floor: never suppress an item that can't muster 3 meaningful
880
+ // tokens. (Also the empty-token guard.)
881
+ if (allTokens.length < OVERLAP_THRESHOLD) return false;
882
+
883
+ const overlap = (other) => {
884
+ const otherTokens = meaningfulTokens(other);
885
+ return allTokens.filter(t => otherTokens.includes(t)).length;
886
+ };
656
887
 
657
- // Check against memory index lines
658
- for (const line of memoryLines) {
659
- const matchCount = allTokens.filter(t => line.includes(t)).length;
660
- if (matchCount >= Math.min(3, allTokens.length)) return true;
888
+ const passes = [
889
+ ['memory', memoryTitles],
890
+ ['thread-cursor', threadCursorLines],
891
+ ['pending', pendingTitles],
892
+ ['resolved', resolvedTitles],
893
+ ['dismissed', dismissedTitles],
894
+ ];
895
+ for (const [corpus, entries] of passes) {
896
+ for (const entry of (entries || [])) {
897
+ if (overlap(entry) >= OVERLAP_THRESHOLD) {
898
+ return { corpus, match: entry };
899
+ }
900
+ }
661
901
  }
902
+ return false;
903
+ }
662
904
 
663
- // Check against already-pending inbox items
664
- for (const pending of pendingTitles) {
665
- const pendingTokens = tokenize(pending);
666
- const overlap = allTokens.filter(t => pendingTokens.includes(t)).length;
667
- if (overlap >= Math.min(3, allTokens.length)) return true;
905
+ // selectNearbyMemoryTitles the M1b prefilter (act:16904ffc). Pick the
906
+ // existing memory titles most relevant to THIS session so the extraction call
907
+ // can be shown "here is what's already saved" and self-filter dupes IN ONE
908
+ // pass (zero new model calls; embeddings stay rejected). Score each title by
909
+ // how many of its meaningful tokens appear in the (recent) transcript; take the
910
+ // top `limit`. Reuses the M1a tokenizer — this is context selection, not a
911
+ // similarity oracle (the extraction model itself is the semantic engine).
912
+ function selectNearbyMemoryTitles(memoryTitles, transcript, limit = 15) {
913
+ if (!Array.isArray(memoryTitles) || memoryTitles.length === 0) return [];
914
+ const tx = new Set(meaningfulTokens(transcript));
915
+ if (tx.size === 0) return [];
916
+ const scored = [];
917
+ for (const title of memoryTitles) {
918
+ const score = [...new Set(meaningfulTokens(title))].filter(t => tx.has(t)).length;
919
+ if (score > 0) scored.push({ title, score });
668
920
  }
921
+ scored.sort((a, b) => b.score - a.score);
922
+ return scored.slice(0, limit).map(s => s.title);
923
+ }
669
924
 
670
- return false;
925
+ // modelRescues — the M1b rescue gate (DEFAULT-KEEP). The extraction model is
926
+ // shown the nearby saved titles and asked, per proposed item, which saved title
927
+ // covers it (`covered_by`) — "none" if genuinely novel. When isDuplicate
928
+ // LEXICALLY flags an item the model AFFIRMATIVELY judged novel, the model's
929
+ // semantic read RESCUES it (file anyway). The model may ONLY rescue, NEVER
930
+ // suppress: a missing/garbage/empty covered_by is NOT an affirmative novelty
931
+ // claim, so isDuplicate's deterministic decision stands. (The historian
932
+ // landmine, act:3975348f — a silent fuzzy SUPPRESSOR was deleted once; this
933
+ // gate can only ADD recall, never remove it.)
934
+ function modelRescues(item) {
935
+ const cb = item && item.covered_by;
936
+ if (typeof cb !== 'string') return false;
937
+ return /^\s*(none|null|n\/?a|nothing|no\b)/i.test(cb.trim());
938
+ }
939
+
940
+ // chunkWithOverlap — the M2 Phase B windowing (act:edd79e15 pt2). Split `text`
941
+ // into windows of `size` chars that OVERLAP by `overlap` chars (so a lesson
942
+ // straddling a boundary appears whole in at least one window). Covers the full
943
+ // text front-to-back; if more than `maxChunks` windows are needed, the
944
+ // MOST-RECENT `maxChunks` are kept (recency — a missed late lesson is the bug
945
+ // this program fights) and `dropped` counts the front windows skipped. Returns
946
+ // { windows, capped, dropped }.
947
+ function chunkWithOverlap(text, size, overlap, maxChunks) {
948
+ if (typeof text !== 'string' || text.length === 0) return { windows: [], capped: false, dropped: 0 };
949
+ if (text.length <= size) return { windows: [text], capped: false, dropped: 0 };
950
+ const step = Math.max(1, size - overlap);
951
+ const windows = [];
952
+ for (let start = 0; start < text.length; start += step) {
953
+ windows.push(text.slice(start, start + size));
954
+ if (start + size >= text.length) break;
955
+ }
956
+ if (windows.length <= maxChunks) return { windows, capped: false, dropped: 0 };
957
+ const dropped = windows.length - maxChunks;
958
+ return { windows: windows.slice(dropped), capped: true, dropped };
959
+ }
960
+
961
+ // mergeChunkExtractions — flatten per-chunk extraction arrays and dedup the
962
+ // merge with a STRICTER-than-corpus matcher (act:edd79e15 pt2). Intra-session
963
+ // titles are fresh and specific, so the overlap windows produce the SAME lesson
964
+ // twice — merge those (normalized-equal title, or >=80% of the shorter title's
965
+ // meaningful tokens shared and >=3) but NEVER merge two genuinely distinct
966
+ // lessons that merely share a few words (don't reuse the loose 3-token corpus
967
+ // bar). covered_by and all fields ride through from the first copy kept.
968
+ function mergeChunkExtractions(perChunk) {
969
+ const norm = (s) => String(s).toLowerCase().replace(/\s+/g, ' ').trim();
970
+ const merged = [];
971
+ for (const arr of perChunk) {
972
+ for (const item of (arr || [])) {
973
+ if (!item || typeof item.title !== 'string') continue;
974
+ const itemTokens = new Set(meaningfulTokens(item.title));
975
+ const dup = merged.some(m => {
976
+ if (norm(m.title) === norm(item.title)) return true;
977
+ const mTokens = meaningfulTokens(m.title);
978
+ if (itemTokens.size === 0 || mTokens.length === 0) return false;
979
+ const shared = mTokens.filter(t => itemTokens.has(t)).length;
980
+ const smaller = Math.min(itemTokens.size, mTokens.length);
981
+ return shared >= 3 && shared >= Math.ceil(0.8 * smaller);
982
+ });
983
+ if (!dup) merged.push(item);
984
+ }
985
+ }
986
+ return merged;
987
+ }
988
+
989
+ // runExtractionCall — one model call → parsed JSON array (or [] on no-match).
990
+ // Shared by the Phase A single call and each Phase B chunk. Throws on a call or
991
+ // JSON error so the caller decides the fail-direction (Phase A returns nothing;
992
+ // Phase B isolates per chunk so one bad window doesn't lose the others).
993
+ async function runExtractionCall(callFn, systemPrompt, userMessage) {
994
+ const response = await callFn(systemPrompt, userMessage);
995
+ const jsonMatch = response.match(/\[[\s\S]*\]/);
996
+ return jsonMatch ? JSON.parse(jsonMatch[0]) : [];
997
+ }
998
+
999
+ // resolutionCorpus — titles of recently resolved/dismissed/superseded inbox
1000
+ // items for a project, split into the two suppression corpora isDuplicate
1001
+ // consumes. Recency-capped at RESOLUTION_CORPUS_DAYS to keep the corpus
1002
+ // bounded (~hundreds of titles max).
1003
+ function resolutionCorpus(projectName) {
1004
+ const since = new Date(
1005
+ Date.now() - RESOLUTION_CORPUS_DAYS * 24 * 60 * 60 * 1000).toISOString();
1006
+ const closed = listItems({
1007
+ project: projectName,
1008
+ statuses: ['resolved', 'dismissed', 'superseded'],
1009
+ since,
1010
+ });
1011
+ const dismissedTitles = closed
1012
+ .filter(i => i.status === 'dismissed' || /not.?relevant/i.test(i.resolution || ''))
1013
+ .map(i => i.title)
1014
+ .filter(Boolean);
1015
+ const dismissedSet = new Set(dismissedTitles);
1016
+ const resolvedTitles = closed
1017
+ .map(i => i.title)
1018
+ .filter(t => t && !dismissedSet.has(t));
1019
+ return { resolvedTitles, dismissedTitles };
1020
+ }
1021
+
1022
+ // threadCursorLines — prose corpus from active thread cursors. Thread cursors
1023
+ // (what / where_left_off / open_questions) are exactly "what the system
1024
+ // already knows the user is working on"; an extraction that restates them is
1025
+ // noise. Returned lines are lowercased; isDuplicate consumes them as the
1026
+ // `threadCursorLines` corpus — whole-token overlap (M1a), not substring.
1027
+ //
1028
+ // Delegates to projectThreadCursorLines in watchtower-lib.mjs — the SINGLE
1029
+ // thread-file reader (the same one Ring 2's enrichment uses). The former local
1030
+ // copy carried a slug-substring membership FALLBACK (slugify(thread).includes(
1031
+ // projectSlug)) that over-matched: a short slug like "flow" pulled in any
1032
+ // "…workflow…" thread's cursor prose, silently suppressing legitimate new
1033
+ // extractions. The shared reader matches ONLY by exact sessions[].project
1034
+ // equality (or explicit thread_ids) — that fallback is gone (act:3975348f).
1035
+ function threadCursorLines(threadsDir, projectSlug) {
1036
+ return projectThreadCursorLines(threadsDir, projectSlug);
1037
+ }
1038
+
1039
+ // buildExtractionCorpora — assemble the dedup corpora isDuplicate consumes,
1040
+ // the SAME machinery Phase 2d and Phase 2m build inline. Extracted so the
1041
+ // close lenses (Phase 2n/2o) don't fork a third copy of the corpus-building
1042
+ // idiom. Every builder fails OPEN — a corpus hiccup degrades dedup, never
1043
+ // aborts the lens (a finding re-filing once beats losing the pass). `phase`
1044
+ // labels the warning lines.
1045
+ //
1046
+ // M1a split: `memoryTitles` (parsed MEMORY.md titles, NO description tail) and
1047
+ // `threadCursorLines` (active-thread cursor prose) are now SEPARATE named
1048
+ // corpora — they used to be smuggled into one `memoryLines` array and substring
1049
+ // -matched together. They are both whole-token-matched by isDuplicate now.
1050
+ function buildExtractionCorpora(project, { phase } = {}) {
1051
+ const tag = phase || 'lens';
1052
+ const memoryTitles = loadMemoryTitles(project.path);
1053
+ let cursorLines = [];
1054
+ try {
1055
+ cursorLines = threadCursorLines(
1056
+ join(WATCHTOWER_DIR, 'state', 'threads'), project.slug);
1057
+ } catch (e) {
1058
+ logError(`${tag}: thread-cursor corpus failed (${e.message}) — continuing without it`);
1059
+ }
1060
+ let pendingTitles = [];
1061
+ try {
1062
+ pendingTitles = listPending({ project: project.name })
1063
+ .map(p => p.title).filter(t => typeof t === 'string' && t.trim());
1064
+ } catch (e) {
1065
+ logError(`${tag}: pending corpus failed (${e.message}) — continuing without it`);
1066
+ }
1067
+ let resolutionTitles = { resolvedTitles: [], dismissedTitles: [] };
1068
+ try {
1069
+ resolutionTitles = resolutionCorpus(project.name);
1070
+ } catch (e) {
1071
+ logError(`${tag}: resolution corpus failed (${e.message}) — continuing without it`);
1072
+ }
1073
+ return { memoryTitles, threadCursorLines: cursorLines, pendingTitles, resolutionTitles };
1074
+ }
1075
+
1076
+ // completionReviewEmitGuard — emit-time guards for Phase 2c (one call per
1077
+ // evaluation, immediately before createItem):
1078
+ //
1079
+ // 1. Status re-check: skip unless the action is STILL open/in-progress in
1080
+ // pib.db at emit time — kills the done-between-snapshot-and-emit and the
1081
+ // deferred classes in one check. FAIL-OPEN with a distinct logError: if
1082
+ // the SELECT throws (e.g. sqlite lock contention at session close), the
1083
+ // item emits anyway — a DB hiccup degrades to today's behavior, never to
1084
+ // silent wholesale suppression. The try/catch is the guard's own so a
1085
+ // throw can't abort the remaining evaluations.
1086
+ // 2. Per-fid dedup: skip when an existing completion-review item for the
1087
+ // same fid is in `existingItems` (the caller builds that set from
1088
+ // pending ∪ resolved/dismissed-within-COMPLETION_REVIEW_DEDUP_DAYS).
1089
+ //
1090
+ // Returns { emit: true } or { emit: false, reason }.
1091
+ function completionReviewEmitGuard(fid, { statusStmt, existingItems = [] } = {}) {
1092
+ if (statusStmt) {
1093
+ try {
1094
+ const row = statusStmt.get(fid);
1095
+ const status = row ? row.status : null;
1096
+ if (status !== 'open' && status !== 'in-progress') {
1097
+ return { emit: false, reason: `status now '${status ?? 'gone'}'` };
1098
+ }
1099
+ } catch (e) {
1100
+ // FAIL-OPEN: emit despite the failed re-check.
1101
+ logError(`Phase 2c: emit-time status re-check threw for ${fid} (${e.message}) — failing open, item will emit`);
1102
+ }
1103
+ }
1104
+ const existing = existingItems.find(
1105
+ i => i.plan_fid === fid || i.evidence?.fid === fid);
1106
+ if (existing) {
1107
+ return {
1108
+ emit: false,
1109
+ reason: `existing completion-review item ${existing.id} (${existing.status})`,
1110
+ };
1111
+ }
1112
+ return { emit: true };
671
1113
  }
672
1114
 
673
1115
  // ---------------------------------------------------------------------------
674
1116
  // Phase 2d: Knowledge extraction → inbox
675
1117
  // ---------------------------------------------------------------------------
676
1118
 
677
- async function decisionExtraction(compressed, project, sessionId, transcriptPath, threadIds = []) {
1119
+ async function decisionExtraction(compressed, project, sessionId, transcriptPath,
1120
+ threadIds = [], { callFn = claudeCall } = {}) {
678
1121
  const projectPath = project.path;
679
1122
  log('Phase 2d: Knowledge extraction');
680
1123
 
1124
+ // Dedup corpora — built FIRST (before the extraction call) so M1b can inject
1125
+ // the nearest saved titles into the prompt AND back the result with the
1126
+ // deterministic isDuplicate pass after. The SAME builder the close lenses use
1127
+ // (one corpus-building idiom). M1a: memory is title-matched (no description
1128
+ // tail), thread cursors are their own whole-token corpus; every builder fails
1129
+ // open. Queried by the resolved project NAME (the old basename query looked
1130
+ // up a phantom project, so dedup never matched and dups re-filed).
1131
+ const { memoryTitles, threadCursorLines: cursorLines, pendingTitles, resolutionTitles } =
1132
+ buildExtractionCorpora(project, { phase: 'Phase 2d' });
1133
+
1134
+ const transcript = recentSlice(compressed, SINGLE_CALL_TRANSCRIPT_BUDGET);
1135
+ // M1b prefilter: the saved titles most relevant to THIS session (scored over
1136
+ // the full compressed transcript, not just the recent slice).
1137
+ const nearbyTitles = selectNearbyMemoryTitles(memoryTitles, compressed);
1138
+
681
1139
  const systemPrompt = `You are extracting decisions, constraints, lessons, and user preferences from a Claude Code session transcript. For each item found, classify its home:
682
1140
 
683
1141
  - "memory" = a lesson, preference, or constraint worth remembering across sessions
@@ -689,6 +1147,8 @@ Only extract items that represent NEW durable knowledge — things learned or de
689
1147
 
690
1148
  Do NOT extract transient operational state: project completion status ("X has 0 open actions"), branch merge status ("branch Y was merged"), install success confirmations ("all rings working"), or other point-in-time observations that will be stale within days. These belong in state files, not the inbox.
691
1149
 
1150
+ NOVELTY: an "ALREADY SAVED TO MEMORY" list may appear at the very end of the input. Use it as novelty context: OMIT an item ONLY when a saved title clearly and substantially covers the SAME specific knowledge. DEFAULT TO INCLUDING — when in doubt whether a saved title covers it, INCLUDE the item (re-proposing a near-duplicate is cheap; losing a novel lesson is not). For every item you DO output, set "covered_by" to the exact saved title that most covers it, or "none" if no saved title covers it.
1151
+
692
1152
  For each item, assess how time-sensitive routing is. Urgency means HOW FAST THE VALUE DECAYS if not routed — it is NOT importance:
693
1153
  - "urgent" = the value evaporates within days if not routed (a trigger condition about to fire, a constraint someone will trip over THIS WEEK, a decision another active session needs right now). Apply the time-decay test: "if this sits in the inbox for a week, is most of its value gone?" If no, it is not urgent.
694
1154
  - "normal" = worth routing but the value keeps (most decisions and constraints)
@@ -696,15 +1156,41 @@ For each item, assess how time-sensitive routing is. Urgency means HOW FAST THE
696
1156
 
697
1157
  Lessons and preferences are durable knowledge — their value does not decay. They are almost NEVER urgent, no matter how important they are. An important-but-durable item is "normal".
698
1158
 
699
- Output JSON array: [{"type":"decision|constraint|lesson|preference","home":"memory|claude-md|pib-db-trigger|upstream-feedback","urgency":"urgent|normal|low","title":"short title","content":"detailed description"}]
1159
+ Output JSON array: [{"type":"decision|constraint|lesson|preference","home":"memory|claude-md|pib-db-trigger|upstream-feedback","urgency":"urgent|normal|low","title":"short title","content":"detailed description","covered_by":"exact already-saved title that covers this, or \\"none\\""}]
700
1160
  Output ONLY the JSON array, no other text. If nothing found, output [].`;
701
1161
 
1162
+ // M1b injection: show the model what's already saved so it self-filters dupes
1163
+ // in this one pass. DEFAULT-KEEP — the wording leans toward inclusion. Empty
1164
+ // nearby set ⇒ no block ⇒ the prompt degrades to blind extraction (fail-open).
1165
+ const savedBlock = nearbyTitles.length
1166
+ ? `\n\nALREADY SAVED TO MEMORY (do NOT re-propose anything one of these substantially and specifically covers; when unsure, INCLUDE the item):\n${nearbyTitles.map(t => `- ${t}`).join('\n')}`
1167
+ : '';
1168
+
702
1169
  let extractions = [];
703
1170
  try {
704
- const response = await claudeCall(systemPrompt, compressed.slice(0, 50000));
705
- const jsonMatch = response.match(/\[[\s\S]*\]/);
706
- if (jsonMatch) {
707
- extractions = JSON.parse(jsonMatch[0]);
1171
+ if (compressed.length > SINGLE_CALL_TRANSCRIPT_BUDGET) {
1172
+ // M2 Phase B: the compressed transcript exceeds one call — chunk it into
1173
+ // overlapping windows so nothing in the middle is dropped, extract each,
1174
+ // merge with a strict intra-session matcher. Per-chunk isolation: a bad
1175
+ // window logs and contributes nothing; the others still file (fail-open).
1176
+ const { windows, capped, dropped } = chunkWithOverlap(
1177
+ compressed, SINGLE_CALL_TRANSCRIPT_BUDGET, PHASE_B_OVERLAP, MAX_EXTRACTION_CHUNKS);
1178
+ log(`Phase 2d: M2-B — compressed ${compressed.length} chars > budget; chunk-and-merge over ${windows.length} overlapping window(s)`);
1179
+ if (capped) {
1180
+ log(`Phase 2d: M2-B — MAX_CHUNKS cap hit; oldest ${dropped} window(s) NOT extracted (most-recent kept)`);
1181
+ }
1182
+ const perChunk = [];
1183
+ for (const window of windows) {
1184
+ try {
1185
+ perChunk.push(await runExtractionCall(callFn, systemPrompt, `${window}${savedBlock}`));
1186
+ } catch (e) {
1187
+ logError(`Phase 2d: M2-B chunk extraction failed (${e.message}) — continuing with the other windows`);
1188
+ perChunk.push([]);
1189
+ }
1190
+ }
1191
+ extractions = mergeChunkExtractions(perChunk);
1192
+ } else {
1193
+ extractions = await runExtractionCall(callFn, systemPrompt, `${transcript}${savedBlock}`);
708
1194
  }
709
1195
  } catch (e) {
710
1196
  logError(`Phase 2d: Claude extraction failed: ${e.message}`);
@@ -715,23 +1201,39 @@ Output ONLY the JSON array, no other text. If nothing found, output [].`;
715
1201
  log('Phase 2d: No knowledge extracted');
716
1202
  return { autoWritten: 0, queued: 0 };
717
1203
  }
718
-
719
- // Load existing context for dedup query by the resolved name, the same
720
- // key the items are filed under (the old basename query looked up a
721
- // phantom project, so dedup never matched and duplicates re-filed).
722
- const memoryLines = loadMemoryIndex(projectPath);
723
- const pending = listPending({ project: project.name });
724
- const pendingTitles = pending.map(p => p.title);
1204
+ if (nearbyTitles.length) {
1205
+ log(`Phase 2d: M1b injected ${nearbyTitles.length} nearby saved title(s) for novelty context`);
1206
+ }
725
1207
 
726
1208
  let queued = 0;
727
1209
  let deduped = 0;
1210
+ let rescued = 0;
728
1211
 
729
1212
  for (const item of extractions) {
730
1213
  const fullTitle = `${item.type}: ${item.title}`;
731
1214
 
732
- if (isDuplicate(fullTitle, item.content || '', memoryLines, pendingTitles)) {
733
- deduped++;
734
- continue;
1215
+ const dup = isDuplicate(
1216
+ fullTitle, item.content || '', memoryTitles, pendingTitles,
1217
+ { ...resolutionTitles, threadCursorLines: cursorLines });
1218
+ if (dup) {
1219
+ // M1b rescue gate: a lexical flag the model AFFIRMATIVELY judged novel is
1220
+ // RESCUED (filed). The model can only rescue, never independently suppress
1221
+ // — a missing/non-"none" covered_by leaves isDuplicate's decision intact.
1222
+ if (modelRescues(item)) {
1223
+ rescued++;
1224
+ log(`Phase 2d: rescued "${fullTitle}" — ${dup.corpus} lexical match "${dup.match}" but model judged novel (covered_by: ${item.covered_by})`);
1225
+ } else {
1226
+ deduped++;
1227
+ // One line per suppression — the dismissed matcher is the loosest in
1228
+ // the system; over-suppression must be visible and tunable.
1229
+ log(`Phase 2d: suppressed "${fullTitle}" — ${dup.corpus} corpus matched "${dup.match}"`);
1230
+ recordSuppression({
1231
+ project: project.name, corpus: dup.corpus,
1232
+ suppressed_title: fullTitle, matched_against: dup.match,
1233
+ session_id: sessionId,
1234
+ });
1235
+ continue;
1236
+ }
735
1237
  }
736
1238
 
737
1239
  try {
@@ -772,6 +1274,7 @@ Output ONLY the JSON array, no other text. If nothing found, output [].`;
772
1274
  }
773
1275
 
774
1276
  if (deduped > 0) log(`Phase 2d: ${deduped} extractions skipped (already in memory or inbox)`);
1277
+ if (rescued > 0) log(`Phase 2d: ${rescued} extraction(s) rescued from a lexical match (model judged novel)`);
775
1278
  log(`Phase 2d: ${queued} extractions queued for review`);
776
1279
  return { autoWritten: 0, queued };
777
1280
  }
@@ -797,8 +1300,8 @@ async function qualityPatternCapture(compressed, projectPath) {
797
1300
  const systemPrompt = `You are analyzing a Claude Code session transcript for recurring quality patterns — issues, gaps, friction, or anti-patterns that surface during any kind of work (coding, debugging, planning, auditing, reviewing). Identify patterns worth learning from: things that keep going wrong, systematic gaps, workflow friction, or quality issues that a team member should watch for in future sessions. Output as markdown with ## headers for each pattern found. Include **Evidence:** (what you observed) and **Gap:** (what's missing or broken) for each. If no meaningful patterns, output "No recurring patterns detected."`;
798
1301
 
799
1302
  const userMessage = triageHistory
800
- ? `Session transcript:\n${compressed.slice(0, 30000)}\n\nAudit triage history:\n${triageHistory.slice(0, 10000)}`
801
- : `Session transcript:\n${compressed.slice(0, 30000)}`;
1303
+ ? `Session transcript:\n${recentSlice(compressed, 30000)}\n\nAudit triage history:\n${triageHistory.slice(0, 10000)}`
1304
+ : `Session transcript:\n${recentSlice(compressed, 30000)}`;
802
1305
 
803
1306
  try {
804
1307
  const response = await claudeCall(systemPrompt, userMessage);
@@ -839,7 +1342,7 @@ VERIFICATION REQUIREMENT: a methodology only counts if the session produced a du
839
1342
  If a new methodology was established, output JSON: {"found": true, "title": "short description", "content": "what the methodology is and how to apply it", "artifact_path": "relative/path/to/the/file"}
840
1343
  If nothing new was established, output JSON: {"found": false}
841
1344
  Output ONLY the JSON object.`,
842
- compressed.slice(0, 30000),
1345
+ recentSlice(compressed, 30000),
843
1346
  );
844
1347
 
845
1348
  const jsonMatch = response.match(/\{[\s\S]*\}/);
@@ -902,7 +1405,7 @@ If NO friction found, output exactly: []
902
1405
  Be conservative. False positives waste time. Output ONLY the JSON array.`;
903
1406
 
904
1407
  try {
905
- const response = await claudeCall(systemPrompt, compressed.slice(0, 40000));
1408
+ const response = await claudeCall(systemPrompt, recentSlice(compressed, 40000));
906
1409
  const jsonMatch = response.match(/\[[\s\S]*\]/);
907
1410
  if (!jsonMatch) {
908
1411
  log('Phase 2g: No upstream friction (parse failure)');
@@ -939,51 +1442,595 @@ Be conservative. False positives waste time. Output ONLY the JSON array.`;
939
1442
  }
940
1443
 
941
1444
  // ---------------------------------------------------------------------------
942
- // Phase 2h: Feedback outbox flush
1445
+ // Phase 2m: Session advisor pass (act:aded4fc9)
943
1446
  // ---------------------------------------------------------------------------
1447
+ //
1448
+ // The standing session advisors (historian, system-advocate, user-advocate,
1449
+ // anthropic-insider) were re-homed from /orient + /debrief standing mandates
1450
+ // to this transcript-fed close pass (plus /briefing's live panel on the
1451
+ // start side — the two halves of the operator's keep-the-whole-roster
1452
+ // ruling). Roster discovery is INDEX-DRIVEN, never hardcoded: any member
1453
+ // whose standing-mandate includes `session-close` AND declares a
1454
+ // `directives.session-close` runs here, so consumer projects can re-home or
1455
+ // extend the roster via directives-project.yaml without touching this
1456
+ // script. Findings file as `advisor-finding` inbox items; /briefing
1457
+ // re-surfaces fresh ones at the top of the owning project's chunk (a Step
1458
+ // 3b candidate class — never a permanent section). Cost control: reuses
1459
+ // this run's pinned-sonnet claudeCall — no Claude Code spawn, no extra
1460
+ // process; silence is the expected common case.
1461
+
1462
+ const ADVISOR_MAX_FINDINGS = 2; // per member per session
1463
+ const ADVISOR_TRANSCRIPT_SLICE = SINGLE_CALL_TRANSCRIPT_BUDGET; // M2: full recent transcript (was 40_000 front-slice)
1464
+ const ADVISOR_SKILL_SLICE = 8_000; // chars of member identity
1465
+
1466
+ function discoverSessionAdvisors(projectPath) {
1467
+ const indexPath = join(projectPath, '.claude', 'skills', '_index.json');
1468
+ if (!existsSync(indexPath)) {
1469
+ return { advisors: [], reason: 'no skills index' };
1470
+ }
1471
+ let index;
1472
+ try {
1473
+ index = JSON.parse(readFileSync(indexPath, 'utf8'));
1474
+ } catch (e) {
1475
+ return { advisors: [], reason: `unparseable skills index (${e.message})` };
1476
+ }
1477
+ // Index shape: a JSON object with a top-level `skills` array.
1478
+ const skills = Array.isArray(index?.skills) ? index.skills : [];
1479
+ const advisors = [];
1480
+ for (const entry of skills) {
1481
+ const mandate = entry.standingMandate;
1482
+ if (!Array.isArray(mandate) || !mandate.includes('session-close')) continue;
1483
+ const directive = entry.directives?.['session-close'];
1484
+ if (!directive || typeof directive !== 'string' || !directive.trim()) {
1485
+ // A mandate without a directive is a data error — visible, not fatal.
1486
+ logError(`Phase 2m: ${entry.name} has a session-close mandate but no directives.session-close — skipping (data error)`);
1487
+ continue;
1488
+ }
1489
+ advisors.push({ name: entry.name, path: entry.path, directive });
1490
+ }
1491
+ return { advisors, reason: null };
1492
+ }
944
1493
 
945
- async function feedbackOutboxFlush(projectPath) {
946
- log('Phase 2h: Feedback outbox flush');
1494
+ function parseAdvisorFindings(text) {
1495
+ const jsonMatch = String(text || '').match(/\[[\s\S]*\]/);
1496
+ if (!jsonMatch) return [];
1497
+ let parsed;
1498
+ try {
1499
+ parsed = JSON.parse(jsonMatch[0]);
1500
+ } catch {
1501
+ return [];
1502
+ }
1503
+ if (!Array.isArray(parsed)) return [];
1504
+ return parsed
1505
+ .filter(f => f && typeof f.title === 'string' && f.title.trim())
1506
+ .slice(0, ADVISOR_MAX_FINDINGS)
1507
+ .map(f => ({
1508
+ title: f.title.trim(),
1509
+ summary: typeof f.summary === 'string' ? f.summary : '',
1510
+ urgency: ['urgent', 'normal', 'low'].includes(f.urgency) ? f.urgency : 'normal',
1511
+ }));
1512
+ }
947
1513
 
948
- const outboxPath = join(projectPath, 'cc-feedback-outbox.json');
949
- if (!existsSync(outboxPath)) {
950
- log('Phase 2h: No feedback outbox found');
951
- return;
1514
+ // callFn is injectable for hermetic tests — production callers omit it and
1515
+ // get this run's pinned-sonnet claudeCall.
1516
+ async function advisorPass(compressed, project, sessionId, threadIds = [],
1517
+ { callFn = claudeCall } = {}) {
1518
+ log('Phase 2m: Session advisor pass');
1519
+
1520
+ const { advisors, reason } = discoverSessionAdvisors(project.path);
1521
+ if (advisors.length === 0) {
1522
+ log(`Phase 2m: No session-close advisors (${reason || 'none declared'}) — skipping`);
1523
+ return { filed: 0 };
952
1524
  }
1525
+ log(`Phase 2m: Roster — ${advisors.map(a => a.name).join(', ')}`);
953
1526
 
954
- let outbox;
1527
+ // Dedup corpora — same suppression machinery as Phase 2d; builders fail
1528
+ // open (an advisor finding re-filing once beats losing the whole pass).
1529
+ let pendingTitles = [];
955
1530
  try {
956
- outbox = JSON.parse(readFileSync(outboxPath, 'utf8'));
1531
+ pendingTitles = listPending({ project: project.name })
1532
+ .map(p => p.title).filter(t => typeof t === 'string' && t.trim());
957
1533
  } catch (e) {
958
- logError(`Phase 2h: Cannot parse outbox: ${e.message}`);
959
- return;
1534
+ logError(`Phase 2m: pending corpus failed (${e.message}) — continuing without it`);
1535
+ }
1536
+ let resolutionTitles = { resolvedTitles: [], dismissedTitles: [] };
1537
+ try {
1538
+ resolutionTitles = resolutionCorpus(project.name);
1539
+ } catch (e) {
1540
+ logError(`Phase 2m: resolution corpus failed (${e.message}) — continuing without it`);
960
1541
  }
961
1542
 
962
- if (!Array.isArray(outbox) || outbox.length === 0) {
963
- log('Phase 2h: Outbox empty');
964
- return;
1543
+ const transcriptSlice = recentSlice(compressed, ADVISOR_TRANSCRIPT_SLICE);
1544
+
1545
+ const results = await Promise.all(advisors.map(async (advisor) => {
1546
+ try {
1547
+ const skillPath = join(project.path, advisor.path);
1548
+ const skillBody = existsSync(skillPath)
1549
+ ? readFileSync(skillPath, 'utf8').slice(0, ADVISOR_SKILL_SLICE)
1550
+ : '';
1551
+ const systemPrompt = `You are the cabinet member "${advisor.name}", running your AUTOMATIC SESSION-CLOSE pass over a finished Claude Code session transcript. Your identity:
1552
+
1553
+ ${skillBody}
1554
+
1555
+ Your session-close directive: ${advisor.directive}
1556
+
1557
+ Review the transcript through that directive ONLY — do not free-range into other domains. File at most ${ADVISOR_MAX_FINDINGS} findings, and only things that genuinely meet the bar: a finding must be worth the operator's attention at their next briefing, not commentary on the session. SILENCE IS FINE and is the expected common case.
1558
+
1559
+ Output a JSON array: [{"title":"short title","summary":"what you observed and why it matters","urgency":"urgent|normal|low"}]
1560
+ If nothing meets the bar, output exactly: []
1561
+ Output ONLY the JSON array, no other text.`;
1562
+
1563
+ const response = await callFn(systemPrompt, transcriptSlice);
1564
+ return { advisor, findings: parseAdvisorFindings(response) };
1565
+ } catch (e) {
1566
+ logError(`Phase 2m: ${advisor.name} pass failed: ${e.message}`);
1567
+ return { advisor, findings: [] };
1568
+ }
1569
+ }));
1570
+
1571
+ let filed = 0;
1572
+ let suppressed = 0;
1573
+ for (const { advisor, findings } of results) {
1574
+ const shortName = advisor.name.replace(/^cabinet-/, '');
1575
+ for (const f of findings) {
1576
+ const fullTitle = `${shortName}: ${f.title}`;
1577
+ const dup = isDuplicate(fullTitle, f.summary, [], pendingTitles, resolutionTitles);
1578
+ if (dup) {
1579
+ suppressed++;
1580
+ log(`Phase 2m: suppressed "${fullTitle}" — ${dup.corpus} corpus matched "${dup.match}"`);
1581
+ recordSuppression({
1582
+ project: project.name, corpus: dup.corpus,
1583
+ suppressed_title: fullTitle, matched_against: dup.match,
1584
+ session_id: sessionId,
1585
+ });
1586
+ continue;
1587
+ }
1588
+ try {
1589
+ createItem({
1590
+ project: project.name,
1591
+ project_path: project.path,
1592
+ ...(project.unresolved ? { project_unresolved: true } : {}),
1593
+ category: 'advisor-finding',
1594
+ urgency: f.urgency,
1595
+ title: fullTitle,
1596
+ summary: f.summary,
1597
+ context_anchor: `session ${sessionId} — ${advisor.name} close pass`,
1598
+ evidence: {
1599
+ member: advisor.name,
1600
+ directive_key: 'session-close',
1601
+ session_id: sessionId,
1602
+ },
1603
+ filed_by: 'ring3-close',
1604
+ thread_ids: threadIds,
1605
+ });
1606
+ filed++;
1607
+ } catch (e) {
1608
+ logError(`Phase 2m: Failed to file finding from ${advisor.name}: ${e.message}`);
1609
+ }
1610
+ }
965
1611
  }
966
1612
 
967
- const undelivered = outbox.filter(item => !item.delivered);
968
- if (undelivered.length === 0) {
969
- log('Phase 2h: No undelivered feedback items');
970
- return;
1613
+ log(`Phase 2m: ${filed} advisor finding(s) filed${suppressed ? ` (${suppressed} suppressed as duplicates)` : ''}`);
1614
+ return { filed };
1615
+ }
1616
+
1617
+ // ---------------------------------------------------------------------------
1618
+ // Session-close extraction lenses (act:4ff2cfb3) — three additions to the
1619
+ // transcript pass, decided in the 2026-06-12 ledger walkthrough. Each is the
1620
+ // SAME shape as the existing lenses (Phase 2d / Phase 2m): one pinned-sonnet
1621
+ // call, structured-JSON output, the shared dedup corpora, a per-lens cap, and
1622
+ // one log line per suppression. callFn is injectable for hermetic tests
1623
+ // (production omits it → this run's claudeCall).
1624
+ //
1625
+ // 2n: Raised-but-unhandled — anything the session RAISED but neither did
1626
+ // nor filed (passing promises, side-issues, hanging questions) → inbox.
1627
+ // 2o: Skill-candidate — a manual procedure repeated by hand that a skill
1628
+ // would automate → inbox. The skill-discovery extension of the
1629
+ // knowledge-extraction concern (Phase 2d's sibling).
1630
+ // 2p: Checklist-catch — a surfaced change-impact check that caught a real
1631
+ // bug → recorded to checklist-stats.json (the catch-recording side of
1632
+ // /debrief's checklist-feedback phase, which dies with debrief; feeds
1633
+ // the audit pruning loop).
1634
+ // ---------------------------------------------------------------------------
1635
+
1636
+ const RAISED_UNHANDLED_MAX = 5; // loose-end items filed per session
1637
+ const SKILL_CANDIDATE_MAX = 3; // skill candidates filed per session
1638
+ const CHECKLIST_CATCH_MAX = 5; // catches recorded per session
1639
+ const LENS_TRANSCRIPT_SLICE = SINGLE_CALL_TRANSCRIPT_BUDGET; // M2: full recent transcript (was 50_000 front-slice)
1640
+
1641
+ // parseLensFindings — shared structured-output parser for the inbox lenses.
1642
+ // Extracts the first JSON array from the model text, keeps only entries with
1643
+ // a non-empty title, normalizes urgency, caps the count. `extraKeys` carries
1644
+ // through lens-specific string fields (e.g. kind, repetition) onto evidence.
1645
+ function parseLensFindings(text, { cap, extraKeys = [] } = {}) {
1646
+ const jsonMatch = String(text || '').match(/\[[\s\S]*\]/);
1647
+ if (!jsonMatch) return [];
1648
+ let parsed;
1649
+ try {
1650
+ parsed = JSON.parse(jsonMatch[0]);
1651
+ } catch {
1652
+ return [];
1653
+ }
1654
+ if (!Array.isArray(parsed)) return [];
1655
+ return parsed
1656
+ .filter(f => f && typeof f.title === 'string' && f.title.trim())
1657
+ .slice(0, cap)
1658
+ .map(f => {
1659
+ const out = {
1660
+ title: f.title.trim(),
1661
+ summary: typeof f.summary === 'string' ? f.summary : '',
1662
+ urgency: ['urgent', 'normal', 'low'].includes(f.urgency) ? f.urgency : 'normal',
1663
+ };
1664
+ for (const k of extraKeys) {
1665
+ if (typeof f[k] === 'string' && f[k].trim()) out[k] = f[k].trim();
1666
+ }
1667
+ return out;
1668
+ });
1669
+ }
1670
+
1671
+ // ---------------------------------------------------------------------------
1672
+ // Phase 2n: Raised-but-unhandled lens
1673
+ // ---------------------------------------------------------------------------
1674
+ //
1675
+ // Operator framing (2026-06-12): "anything that came up that's unhandled,"
1676
+ // NOT just other-project strays. A promise made in passing ("I'll also wire
1677
+ // X"), a side-issue noticed and set down, a question asked and never answered
1678
+ // — if the session ends with it neither done nor filed, it evaporates. This
1679
+ // lens catches it as a low-ceremony inbox item the operator can promote to an
1680
+ // action or dismiss. It runs AFTER Phase 2c/2d/2m so its pending corpus
1681
+ // already contains this session's completion candidates, extractions, and
1682
+ // advisor findings — a loose end one of those already captured is suppressed.
1683
+ async function raisedUnhandledLens(compressed, project, sessionId, transcriptPath,
1684
+ threadIds = [], { callFn = claudeCall } = {}) {
1685
+ log('Phase 2n: Raised-but-unhandled lens');
1686
+
1687
+ const systemPrompt = `You are scanning a finished Claude Code session transcript for loose ends: things that were RAISED during the session but were neither completed nor recorded anywhere before it ended. Three kinds:
1688
+
1689
+ - "promise" — something the assistant or operator said would be done ("I'll also...", "we should later...", "next we need to...") that never happened this session.
1690
+ - "side-issue" — a problem or risk noticed in passing and set aside without being fixed or filed.
1691
+ - "open-question" — a question raised that was never answered or resolved.
1692
+
1693
+ ONLY surface items that would be LOST if not captured — there is no action, no inbox item, no commit, and no note recording them. Do NOT surface:
1694
+ - work that WAS completed (that is not a loose end),
1695
+ - durable lessons/decisions/constraints (a separate lens already captures those),
1696
+ - "is this action done?" candidates (a separate lens already captures those),
1697
+ - routine next-steps that are obvious from the work itself.
1698
+
1699
+ Be conservative. SILENCE IS FINE and is the expected common case — most sessions tie off their own loose ends.
1700
+
1701
+ Output a JSON array, at most ${RAISED_UNHANDLED_MAX} items: [{"title":"short imperative title","summary":"what was raised and why it would be lost","kind":"promise|side-issue|open-question","urgency":"urgent|normal|low"}]
1702
+ Urgency is value-decay speed, not importance: "urgent" only if the loose end loses its value within days. Output ONLY the JSON array. If nothing qualifies, output exactly: [].`;
1703
+
1704
+ let findings = [];
1705
+ try {
1706
+ const response = await callFn(systemPrompt, recentSlice(compressed, LENS_TRANSCRIPT_SLICE));
1707
+ findings = parseLensFindings(response, { cap: RAISED_UNHANDLED_MAX, extraKeys: ['kind'] });
1708
+ } catch (e) {
1709
+ logError(`Phase 2n: Claude scan failed: ${e.message}`);
1710
+ return { queued: 0 };
1711
+ }
1712
+ if (findings.length === 0) {
1713
+ log('Phase 2n: No unhandled items raised');
1714
+ return { queued: 0 };
1715
+ }
1716
+
1717
+ const { memoryTitles, threadCursorLines: cursorLines, pendingTitles, resolutionTitles } =
1718
+ buildExtractionCorpora(project, { phase: 'Phase 2n' });
1719
+
1720
+ let queued = 0;
1721
+ let suppressed = 0;
1722
+ for (const f of findings) {
1723
+ const fullTitle = `unhandled: ${f.title}`;
1724
+ const dup = isDuplicate(fullTitle, f.summary, memoryTitles, pendingTitles,
1725
+ { ...resolutionTitles, threadCursorLines: cursorLines });
1726
+ if (dup) {
1727
+ suppressed++;
1728
+ log(`Phase 2n: suppressed "${fullTitle}" — ${dup.corpus} corpus matched "${dup.match}"`);
1729
+ recordSuppression({
1730
+ project: project.name, corpus: dup.corpus,
1731
+ suppressed_title: fullTitle, matched_against: dup.match,
1732
+ session_id: sessionId,
1733
+ });
1734
+ continue;
1735
+ }
1736
+ try {
1737
+ createItem({
1738
+ project: project.name,
1739
+ project_path: project.path,
1740
+ ...(project.unresolved ? { project_unresolved: true } : {}),
1741
+ category: 'raised-unhandled',
1742
+ urgency: f.urgency,
1743
+ title: fullTitle,
1744
+ summary: f.summary,
1745
+ context_anchor: `session ${sessionId}`,
1746
+ evidence: {
1747
+ kind: f.kind || 'side-issue',
1748
+ session_id: sessionId,
1749
+ },
1750
+ options: [
1751
+ { key: 'create-action', label: 'Create an action to handle it' },
1752
+ { key: 'keep', label: 'Keep as a reminder' },
1753
+ { key: 'dismiss', label: 'Dismiss (already handled or not worth it)' },
1754
+ ],
1755
+ filed_by: 'ring3-close',
1756
+ transcript_ref: { path: transcriptPath, line_range: null },
1757
+ thread_ids: threadIds,
1758
+ });
1759
+ queued++;
1760
+ } catch (e) {
1761
+ logError(`Phase 2n: Failed to queue loose end: ${e.message}`);
1762
+ }
1763
+ }
1764
+
1765
+ log(`Phase 2n: ${queued} loose end(s) queued${suppressed ? ` (${suppressed} suppressed as duplicates)` : ''}`);
1766
+ return { queued };
1767
+ }
1768
+
1769
+ // ---------------------------------------------------------------------------
1770
+ // Phase 2o: Skill-candidate lens
1771
+ // ---------------------------------------------------------------------------
1772
+ //
1773
+ // The skill-discovery extension of the knowledge-extraction concern (Phase
1774
+ // 2d's sibling): when the session shows the operator performing the SAME
1775
+ // multi-step manual procedure more than once by hand, a Claude Code skill or
1776
+ // slash-command would have automated it. Surface it as a "should this become
1777
+ // a skill?" inbox item. Same dedup discipline as every other lens.
1778
+ async function skillCandidateLens(compressed, project, sessionId, transcriptPath,
1779
+ threadIds = [], { callFn = claudeCall } = {}) {
1780
+ log('Phase 2o: Skill-candidate lens');
1781
+
1782
+ const systemPrompt = `You are scanning a finished Claude Code session transcript for REPEATED MANUAL PROCEDURES that should become a reusable Claude Code skill (slash-command). Look for a multi-step sequence the operator or assistant performed BY HAND two or more times, or an ad-hoc procedure clearly done routinely, where a skill would have automated it.
1783
+
1784
+ A real candidate has:
1785
+ - a repeated, multi-step shape (not a one-off, not a single command),
1786
+ - a clear name for what the procedure accomplishes,
1787
+ - evidence in THIS transcript that it recurred or is recurring manual toil.
1788
+
1789
+ Do NOT surface:
1790
+ - procedures already covered by an existing skill (if a skill was invoked, it is not a candidate),
1791
+ - one-time sequences with no sign of repetition,
1792
+ - generic advice ("you could write a script") with no concrete repeated procedure observed.
1793
+
1794
+ Be conservative. SILENCE IS FINE and is the expected common case.
1795
+
1796
+ Output a JSON array, at most ${SKILL_CANDIDATE_MAX} items: [{"title":"the procedure as a skill name","summary":"what the procedure does and why a skill fits","repetition":"the evidence it recurred this session","urgency":"urgent|normal|low"}]
1797
+ Skill candidates are durable — they are almost never urgent. Output ONLY the JSON array. If nothing qualifies, output exactly: [].`;
1798
+
1799
+ let findings = [];
1800
+ try {
1801
+ const response = await callFn(systemPrompt, recentSlice(compressed, LENS_TRANSCRIPT_SLICE));
1802
+ findings = parseLensFindings(response, { cap: SKILL_CANDIDATE_MAX, extraKeys: ['repetition'] });
1803
+ } catch (e) {
1804
+ logError(`Phase 2o: Claude scan failed: ${e.message}`);
1805
+ return { queued: 0 };
1806
+ }
1807
+ if (findings.length === 0) {
1808
+ log('Phase 2o: No skill candidates detected');
1809
+ return { queued: 0 };
1810
+ }
1811
+
1812
+ const { memoryTitles, threadCursorLines: cursorLines, pendingTitles, resolutionTitles } =
1813
+ buildExtractionCorpora(project, { phase: 'Phase 2o' });
1814
+
1815
+ let queued = 0;
1816
+ let suppressed = 0;
1817
+ for (const f of findings) {
1818
+ const fullTitle = `skill-candidate: ${f.title}`;
1819
+ const dup = isDuplicate(fullTitle, f.summary, memoryTitles, pendingTitles,
1820
+ { ...resolutionTitles, threadCursorLines: cursorLines });
1821
+ if (dup) {
1822
+ suppressed++;
1823
+ log(`Phase 2o: suppressed "${fullTitle}" — ${dup.corpus} corpus matched "${dup.match}"`);
1824
+ recordSuppression({
1825
+ project: project.name, corpus: dup.corpus,
1826
+ suppressed_title: fullTitle, matched_against: dup.match,
1827
+ session_id: sessionId,
1828
+ });
1829
+ continue;
1830
+ }
1831
+ try {
1832
+ createItem({
1833
+ project: project.name,
1834
+ project_path: project.path,
1835
+ ...(project.unresolved ? { project_unresolved: true } : {}),
1836
+ category: 'skill-candidate',
1837
+ urgency: f.urgency,
1838
+ title: fullTitle,
1839
+ summary: f.repetition ? `${f.summary}\n\nObserved repetition: ${f.repetition}` : f.summary,
1840
+ context_anchor: `session ${sessionId}`,
1841
+ evidence: {
1842
+ repetition: f.repetition || '',
1843
+ session_id: sessionId,
1844
+ },
1845
+ options: [
1846
+ { key: 'draft-skill', label: 'Draft a skill for it' },
1847
+ { key: 'dismiss', label: 'Dismiss (not worth a skill)' },
1848
+ ],
1849
+ filed_by: 'ring3-close',
1850
+ transcript_ref: { path: transcriptPath, line_range: null },
1851
+ thread_ids: threadIds,
1852
+ });
1853
+ queued++;
1854
+ } catch (e) {
1855
+ logError(`Phase 2o: Failed to queue skill candidate: ${e.message}`);
1856
+ }
1857
+ }
1858
+
1859
+ log(`Phase 2o: ${queued} skill candidate(s) queued${suppressed ? ` (${suppressed} suppressed as duplicates)` : ''}`);
1860
+ return { queued };
1861
+ }
1862
+
1863
+ // ---------------------------------------------------------------------------
1864
+ // Phase 2p: Checklist-catch detection
1865
+ // ---------------------------------------------------------------------------
1866
+ //
1867
+ // The catch-recording side of /debrief's checklist-feedback phase, re-homed
1868
+ // to the background close pass (the interactive sharpening side cannot move
1869
+ // to a ring — it needs operator approval — but the automatic catch tally can,
1870
+ // and must, since debrief is being retired). When the transcript shows a
1871
+ // surfaced change-impact check actually caught a real bug, append it to the
1872
+ // dimension's `catches` in checklist-stats.json. Those catches are the
1873
+ // evidence the audit checklist-pruning phase weighs to keep a dimension alive.
1874
+ //
1875
+ // Gating: silent no-op unless the project opted into the checklist
1876
+ // (qa-dimensions.yaml present). Fail-open everywhere — losing a data point is
1877
+ // fine; blocking the close pass over bookkeeping is not.
1878
+
1879
+ // extractDimensionNames — pull the top-level dimension keys out of
1880
+ // qa-dimensions.yaml WITHOUT a YAML dependency (the close pass runs in a bare
1881
+ // Node process; minimal-dependency footprint is intentional). The keys are
1882
+ // the 2-space-indented `name:` lines under the top-level `dimensions:` map.
1883
+ // Comments and deeper-nested keys (paths/severity/checks and list entries)
1884
+ // are excluded. Returns [] if the file has no parseable dimensions block.
1885
+ function extractDimensionNames(yamlText) {
1886
+ const lines = String(yamlText || '').split('\n');
1887
+ let inDimensions = false;
1888
+ const names = [];
1889
+ for (const raw of lines) {
1890
+ const line = raw.replace(/\r$/, '');
1891
+ const noComment = line.replace(/\s+#.*$/, '');
1892
+ if (!noComment.trim()) continue;
1893
+ // Top-level key (no leading indent).
1894
+ const topLevel = /^(\S[^:]*):\s*$/.exec(noComment);
1895
+ if (topLevel) {
1896
+ inDimensions = topLevel[1].trim() === 'dimensions';
1897
+ continue;
1898
+ }
1899
+ if (!inDimensions) continue;
1900
+ // A dimension name is a key indented exactly two spaces with nothing but
1901
+ // the colon after it (its paths/severity/checks nest four spaces deeper).
1902
+ const dim = /^ {2}([A-Za-z0-9][\w.-]*):\s*$/.exec(noComment);
1903
+ if (dim) names.push(dim[1]);
1904
+ }
1905
+ return names;
1906
+ }
1907
+
1908
+ // recordChecklistCatches — write the catches into checklist-stats.json per the
1909
+ // schema's write protocol (bootstrap-if-absent, move-aside-if-corrupt, atomic
1910
+ // temp+rename). Catches are append-only evidence; we dedup only same-day
1911
+ // identical entries (dimension+check+note+date) so a re-run can't double-count
1912
+ // — the schema is explicit that counts are honest, not precise, so no heavier
1913
+ // dedup. Returns the number of catches actually appended.
1914
+ function recordChecklistCatches(projectPath, catches, { date } = {}) {
1915
+ const today = date || new Date().toISOString().slice(0, 10);
1916
+ const statsPath = join(projectPath, '.claude', 'cabinet', 'checklist-stats.json');
1917
+
1918
+ let stats;
1919
+ if (existsSync(statsPath)) {
1920
+ try {
1921
+ stats = JSON.parse(readFileSync(statsPath, 'utf8'));
1922
+ } catch {
1923
+ // Unparseable — move aside (never delete), bootstrap fresh.
1924
+ try {
1925
+ renameSync(statsPath, `${statsPath}.corrupt-${today}`);
1926
+ logError(`Phase 2p: checklist-stats.json unparseable — moved aside to checklist-stats.json.corrupt-${today}`);
1927
+ } catch (e) {
1928
+ logError(`Phase 2p: checklist-stats.json unparseable and could not be moved aside (${e.message}) — skipping`);
1929
+ return 0;
1930
+ }
1931
+ stats = null;
1932
+ }
1933
+ }
1934
+ if (!stats || typeof stats !== 'object') {
1935
+ stats = { schema_version: 1, runs: 0, dimensions: {}, pruning_reviews: [] };
1936
+ }
1937
+ if (!stats.dimensions || typeof stats.dimensions !== 'object') stats.dimensions = {};
1938
+
1939
+ let appended = 0;
1940
+ for (const c of catches) {
1941
+ const dim = stats.dimensions[c.dimension]
1942
+ || (stats.dimensions[c.dimension] = { fires: 0, last_fired: null, catches: [] });
1943
+ if (!Array.isArray(dim.catches)) dim.catches = [];
1944
+ const already = dim.catches.some(
1945
+ e => e && e.date === today && e.check === c.check && e.note === c.note);
1946
+ if (already) continue;
1947
+ dim.catches.push({ date: today, check: c.check, note: c.note });
1948
+ appended++;
1949
+ }
1950
+
1951
+ if (appended === 0) return 0;
1952
+ try {
1953
+ atomicWrite(statsPath, stats);
1954
+ } catch (e) {
1955
+ logError(`Phase 2p: checklist-stats.json write failed (${e.message}) — catch evidence lost, continuing`);
1956
+ return 0;
1957
+ }
1958
+ return appended;
1959
+ }
1960
+
1961
+ async function checklistCatchLens(compressed, project, sessionId, { callFn = claudeCall, date } = {}) {
1962
+ log('Phase 2p: Checklist-catch detection');
1963
+
1964
+ const yamlPath = join(project.path, '.claude', 'cabinet', 'qa-dimensions.yaml');
1965
+ if (!existsSync(yamlPath)) {
1966
+ log('Phase 2p: No qa-dimensions.yaml (checklist not opted in), skipping');
1967
+ return { recorded: 0 };
1968
+ }
1969
+ let dimensionNames = [];
1970
+ try {
1971
+ dimensionNames = extractDimensionNames(readFileSync(yamlPath, 'utf8'));
1972
+ } catch (e) {
1973
+ logError(`Phase 2p: could not read qa-dimensions.yaml (${e.message}) — skipping`);
1974
+ return { recorded: 0 };
1975
+ }
1976
+ if (dimensionNames.length === 0) {
1977
+ logError('Phase 2p: qa-dimensions.yaml has no parseable dimensions — skipping (fail-open)');
1978
+ return { recorded: 0 };
971
1979
  }
972
1980
 
973
- let delivered = 0;
974
- for (const item of undelivered) {
975
- // Mark as delivered (actual delivery mechanism depends on transport config)
976
- item.delivered = true;
977
- item.delivered_at = new Date().toISOString();
978
- item.delivered_by = 'ring3-close';
979
- delivered++;
1981
+ const systemPrompt = `You are inspecting a finished Claude Code session transcript for CHANGE-IMPACT CHECKLIST CATCHES. The project has a change-impact checklist organized into these dimensions:
1982
+
1983
+ ${dimensionNames.map(n => `- ${n}`).join('\n')}
1984
+
1985
+ During the session, the checklist may have surfaced targeted checks (a "## Change-Impact Checklist" section, with [run] / [review] items grouped by dimension). A CATCH is when one of those SURFACED checks led to a real bug being found and fixed this session — the check did its job.
1986
+
1987
+ Report ONLY genuine catches: a surfaced check, a real problem it pointed at, and a fix. Do NOT report:
1988
+ - bugs that slipped through (the check did NOT catch them),
1989
+ - checks that were surfaced but found nothing,
1990
+ - general good practices not tied to a surfaced checklist check.
1991
+
1992
+ Each catch must name one of the dimensions listed above EXACTLY. SILENCE IS FINE and is the expected common case — most sessions record no catch.
1993
+
1994
+ Output a JSON array, at most ${CHECKLIST_CATCH_MAX} catches: [{"dimension":"exact dimension name","check":"the surfaced check text, quoted","note":"what real problem it caught"}]
1995
+ Output ONLY the JSON array. If there were no catches, output exactly: [].`;
1996
+
1997
+ let raw = [];
1998
+ try {
1999
+ const response = await callFn(systemPrompt, recentSlice(compressed, LENS_TRANSCRIPT_SLICE));
2000
+ const jsonMatch = String(response || '').match(/\[[\s\S]*\]/);
2001
+ if (jsonMatch) raw = JSON.parse(jsonMatch[0]);
2002
+ } catch (e) {
2003
+ logError(`Phase 2p: Claude scan failed: ${e.message}`);
2004
+ return { recorded: 0 };
2005
+ }
2006
+ if (!Array.isArray(raw)) return { recorded: 0 };
2007
+
2008
+ const known = new Set(dimensionNames);
2009
+ const catches = raw
2010
+ .filter(c => c && typeof c.dimension === 'string' && typeof c.check === 'string' && typeof c.note === 'string')
2011
+ .map(c => ({ dimension: c.dimension.trim(), check: c.check.trim(), note: c.note.trim() }))
2012
+ .filter(c => c.dimension && c.check && c.note && known.has(c.dimension))
2013
+ .slice(0, CHECKLIST_CATCH_MAX);
2014
+
2015
+ if (catches.length === 0) {
2016
+ log('Phase 2p: No checklist catches detected');
2017
+ return { recorded: 0 };
980
2018
  }
981
2019
 
982
- // Atomic rewrite
983
- atomicWrite(outboxPath, JSON.stringify(outbox, null, 2) + '\n');
984
- log(`Phase 2h: ${delivered} feedback item(s) marked as delivered`);
2020
+ const recorded = recordChecklistCatches(project.path, catches, { date });
2021
+ log(`Phase 2p: ${recorded} checklist catch(es) recorded to checklist-stats.json`);
2022
+ return { recorded };
985
2023
  }
986
2024
 
2025
+ // ---------------------------------------------------------------------------
2026
+ // Phase 2h (feedback outbox flush) was REMOVED (act:6c3a4763). It marked
2027
+ // items delivered without delivering them — a silent feedback-loss trap —
2028
+ // and read a project-local outbox path the real pipeline never writes.
2029
+ // Delivery is now a Ring 1 mechanical duty: flushFeedbackOutbox() in
2030
+ // watchtower-lib.mjs reads the GLOBAL ~/.claude/cc-feedback-outbox.json
2031
+ // and writes real files into the CC repo's feedback/ directory.
2032
+ // ---------------------------------------------------------------------------
2033
+
987
2034
  // ---------------------------------------------------------------------------
988
2035
  // Phase 2i: Session auto-naming
989
2036
  // ---------------------------------------------------------------------------
@@ -1160,6 +2207,90 @@ function resolveProject(cwd, config) {
1160
2207
  };
1161
2208
  }
1162
2209
 
2210
+ // ---------------------------------------------------------------------------
2211
+ // Phase 2q: verify-coverage lens — warns when the session shipped UI changes
2212
+ // with no matching .feature edit (drift: the product changed, the walkthrough
2213
+ // scenarios didn't). Diff-based (no API call); ports debrief's verify-coverage
2214
+ // phase to session close so coverage pressure survives debrief's retirement.
2215
+ // /verify update stays the executor; the lens is only the trigger. (act:1be47d42)
2216
+ // ---------------------------------------------------------------------------
2217
+
2218
+ // Pure: from the session's changed paths, is it UI-touching but scenario-silent?
2219
+ export function detectUncoveredUi(changedPaths, uiPaths = VERIFY_UI_PATHS) {
2220
+ const list = Array.isArray(changedPaths) ? changedPaths : [];
2221
+ const touchedUi = list.filter(
2222
+ (p) => typeof p === 'string' && uiPaths.some((u) => p.includes(u)));
2223
+ const touchedFeature = list.some(
2224
+ (p) => typeof p === 'string' && p.endsWith('.feature'));
2225
+ return { uncovered: touchedUi.length > 0 && !touchedFeature, uiPaths: touchedUi };
2226
+ }
2227
+
2228
+ // Best-effort: first ISO timestamp in the transcript = session start. Returns
2229
+ // null if the transcript has no parseable timestamp (the lens then no-ops).
2230
+ function sessionStartFromTranscript(transcriptPath) {
2231
+ try {
2232
+ const raw = readFileSync(transcriptPath, 'utf8');
2233
+ for (const line of raw.split('\n')) {
2234
+ const t = line.trim();
2235
+ if (!t) continue;
2236
+ let o;
2237
+ try { o = JSON.parse(t); } catch { continue; }
2238
+ const ts = o.timestamp || o.time || o.ts;
2239
+ if (typeof ts === 'string' && ts) return ts;
2240
+ }
2241
+ } catch { /* unreadable transcript — no window */ }
2242
+ return null;
2243
+ }
2244
+
2245
+ // Deps injectable for hermetic tests; production callers omit them. Runs against
2246
+ // the RESOLVED project path (a worktree resolves to its main repo), so "shipped"
2247
+ // means landed on the integration branch — un-merged worktree work isn't warned
2248
+ // on until it merges, which is the correct moment.
2249
+ export function verifyCoverageLens(project, sessionStartIso, sessionId, deps = {}) {
2250
+ const runGit = deps.runGit || ((cmd) => safeExec(cmd, { cwd: project.path }));
2251
+ const file = deps.file || createItem;
2252
+ const listPendingItems = deps.listPendingItems || listPending;
2253
+ const hasFeatures = deps.hasFeatures
2254
+ || ((p) => existsSync(join(p, 'e2e', 'features')));
2255
+
2256
+ if (!project?.path || !hasFeatures(project.path)) return { filed: 0 };
2257
+ if (!sessionStartIso) return { filed: 0 };
2258
+
2259
+ const out = runGit(
2260
+ `git log --since=${JSON.stringify(sessionStartIso)} --name-only --pretty=format: --no-renames`) || '';
2261
+ const changed = [...new Set(out.split('\n').map((s) => s.trim()).filter(Boolean))];
2262
+ const { uncovered, uiPaths } = detectUncoveredUi(changed);
2263
+ if (!uncovered) return { filed: 0 };
2264
+
2265
+ // Dedup: one coverage-warning per session.
2266
+ const dup = listPendingItems({ project: project.name, category: 'coverage-warning' })
2267
+ .some((i) => i.evidence?.session_id === sessionId);
2268
+ if (dup) return { filed: 0 };
2269
+
2270
+ const shown = uiPaths.slice(0, 3).join(', ')
2271
+ + (uiPaths.length > 3 ? `, +${uiPaths.length - 3} more` : '');
2272
+ file({
2273
+ project: project.name,
2274
+ project_path: project.path,
2275
+ filed_by: 'ring3-close',
2276
+ category: 'coverage-warning',
2277
+ urgency: 'low',
2278
+ title: 'UI shipped this session without a scenario update',
2279
+ summary: `This session changed UI (${shown}) but touched no .feature file — `
2280
+ + `drift risk: the product changed, the walkthrough scenarios didn't. Run `
2281
+ + `/verify update to propose the matching scenario edits, or accept the drift `
2282
+ + `if the change isn't user-visible.`,
2283
+ context_anchor: `git log --since session start (${sessionId})`,
2284
+ evidence: { session_id: sessionId, ui_paths: uiPaths, session_start: sessionStartIso },
2285
+ options: [
2286
+ { value: 'update', label: 'Update scenarios', description: '/verify update' },
2287
+ { value: 'accept-drift', label: 'Accept drift', description: 'Not user-visible' },
2288
+ { value: 'dismiss', label: 'Dismiss', description: 'Not worth capturing' },
2289
+ ],
2290
+ });
2291
+ return { filed: 1 };
2292
+ }
2293
+
1163
2294
  // ---------------------------------------------------------------------------
1164
2295
  // Main
1165
2296
  // ---------------------------------------------------------------------------
@@ -1294,11 +2425,69 @@ async function main() {
1294
2425
  }
1295
2426
  }
1296
2427
 
1297
- // Phase 2h: Feedback outbox flush
1298
- try {
1299
- await feedbackOutboxFlush(project.path);
1300
- } catch (e) {
1301
- logError(`Phase 2h failed: ${e.message}`);
2428
+ // Phase 2h removed feedback delivery is a Ring 1 mechanical duty
2429
+ // (flushFeedbackOutbox in watchtower-lib.mjs; act:6c3a4763).
2430
+
2431
+ // Phase 2m: Session advisor pass (feature-flagged) — the re-homed standing
2432
+ // advisors' close-side seat (act:aded4fc9). Runs after the extraction
2433
+ // phases so threadIds tag the findings; reuses this run's pinned-sonnet
2434
+ // claudeCall for cost control.
2435
+ if (config.defaults?.session_advisors !== false) {
2436
+ try {
2437
+ const result = await advisorPass(compressed, project, args.sessionId, threadIds);
2438
+ stats.advisorFindings = result.filed;
2439
+ stats.itemsFiled += result.filed;
2440
+ } catch (e) {
2441
+ logError(`Phase 2m failed: ${e.message}`);
2442
+ }
2443
+ }
2444
+
2445
+ // Phase 2n: Raised-but-unhandled lens (feature-flagged). Runs after the
2446
+ // filing phases (2c/2d/2m) so its pending corpus already holds this
2447
+ // session's completion candidates, extractions, and advisor findings — a
2448
+ // loose end one of those already captured is suppressed.
2449
+ if (config.defaults?.raised_unhandled_lens !== false) {
2450
+ try {
2451
+ const result = await raisedUnhandledLens(
2452
+ compressed, project, args.sessionId, args.transcriptPath, threadIds);
2453
+ stats.itemsFiled += result.queued;
2454
+ } catch (e) {
2455
+ logError(`Phase 2n failed: ${e.message}`);
2456
+ }
2457
+ }
2458
+
2459
+ // Phase 2o: Skill-candidate lens (feature-flagged).
2460
+ if (config.defaults?.skill_candidate_lens !== false) {
2461
+ try {
2462
+ const result = await skillCandidateLens(
2463
+ compressed, project, args.sessionId, args.transcriptPath, threadIds);
2464
+ stats.itemsFiled += result.queued;
2465
+ } catch (e) {
2466
+ logError(`Phase 2o failed: ${e.message}`);
2467
+ }
2468
+ }
2469
+
2470
+ // Phase 2p: Checklist-catch detection (feature-flagged). Records to the
2471
+ // project-local checklist-stats.json — no inbox item — and silently no-ops
2472
+ // unless the project opted into qa-dimensions.yaml.
2473
+ if (config.defaults?.checklist_catch_lens !== false) {
2474
+ try {
2475
+ await checklistCatchLens(compressed, project, args.sessionId);
2476
+ } catch (e) {
2477
+ logError(`Phase 2p failed: ${e.message}`);
2478
+ }
2479
+ }
2480
+
2481
+ // Phase 2q: verify-coverage lens (feature-flagged; per-project verify gate).
2482
+ // Diff-based — warns when the session shipped UI with no .feature edit.
2483
+ if (config.defaults?.verify_coverage !== false) {
2484
+ try {
2485
+ const r = verifyCoverageLens(
2486
+ project, sessionStartFromTranscript(args.transcriptPath), args.sessionId);
2487
+ stats.itemsFiled += r.filed;
2488
+ } catch (e) {
2489
+ logError(`Phase 2q failed: ${e.message}`);
2490
+ }
1302
2491
  }
1303
2492
 
1304
2493
  // Phase 2i: Session auto-naming (feature-flagged)
@@ -1323,6 +2512,28 @@ async function main() {
1323
2512
  logError(`Phase 2j failed: ${e.message}`);
1324
2513
  }
1325
2514
 
2515
+ // Phase 2j2: Session-close routine dispatch (feature-flagged). Declared
2516
+ // routines with a session-close trigger fire for the RESOLVED project (a
2517
+ // worktree session fires its main project's routines) and dispatch to the
2518
+ // desk's main session via the engine's single mux path. Mechanical-tick
2519
+ // triggers belong to Ring 1; only session-close events originate here.
2520
+ // Skipped for unresolved identities — a phantom project has no declared
2521
+ // routines, and firing under a basename key would split routine state.
2522
+ if (config.defaults?.routine_dispatch !== false && !project.unresolved) {
2523
+ try {
2524
+ const pass = runRoutinePass({
2525
+ config,
2526
+ event: { type: 'session-close', project: project.name },
2527
+ filedBy: 'ring3-close',
2528
+ });
2529
+ if (pass.fired.length > 0) {
2530
+ log(`Phase 2j2: fired ${pass.fired.map((f) => `${f.key} (${f.status})`).join(', ')}`);
2531
+ }
2532
+ } catch (e) {
2533
+ logError(`Phase 2j2 failed: ${e.message}`);
2534
+ }
2535
+ }
2536
+
1326
2537
  // Phase 2k: Signal Ring 2
1327
2538
  try {
1328
2539
  signalRing2();
@@ -1344,7 +2555,54 @@ async function main() {
1344
2555
  log(`Ring 3 close complete in ${duration}ms. Actions closed: ${stats.actionsClosed}, items filed: ${stats.itemsFiled}, memory written: ${stats.memoryWritten}, threads updated: ${stats.threadsUpdated || 0}`);
1345
2556
  }
1346
2557
 
1347
- main().catch(e => {
1348
- logError(`Fatal: ${e.message}`);
1349
- process.exit(1);
1350
- });
2558
+ // ---------------------------------------------------------------------------
2559
+ // Entry guard — run main() only when this file is the invoked module, so
2560
+ // tests can import the helpers without firing a transcript run. realpathSync
2561
+ // matters: node realpath-resolves the main module for import.meta.url while
2562
+ // argv[1] keeps the given path — a symlinked invocation would otherwise make
2563
+ // main() silently never run.
2564
+ // ---------------------------------------------------------------------------
2565
+
2566
+ const isMain = (() => {
2567
+ try {
2568
+ return process.argv[1]
2569
+ && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
2570
+ } catch {
2571
+ return false;
2572
+ }
2573
+ })();
2574
+
2575
+ if (isMain) {
2576
+ main().catch(e => {
2577
+ logError(`Fatal: ${e.message}`);
2578
+ process.exit(1);
2579
+ });
2580
+ }
2581
+
2582
+ // Exported for tests (ring3-dedup.test.mjs, advisor-pass.test.mjs). Runtime
2583
+ // behavior is unchanged — the session-end hook invokes this file directly,
2584
+ // which runs main() above.
2585
+ export {
2586
+ tokenize,
2587
+ isDuplicate,
2588
+ parseMemoryTitles,
2589
+ selectNearbyMemoryTitles,
2590
+ modelRescues,
2591
+ chunkWithOverlap,
2592
+ mergeChunkExtractions,
2593
+ decisionExtraction,
2594
+ resolutionCorpus,
2595
+ threadCursorLines,
2596
+ buildExtractionCorpora,
2597
+ completionReviewEmitGuard,
2598
+ discoverSessionAdvisors,
2599
+ parseAdvisorFindings,
2600
+ advisorPass,
2601
+ // Session-close extraction lenses (act:4ff2cfb3)
2602
+ parseLensFindings,
2603
+ raisedUnhandledLens,
2604
+ skillCandidateLens,
2605
+ extractDimensionNames,
2606
+ recordChecklistCatches,
2607
+ checklistCatchLens,
2608
+ };