create-claude-cabinet 0.45.0 → 0.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -10
- package/lib/cli.js +374 -91
- package/lib/copy.js +108 -6
- package/lib/db-setup.js +122 -17
- package/lib/engagement-server-setup.js +34 -9
- package/lib/metadata.js +51 -2
- package/lib/migrate-from-omega.js +13 -1
- package/lib/mux-setup.js +33 -9
- package/lib/settings-merge.js +52 -10
- package/lib/watchtower-setup.js +230 -0
- package/package.json +5 -1
- package/templates/cabinet/_cabinet-member-template.md +8 -3
- package/templates/cabinet/advisories-state-schema.md +34 -7
- package/templates/cabinet/checklist-stats-schema.md +15 -1
- package/templates/cabinet/composition-patterns.md +4 -3
- package/templates/cabinet/memory-lifecycle-contract.md +135 -0
- package/templates/cabinet/pib-db-access.md +13 -0
- package/templates/cabinet/skill-output-conventions.md +35 -1
- package/templates/cabinet/watchtower-contracts.md +503 -1
- package/templates/cabinet/worktree-invocation-contract.md +87 -0
- package/templates/engagement/OVERVIEW.md +12 -0
- package/templates/engagement/__tests__/engagement.test.mjs +177 -2
- package/templates/engagement/__tests__/invoice-doc.test.mjs +195 -0
- package/templates/engagement/engagement-preview.mjs +100 -0
- package/templates/engagement/engagement-schema.md +240 -0
- package/templates/engagement/engagement.mjs +391 -11
- package/templates/engagement/pib-db-patches/pib-db-lib.mjs +10 -1
- package/templates/engagement/pib-db-patches/pib-db-mcp-server.mjs +5 -44
- package/templates/engagement/pib-db-patches/pib-db.mjs +5 -2
- package/templates/hooks/action-completion-gate.sh +9 -3
- package/templates/hooks/memory-index-guard.sh +17 -11
- package/templates/hooks/watchtower-session-start.sh +24 -2
- package/templates/mcp/pib-db.json +1 -4
- package/templates/mux/__tests__/mux-fail-loud.fixture.sh +44 -0
- package/templates/mux/__tests__/station-liveness.fixture.sh +234 -0
- package/templates/mux/__tests__/station-liveness.test.mjs +47 -0
- package/templates/mux/bin/mux +289 -56
- package/templates/mux/config/help.txt +1 -0
- package/templates/rules/acknowledge-when-corrected.md +33 -0
- package/templates/rules/maintainability.md +11 -0
- package/templates/rules/memory-capture.md +26 -4
- package/templates/rules/plan-before-bulk-or-irreversible-actions.md +48 -0
- package/templates/rules/verify-before-asserting.md +79 -0
- package/templates/scripts/__tests__/advisor-pass.test.mjs +238 -0
- package/templates/scripts/__tests__/advisories.test.mjs +262 -0
- package/templates/scripts/__tests__/ahead-check-origin.test.mjs +355 -0
- package/templates/scripts/__tests__/batch-disposition.test.mjs +235 -0
- package/templates/scripts/__tests__/claude-churn-authored.test.mjs +90 -0
- package/templates/scripts/__tests__/cross-ring-reader.test.mjs +505 -0
- package/templates/scripts/__tests__/dx-captures-briefing.test.mjs +169 -0
- package/templates/scripts/__tests__/feedback-outbox-flush.test.mjs +232 -0
- package/templates/scripts/__tests__/hook-runner.test.mjs +247 -0
- package/templates/scripts/__tests__/narrative-corpus.test.mjs +247 -0
- package/templates/scripts/__tests__/phase-shim.test.mjs +112 -0
- package/templates/scripts/__tests__/qa-handoff-gate.test.mjs +68 -0
- package/templates/scripts/__tests__/qa-handoff-merge-state.test.mjs +162 -0
- package/templates/scripts/__tests__/resolve-cli.test.mjs +314 -0
- package/templates/scripts/__tests__/ring-state-ownership.test.mjs +228 -3
- package/templates/scripts/__tests__/ring1-content-detector.test.mjs +168 -0
- package/templates/scripts/__tests__/ring1-flagged-actions.test.mjs +135 -0
- package/templates/scripts/__tests__/ring1-script-drift.test.mjs +126 -0
- package/templates/scripts/__tests__/ring2-queue-reader.test.mjs +80 -0
- package/templates/scripts/__tests__/ring2-recall-canary.test.mjs +150 -0
- package/templates/scripts/__tests__/ring2-roster-review.test.mjs +311 -0
- package/templates/scripts/__tests__/ring2-thread-context.test.mjs +189 -0
- package/templates/scripts/__tests__/ring3-chunk-merge.test.mjs +122 -0
- package/templates/scripts/__tests__/ring3-close-lenses.test.mjs +359 -0
- package/templates/scripts/__tests__/ring3-dedup.test.mjs +467 -0
- package/templates/scripts/__tests__/ring3-memory-titles.test.mjs +187 -0
- package/templates/scripts/__tests__/ring3-novelty-rescue.test.mjs +148 -0
- package/templates/scripts/__tests__/ring3-recent-slice.test.mjs +101 -0
- package/templates/scripts/__tests__/ring4-reconcile.test.mjs +393 -0
- package/templates/scripts/__tests__/routine-dispatch.test.mjs +312 -0
- package/templates/scripts/__tests__/shared-thread-reader.test.mjs +187 -0
- package/templates/scripts/__tests__/suppression-ledger.test.mjs +148 -0
- package/templates/scripts/__tests__/verify-backfill.test.mjs +103 -0
- package/templates/scripts/__tests__/verify-coverage.test.mjs +80 -0
- package/templates/scripts/__tests__/watchtower-snapshot.test.mjs +261 -0
- package/templates/scripts/__tests__/watchtower-sync.test.mjs +311 -0
- package/templates/scripts/load-triage-history.js +5 -2
- package/templates/scripts/pib-db-mcp-server.mjs +5 -44
- package/templates/scripts/pib-db-path.mjs +61 -0
- package/templates/scripts/pib-db.mjs +5 -2
- package/templates/scripts/validate-memory.mjs +214 -16
- package/templates/scripts/watchtower-advisories.mjs +309 -0
- package/templates/scripts/watchtower-build-context.mjs +301 -18
- package/templates/scripts/watchtower-cross-ring-reader.mjs +700 -0
- package/templates/scripts/watchtower-hook-runner.mjs +422 -0
- package/templates/scripts/watchtower-lib.mjs +447 -2
- package/templates/scripts/watchtower-narrative-corpus.mjs +385 -0
- package/templates/scripts/watchtower-phase-shim.mjs +171 -0
- package/templates/scripts/watchtower-queue.mjs +484 -1
- package/templates/scripts/watchtower-ring1.mjs +533 -53
- package/templates/scripts/watchtower-ring2.mjs +787 -48
- package/templates/scripts/watchtower-ring3-close.mjs +1357 -99
- package/templates/scripts/watchtower-ring4-runner.sh +85 -0
- package/templates/scripts/watchtower-ring4.mjs +753 -0
- package/templates/scripts/watchtower-routines.mjs +358 -0
- package/templates/scripts/watchtower-snapshot.mjs +452 -0
- package/templates/scripts/watchtower-status.sh +1 -1
- package/templates/scripts/watchtower-sync.mjs +393 -0
- package/templates/skills/audit/SKILL.md +5 -1
- package/templates/skills/briefing/SKILL.md +584 -236
- package/templates/skills/cabinet-anthropic-insider/SKILL.md +14 -6
- package/templates/skills/cabinet-historian/SKILL.md +14 -11
- package/templates/skills/cabinet-process-therapist/SKILL.md +28 -0
- package/templates/skills/cabinet-security/SKILL.md +11 -0
- package/templates/skills/cabinet-system-advocate/SKILL.md +22 -21
- package/templates/skills/cabinet-user-advocate/SKILL.md +13 -7
- package/templates/skills/catch-up/SKILL.md +113 -0
- package/templates/skills/cc-publish/SKILL.md +148 -25
- package/templates/skills/cc-remember/SKILL.md +45 -0
- package/templates/skills/close/SKILL.md +107 -0
- package/templates/skills/collab-client/SKILL.md +22 -5
- package/templates/skills/collab-consultant/SKILL.md +110 -2
- package/templates/skills/debrief/SKILL.md +148 -16
- package/templates/skills/debrief-classic/SKILL.md +696 -0
- package/templates/skills/debrief-classic/calibration.md +44 -0
- package/templates/skills/debrief-classic/phases/audit-pattern-capture.md +78 -0
- package/templates/skills/debrief-classic/phases/auto-maintenance.md +48 -0
- package/templates/skills/debrief-classic/phases/checklist-feedback.md +123 -0
- package/templates/skills/debrief-classic/phases/close-work.md +163 -0
- package/templates/skills/debrief-classic/phases/health-checks.md +54 -0
- package/templates/skills/debrief-classic/phases/inventory.md +40 -0
- package/templates/skills/debrief-classic/phases/loose-ends.md +52 -0
- package/templates/skills/debrief-classic/phases/methodology-capture.md +223 -0
- package/templates/skills/debrief-classic/phases/qa-handoff-sweep.md +78 -0
- package/templates/skills/debrief-classic/phases/record-lessons.md +177 -0
- package/templates/skills/debrief-classic/phases/report.md +59 -0
- package/templates/skills/debrief-classic/phases/update-state.md +48 -0
- package/templates/skills/debrief-classic/phases/upstream-feedback.md +185 -0
- package/templates/skills/debrief-classic/phases/verify-coverage.md +101 -0
- package/templates/skills/execute/SKILL.md +77 -6
- package/templates/skills/execute/phases/commit-and-deploy.md +8 -0
- package/templates/skills/execute-group/SKILL.md +23 -26
- package/templates/skills/generate-plan-groups/SKILL.md +20 -0
- package/templates/skills/inbox/SKILL.md +124 -7
- package/templates/skills/memory/SKILL.md +22 -6
- package/templates/skills/orient/SKILL.md +140 -52
- package/templates/skills/orient-classic/SKILL.md +770 -0
- package/templates/skills/orient-classic/phases/auto-maintenance.md +52 -0
- package/templates/skills/orient-classic/phases/briefing.md +53 -0
- package/templates/skills/orient-classic/phases/cabinet.md +46 -0
- package/templates/skills/orient-classic/phases/checklist-status.md +54 -0
- package/templates/skills/orient-classic/phases/context.md +88 -0
- package/templates/skills/orient-classic/phases/data-sync.md +35 -0
- package/templates/skills/orient-classic/phases/deferred-check.md +55 -0
- package/templates/skills/orient-classic/phases/dx-captures.md +53 -0
- package/templates/skills/orient-classic/phases/health-checks.md +50 -0
- package/templates/skills/orient-classic/phases/verify-backfill.md +109 -0
- package/templates/skills/orient-classic/phases/work-scan.md +69 -0
- package/templates/skills/plan/SKILL.md +8 -0
- package/templates/skills/qa-drain/SKILL.md +157 -0
- package/templates/skills/qa-handoff/SKILL.md +287 -46
- package/templates/skills/session-handoff/SKILL.md +175 -6
- package/templates/skills/setup-accounts/SKILL.md +38 -16
- package/templates/skills/threads/SKILL.md +4 -0
- package/templates/skills/triage-audit/SKILL.md +6 -0
- package/templates/skills/validate/phases/validators.md +41 -0
- package/templates/skills/watchtower/SKILL.md +199 -2
- package/templates/verify-runtime/CONVENTIONS.md +9 -0
- package/templates/verify-runtime/README.md +37 -0
- package/templates/watchtower/config.json.template +10 -2
- package/templates/watchtower/queue/items/item.json.schema +1 -1
- package/templates/workflows/execute-group-complete.js +10 -1
|
@@ -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:
|
|
17
|
+
// 2h: (removed 2026-06-12, act:6c3a4763 — feedback 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
|
|
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 (
|
|
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
|
-
//
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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
|
-
|
|
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
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
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
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
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
|
-
|
|
720
|
-
|
|
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
|
-
|
|
733
|
-
|
|
734
|
-
|
|
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
|
|
801
|
-
: `Session transcript:\n${compressed
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
946
|
-
|
|
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
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
959
|
-
|
|
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
|
-
|
|
963
|
-
|
|
964
|
-
|
|
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
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
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
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
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
|
-
|
|
983
|
-
|
|
984
|
-
|
|
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
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
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
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
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
|
+
};
|