heyspex 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +157 -0
  2. package/bin/hs.mjs +1028 -0
  3. package/bin/postinstall.mjs +88 -0
  4. package/dev-factory/lib/fleet-readiness.mjs +274 -0
  5. package/dev-factory/lib/fleet-state.mjs +250 -0
  6. package/dev-factory/lib/sufficiency-score.mjs +441 -0
  7. package/dist/cli/hs-check.mjs +747 -0
  8. package/dist/cli/hs-claim.mjs +541 -0
  9. package/dist/cli/hs-connect.mjs +3436 -0
  10. package/dist/cli/hs-defer.mjs +8455 -0
  11. package/dist/cli/hs-env.mjs +36845 -0
  12. package/dist/cli/hs-init.mjs +1145 -0
  13. package/dist/cli/hs-install.mjs +1390 -0
  14. package/dist/cli/hs-join.mjs +38553 -0
  15. package/dist/cli/hs-vault-pull.mjs +7782 -0
  16. package/dist/cli/hs-vault.mjs +4166 -0
  17. package/dist/cli/hs-workspace.mjs +2898 -0
  18. package/dist/cli-templates/customer-vault/specs/entity-model.md +1735 -0
  19. package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +531 -0
  20. package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +48 -0
  21. package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +548 -0
  22. package/docs/mcp-client-config.md +200 -0
  23. package/package.json +124 -0
  24. package/scripts/check-mcp-freshness.mjs +379 -0
  25. package/scripts/dev-server-status.mjs +722 -0
  26. package/scripts/gateway-skills-lib.mjs +421 -0
  27. package/scripts/lib/console-url-resolver.mjs +152 -0
  28. package/scripts/lib/dev-mode.mjs +369 -0
  29. package/scripts/lib/hs-link.mjs +154 -0
  30. package/scripts/lib/hs-source-commit.mjs +207 -0
  31. package/scripts/lib/hs-source-log.mjs +366 -0
  32. package/scripts/lib/hs-source-preflight.mjs +402 -0
  33. package/scripts/lib/hs-source-push.mjs +307 -0
  34. package/scripts/lib/hs-source-secret-scan.mjs +263 -0
  35. package/scripts/lib/standalone-doctor.mjs +89 -0
  36. package/scripts/vault-id-lib.mjs +173 -0
  37. package/vendor/heyspex-mcp/alignment-gate.mjs +1347 -0
  38. package/vendor/heyspex-mcp/blocker-resolver.mjs +891 -0
  39. package/vendor/heyspex-mcp/breakdown-ladder.mjs +309 -0
  40. package/vendor/heyspex-mcp/breakdown-source.mjs +236 -0
  41. package/vendor/heyspex-mcp/breakdown-writer.mjs +524 -0
  42. package/vendor/heyspex-mcp/brief-observability.mjs +67 -0
  43. package/vendor/heyspex-mcp/canvas-inbox.mjs +130 -0
  44. package/vendor/heyspex-mcp/capture-active-rehydrate.mjs +76 -0
  45. package/vendor/heyspex-mcp/capture-buffer-patches.mjs +147 -0
  46. package/vendor/heyspex-mcp/capture-channel.mjs +577 -0
  47. package/vendor/heyspex-mcp/capture-console-client.mjs +290 -0
  48. package/vendor/heyspex-mcp/capture-focus.mjs +1366 -0
  49. package/vendor/heyspex-mcp/capture-kind-suggest.mjs +121 -0
  50. package/vendor/heyspex-mcp/capture-outbox.mjs +239 -0
  51. package/vendor/heyspex-mcp/capture-stage-gate.mjs +160 -0
  52. package/vendor/heyspex-mcp/cli.mjs +15754 -0
  53. package/vendor/heyspex-mcp/config-settings.mjs +419 -0
  54. package/vendor/heyspex-mcp/console-fetch.mjs +342 -0
  55. package/vendor/heyspex-mcp/defer-spec.mjs +527 -0
  56. package/vendor/heyspex-mcp/deferred-reclaim.mjs +284 -0
  57. package/vendor/heyspex-mcp/design-tools.mjs +449 -0
  58. package/vendor/heyspex-mcp/dev-factory-bridge.mjs +66 -0
  59. package/vendor/heyspex-mcp/diagnose-skeleton.mjs +114 -0
  60. package/vendor/heyspex-mcp/fleet-queue-tools.mjs +241 -0
  61. package/vendor/heyspex-mcp/hooks-engine.mjs +399 -0
  62. package/vendor/heyspex-mcp/login-handoff.mjs +438 -0
  63. package/vendor/heyspex-mcp/mcp-subprocess.mjs +136 -0
  64. package/vendor/heyspex-mcp/mvp-mode.mjs +101 -0
  65. package/vendor/heyspex-mcp/next-resolution.mjs +240 -0
  66. package/vendor/heyspex-mcp/next-review.mjs +548 -0
  67. package/vendor/heyspex-mcp/peer-presence.mjs +430 -0
  68. package/vendor/heyspex-mcp/plan-ground-conflicts.mjs +321 -0
  69. package/vendor/heyspex-mcp/plan-ground.mjs +2001 -0
  70. package/vendor/heyspex-mcp/plan-ingest.mjs +476 -0
  71. package/vendor/heyspex-mcp/plan-push.mjs +1826 -0
  72. package/vendor/heyspex-mcp/plan-review.mjs +1419 -0
  73. package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
  74. package/vendor/heyspex-mcp/procedure-tools.mjs +970 -0
  75. package/vendor/heyspex-mcp/promote-capture.mjs +1280 -0
  76. package/vendor/heyspex-mcp/promote-diagnose.mjs +187 -0
  77. package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +338 -0
  78. package/vendor/heyspex-mcp/promote-research.mjs +1553 -0
  79. package/vendor/heyspex-mcp/promotion-helpers.mjs +280 -0
  80. package/vendor/heyspex-mcp/read-path.mjs +138 -0
  81. package/vendor/heyspex-mcp/recipe-tools.mjs +258 -0
  82. package/vendor/heyspex-mcp/regen-tasks.mjs +326 -0
  83. package/vendor/heyspex-mcp/register-spec.mjs +417 -0
  84. package/vendor/heyspex-mcp/research-focus.mjs +476 -0
  85. package/vendor/heyspex-mcp/research-tools.mjs +109 -0
  86. package/vendor/heyspex-mcp/research-type-suggest.mjs +112 -0
  87. package/vendor/heyspex-mcp/resolution-focus.mjs +45 -0
  88. package/vendor/heyspex-mcp/review-focus.mjs +55 -0
  89. package/vendor/heyspex-mcp/review-rollup.mjs +47 -0
  90. package/vendor/heyspex-mcp/run-front.mjs +60 -0
  91. package/vendor/heyspex-mcp/scheduler.mjs +813 -0
  92. package/vendor/heyspex-mcp/seat-identity.mjs +281 -0
  93. package/vendor/heyspex-mcp/settings-tools.mjs +119 -0
  94. package/vendor/heyspex-mcp/spec-id-lib.mjs +790 -0
  95. package/vendor/heyspex-mcp/split-research.mjs +832 -0
  96. package/vendor/heyspex-mcp/submit-for-review.mjs +190 -0
  97. package/vendor/heyspex-mcp/summary-contract.mjs +162 -0
  98. package/vendor/heyspex-mcp/test-helpers.mjs +43 -0
  99. package/vendor/heyspex-mcp/validate-linkage.mjs +574 -0
  100. package/vendor/heyspex-mcp/vault-resolve.mjs +297 -0
  101. package/vendor/heyspex-mcp/vault-settings.mjs +87 -0
  102. package/vendor/heyspex-mcp/verify-reproduction.mjs +314 -0
  103. package/vendor/heyspex-mcp/why-now.mjs +64 -0
  104. package/vendor/heyspex-mcp/yaml-scalar.mjs +34 -0
@@ -0,0 +1,1419 @@
1
+ /**
2
+ * HeySpex plan-review executor — the brain of the plan V1 → quality+canonical
3
+ * check → V2 gate ([[HS-EPIC-PLRV-plan-v1-quality-canonical-check-v2-gate]]).
4
+ *
5
+ * Pure-logic module backing the `heyspex_plan_review` MCP tool mounted in
6
+ * cli.mjs. Takes a plan file (path or inline content), runs four passes, and
7
+ * returns structured findings + V2 patch hints. Also writes a marker file at
8
+ * `.heyspex/plan-reviews/<sha256-of-plan-content>.json` that the per-editor
9
+ * gates read to decide whether ExitPlanMode is allowed.
10
+ *
11
+ * Four passes:
12
+ * 1. Scope-term extraction — spec ids, wikilinks, file paths, symbols,
13
+ * content keywords (mirrors the tag-overlap heuristic in
14
+ * scoreRelevantMemories at cli.mjs).
15
+ * 2. Overlap grep — scan specs/{tasks,epics,research,captures,protocols,
16
+ * milestones,diagnoses} filtered to in-flight status only (closed /
17
+ * superseded specs are frozen records, not competitors — see
18
+ * [[closed-trackable-specs-are-historical-record-not-living-docs]]) and
19
+ * classify each hit SUPERSEDED | SUBSUMED | SIBLING | UPSTREAM | PARENT.
20
+ * 3. Code audit — resolve the literal files/symbols the plan names against
21
+ * the codebase: unresolved → BLOCKER, resolved → INFO.
22
+ * 4. Canonical + supersession — borrow the SRP test from
23
+ * [[doc-canonicalization-protocol]] and the relationship language from
24
+ * [[supersession-protocol]].
25
+ *
26
+ * Spec: [[HS-TASK-PRVT-plan-review-mcp-tool-overlap-and-audit]]
27
+ * Pattern source: vendor/heyspex-mcp/procedure-tools.mjs (the pure-executor
28
+ * shape this module mirrors).
29
+ * Run tests with: node --test vendor/heyspex-mcp/plan-review.test.mjs
30
+ */
31
+
32
+ import { createHash } from 'node:crypto';
33
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
34
+ import { join } from 'node:path';
35
+
36
+ // In-flight statuses only. Terminal-status specs (closed / superseded /
37
+ // cancelled / duplicate / archived) are frozen historical records, filtered
38
+ // out of overlap matches per the closed-spec immutability principle.
39
+ const IN_FLIGHT_STATUSES = new Set(['open', 'in_progress', 'in_review']);
40
+
41
+ // Spec buckets scanned for overlap. Mirrors loadVaultSpecsForScheduler but
42
+ // widened to the doc/research/capture surfaces a plan can collide with.
43
+ const OVERLAP_DIRS = [
44
+ 'specs/tasks',
45
+ 'specs/epics',
46
+ 'specs/milestones',
47
+ 'specs/research',
48
+ 'specs/captures',
49
+ 'specs/protocols',
50
+ 'specs/diagnoses',
51
+ // Axis-B drift: governing memory specs are not deliverables but constrain plans.
52
+ 'specs/memory/decisions',
53
+ 'specs/memory/lessons',
54
+ ];
55
+
56
+ // Memory buckets scanned only for the Axis-B design-supersession pass (Pass 6).
57
+ const MEMORY_AXIS_DIRS = ['specs/memory/decisions', 'specs/memory/lessons'];
58
+
59
+ // Code roots scanned by the code-audit pass for symbol resolution. Only those
60
+ // that exist under vaultRoot are read; missing roots are skipped silently.
61
+ // (specs/ is intentionally excluded — it holds markdown, not code; symbols
62
+ // mentioned only in prose specs must not count as "resolved" in code.)
63
+ const CODE_ROOTS = ['vendor/heyspex-mcp', 'spex-console/src', 'scripts'];
64
+ const CODE_EXTS = new Set(['.mjs', '.ts', '.tsx', '.js', '.jsx', '.cjs', '.mts', '.cts']);
65
+ const SKIP_DIRS = new Set(['node_modules', '.git', '.next', 'dist', 'build', '.turbo', 'coverage']);
66
+ const MAX_CODE_FILES = 4000; // backstop so the audit can't walk an unbounded tree
67
+
68
+ // Relationship-language cues used to classify an in-flight overlap. Borrowed
69
+ // from supersession-protocol (supersede words) and the dependency vocabulary.
70
+ const SUPERSEDE_WORDS = ['supersede', 'supersedes', 'superseded', 'replace', 'replaces', 'deprecate', 'deprecates', 'retire', 'retires', 'obsolete', 'obsoletes', 'discontinue', 'discontinues'];
71
+ const SUBSUME_WORDS = ['subsume', 'subsumes', 'absorb', 'absorbs', 'fold in', 'folds in', 'roll up', 'rolls up', 'rolls into', 'rolled into', 'merge into', 'merges into'];
72
+ const UPSTREAM_WORDS = ['depends on', 'depend on', 'depends_on', 'builds on', 'built on', 'requires', 'required by', 'prerequisite', 'upstream', 'blocked by', 'after landing', 'once .* lands', 'builds upon'];
73
+ const PARENT_WORDS = ['parent', 'under the', 'child of', 'belongs under', 'milestone', 'epic for'];
74
+
75
+ // Minimal English stopword set for content-keyword extraction. Not exhaustive —
76
+ // just enough to keep overlap scoring from being dominated by glue words.
77
+ const STOPWORDS = new Set([
78
+ 'the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'any', 'can', 'her', 'was', 'one', 'our', 'out', 'has', 'have', 'had', 'his', 'how', 'man', 'new', 'now', 'old', 'see', 'two', 'way', 'who', 'its', 'this', 'that', 'with', 'from', 'they', 'will', 'what', 'when', 'they', 'them', 'then', 'than', 'into', 'over', 'such', 'each', 'their', 'there', 'these', 'those', 'which', 'would', 'should', 'could', 'about', 'after', 'before', 'where', 'while', 'being', 'been', 'were', 'also', 'only', 'some', 'more', 'most', 'other', 'must', 'does', 'done', 'made', 'make', 'just', 'like', 'plan', 'spec', 'task', 'epic', 'work', 'use', 'used', 'using', 'via', 'per', 'add', 'adds', 'added', 'new',
79
+ ]);
80
+
81
+ /**
82
+ * Parse YAML frontmatter from a markdown string. Self-contained (does not
83
+ * import cli.mjs internals) so the executor stays independently testable.
84
+ * Returns { frontmatter, body }. Only the shallow scalars / simple lists that
85
+ * spec frontmatter uses are parsed.
86
+ */
87
+ export function parsePlanFrontmatter(content) {
88
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
89
+ if (!match) return { frontmatter: {}, body: content };
90
+ const yamlBlock = match[1];
91
+ const body = content.slice(match[0].length).trim();
92
+ const frontmatter = {};
93
+ let currentListKey = null;
94
+ for (const rawLine of yamlBlock.split(/\r?\n/)) {
95
+ const line = rawLine.replace(/\s+$/, '');
96
+ if (!line.trim()) continue;
97
+ const listItem = line.match(/^\s*-\s+(.*)$/);
98
+ if (listItem && currentListKey) {
99
+ frontmatter[currentListKey].push(stripScalar(listItem[1]));
100
+ continue;
101
+ }
102
+ const kv = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/);
103
+ if (!kv) continue;
104
+ const key = kv[1];
105
+ const value = kv[2];
106
+ if (value === '' || value === '|' || value === '>') {
107
+ // Begin a list (or a block scalar we treat as empty); next indented
108
+ // `- ` lines attach to this key.
109
+ frontmatter[key] = [];
110
+ currentListKey = key;
111
+ } else {
112
+ frontmatter[key] = stripScalar(value);
113
+ currentListKey = null;
114
+ }
115
+ }
116
+ return { frontmatter, body };
117
+ }
118
+
119
+ function stripScalar(raw) {
120
+ let v = raw.trim();
121
+ if ((v.startsWith("'") && v.endsWith("'")) || (v.startsWith('"') && v.endsWith('"'))) {
122
+ v = v.slice(1, -1);
123
+ }
124
+ return v;
125
+ }
126
+
127
+ /** Normalize a spec id / wikilink basename to its bare HS-XXX-YYYY id form. */
128
+ function toSpecId(ref) {
129
+ const m = String(ref).match(/HS-[A-Z]+-[A-Za-z0-9]{3,}/);
130
+ return m ? m[0].toUpperCase() : null;
131
+ }
132
+
133
+ /**
134
+ * Pass 1 — extract the terms a plan touches.
135
+ * spec_ids — HS-XXX-YYYY ids referenced anywhere
136
+ * wikilinks — [[basename]] targets
137
+ * files — path-like tokens ending in a known code/doc extension
138
+ * symbols — identifiers that look like function/class names
139
+ * keywords — deduped lowercase content words (stopwords removed)
140
+ */
141
+ export function extractScopeTerms(planBody) {
142
+ const text = String(planBody || '');
143
+
144
+ const specIds = new Set();
145
+ for (const m of text.matchAll(/HS-[A-Z]+-[A-Za-z0-9]{3,}/g)) {
146
+ specIds.add(m[0].toUpperCase());
147
+ }
148
+
149
+ const wikilinks = new Set();
150
+ for (const m of text.matchAll(/\[\[([^\]|]+)(?:\|[^\]]*)?\]\]/g)) {
151
+ wikilinks.add(m[1].trim());
152
+ }
153
+
154
+ const files = new Set();
155
+ for (const m of text.matchAll(/[\w./@-]+\.(?:mjs|cjs|mts|cts|ts|tsx|js|jsx|json|md|yaml|yml)\b/g)) {
156
+ // Skip bare extensions captured without a path segment.
157
+ const f = m[0];
158
+ if (f.includes('/') || f.includes('.')) files.add(f.replace(/^[`'"]+|[`'"]+$/g, ''));
159
+ }
160
+
161
+ const symbols = new Set();
162
+ // Backtick-quoted identifiers: `someFunction`, `ClassName`, `runPlanReview()`
163
+ for (const m of text.matchAll(/`([A-Za-z_$][A-Za-z0-9_$]*)\(?\)?`/g)) {
164
+ const s = m[1];
165
+ if (s.length >= 3 && /[a-z]/.test(s) && /[A-Z_]/.test(s)) symbols.add(s);
166
+ }
167
+ // Call-shaped identifiers in prose: someFunction(
168
+ for (const m of text.matchAll(/\b([a-z][A-Za-z0-9_$]*[A-Z][A-Za-z0-9_$]*|[a-z_][A-Za-z0-9_$]+)\s*\(/g)) {
169
+ const s = m[1];
170
+ if (s.length >= 4 && /[A-Z_]/.test(s) && !STOPWORDS.has(s.toLowerCase())) symbols.add(s);
171
+ }
172
+
173
+ const keywords = new Set();
174
+ for (const m of text.toLowerCase().matchAll(/[a-z][a-z0-9-]{2,}/g)) {
175
+ const w = m[0];
176
+ if (w.length < 4 || w.length > 32) continue;
177
+ if (STOPWORDS.has(w)) continue;
178
+ keywords.add(w);
179
+ }
180
+
181
+ return {
182
+ spec_ids: [...specIds],
183
+ wikilinks: [...wikilinks],
184
+ files: [...files],
185
+ symbols: [...symbols],
186
+ keywords: [...keywords],
187
+ };
188
+ }
189
+
190
+ /** Load one in-flight spec descriptor, or null if absent / terminal-status. */
191
+ function loadOverlapCandidates(vaultRoot) {
192
+ const out = [];
193
+ for (const dir of OVERLAP_DIRS) {
194
+ const full = join(vaultRoot, dir);
195
+ if (!existsSync(full)) continue;
196
+ let files;
197
+ try {
198
+ files = readdirSync(full, { withFileTypes: true });
199
+ } catch {
200
+ continue;
201
+ }
202
+ for (const entry of files) {
203
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
204
+ if (entry.name.endsWith('.review.md')) continue;
205
+ const relPath = `${dir}/${entry.name}`;
206
+ let content;
207
+ try {
208
+ content = readFileSync(join(vaultRoot, relPath), 'utf8');
209
+ } catch {
210
+ continue;
211
+ }
212
+ const { frontmatter, body } = parsePlanFrontmatter(content);
213
+ const basename = entry.name.replace(/\.md$/i, '');
214
+ const id = (typeof frontmatter.id === 'string' && frontmatter.id) ? frontmatter.id : basename;
215
+ const status = String(frontmatter.status || 'open').toLowerCase();
216
+ if (!IN_FLIGHT_STATUSES.has(status)) continue; // frozen record — skip
217
+ const tags = Array.isArray(frontmatter.tags) ? frontmatter.tags : [];
218
+ out.push({
219
+ // `id` is the raw frontmatter id for display; `matchId` is the bare
220
+ // HS-XXX-YYYY form used to compare against plan references (protocol /
221
+ // research ids carry a hyphenated slug toSpecId would truncate, so we
222
+ // never overwrite the display id with the truncated form).
223
+ id: id,
224
+ matchId: toSpecId(id),
225
+ basename,
226
+ relPath,
227
+ type: String(frontmatter.type || dir.split('/')[1] || 'spec'),
228
+ status,
229
+ canonical: frontmatter.canonical !== 'false' && frontmatter.canonical !== false,
230
+ canonicalExplicit: frontmatter.canonical === 'true' || frontmatter.canonical === true,
231
+ title: String(frontmatter.name || frontmatter.id || basename),
232
+ tags: tags.map((t) => String(t).toLowerCase()),
233
+ body,
234
+ });
235
+ }
236
+ }
237
+ return out;
238
+ }
239
+
240
+ /** Load open memory decisions/lessons for the Axis-B design-supersession pass. */
241
+ function loadMemoryAxisSpecs(vaultRoot) {
242
+ const out = [];
243
+ for (const dir of MEMORY_AXIS_DIRS) {
244
+ const full = join(vaultRoot, dir);
245
+ if (!existsSync(full)) continue;
246
+ let files;
247
+ try {
248
+ files = readdirSync(full, { withFileTypes: true });
249
+ } catch {
250
+ continue;
251
+ }
252
+ for (const entry of files) {
253
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
254
+ const relPath = `${dir}/${entry.name}`;
255
+ let content;
256
+ try {
257
+ content = readFileSync(join(vaultRoot, relPath), 'utf8');
258
+ } catch {
259
+ continue;
260
+ }
261
+ const { frontmatter, body } = parsePlanFrontmatter(content);
262
+ const basename = entry.name.replace(/\.md$/i, '');
263
+ const id = typeof frontmatter.id === 'string' && frontmatter.id ? frontmatter.id : basename;
264
+ const status = String(frontmatter.status || 'open').toLowerCase();
265
+ if (!IN_FLIGHT_STATUSES.has(status)) continue;
266
+ out.push({
267
+ id,
268
+ matchId: toSpecId(id),
269
+ basename,
270
+ relPath,
271
+ type: String(frontmatter.type || 'memory-decision'),
272
+ status,
273
+ title: String(frontmatter.name || frontmatter.id || basename),
274
+ body,
275
+ });
276
+ }
277
+ }
278
+ return out;
279
+ }
280
+
281
+ // Cues in memory decisions that mark design constraints (Axis B).
282
+ const OFF_DIRECTION_CUES = [
283
+ 'off-direction',
284
+ 'does not implement',
285
+ 'does not run',
286
+ 'never calls',
287
+ 'never sit in',
288
+ 'do not revive',
289
+ 'do not rebuild',
290
+ 'push back before building',
291
+ 'superseded by',
292
+ 'replaced by',
293
+ 'is dead',
294
+ 'are dead',
295
+ 'deprecated.',
296
+ ];
297
+
298
+ function constraintTokens(text) {
299
+ const set = new Set();
300
+ for (const m of String(text).toLowerCase().matchAll(/[a-z][a-z0-9-]{2,}/g)) {
301
+ const w = m[0];
302
+ if (w.length >= 4 && !STOPWORDS.has(w)) set.add(w);
303
+ }
304
+ return set;
305
+ }
306
+
307
+ /**
308
+ * Extract "dead" and off-direction constraint lines from a memory spec body.
309
+ * Exported for unit tests ([[drift-is-two-axes-as-built-and-design-supersession]]).
310
+ */
311
+ export function extractDesignConstraints(body) {
312
+ const text = String(body || '');
313
+ const constraints = [];
314
+
315
+ // **Dead ...:** section header with following bullets (some older docs).
316
+ const deadSection = text.match(/\*\*Dead[^*]*\*\*[^\n]*\n([\s\S]*?)(?=\n\*\*[^*]|\n## |\n---|$)/i);
317
+ if (deadSection) {
318
+ for (const line of deadSection[1].split('\n')) {
319
+ const bullet = line.match(/^\s*[-*]\s+(.*)/);
320
+ if (bullet && bullet[1].trim().length > 8) {
321
+ constraints.push({ kind: 'dead', text: bullet[1].trim() });
322
+ }
323
+ }
324
+ }
325
+
326
+ for (const line of text.split('\n')) {
327
+ const trimmed = line.trim();
328
+ if (trimmed.length < 12) continue;
329
+
330
+ // Inline bullet: `- **Dead (in-app inference):** BYOK getApiKey + Settings key UI`
331
+ const deadInline = trimmed.match(/^[-*]\s+\*\*Dead[^*]*:\*\*\s*(.+)/i);
332
+ if (deadInline && deadInline[1].trim().length > 8) {
333
+ constraints.push({ kind: 'dead', text: deadInline[1].trim() });
334
+ continue;
335
+ }
336
+
337
+ const lower = trimmed.toLowerCase();
338
+ if (OFF_DIRECTION_CUES.some((cue) => lower.includes(cue))) {
339
+ constraints.push({ kind: 'off_direction', text: trimmed.replace(/^[-*#>\s]+/, '') });
340
+ }
341
+ }
342
+
343
+ return constraints;
344
+ }
345
+
346
+ /**
347
+ * Pass 6 — Axis B design-supersession drift. Detects when a plan proposes work
348
+ * that governing memory decisions mark dead or off-direction (see
349
+ * [[drift-is-two-axes-as-built-and-design-supersession]]). The overlap grep's
350
+ * conflict detector is blind to this axis.
351
+ */
352
+ export function runAxisBDriftPass(planBody, scope, memorySpecs) {
353
+ const planLower = String(planBody).toLowerCase();
354
+ const planKw = new Set(scope.keywords);
355
+ const conflicts = [];
356
+ const checked = [];
357
+
358
+ for (const spec of memorySpecs) {
359
+ const constraints = extractDesignConstraints(spec.body);
360
+ if (!constraints.length) continue;
361
+ checked.push(spec.id);
362
+
363
+ for (const { kind, text } of constraints) {
364
+ const cTok = constraintTokens(text);
365
+ let shared = 0;
366
+ const sharedTerms = [];
367
+ for (const t of cTok) {
368
+ if (planKw.has(t) || planLower.includes(t)) {
369
+ shared += 1;
370
+ sharedTerms.push(t);
371
+ }
372
+ }
373
+ const phrase = text.replace(/`/g, '').toLowerCase();
374
+ const phraseHit =
375
+ phrase.length >= 18 &&
376
+ planLower.includes(phrase.slice(0, Math.min(phrase.length, 48)).trim());
377
+
378
+ if (shared >= 2 || phraseHit) {
379
+ conflicts.push({
380
+ decision_id: spec.id,
381
+ decision_basename: spec.basename,
382
+ constraint_kind: kind,
383
+ constraint: text.slice(0, 240),
384
+ shared_terms: sharedTerms.slice(0, 8),
385
+ note:
386
+ 'Plan scope overlaps a governing memory constraint (Axis B — design supersession). ' +
387
+ 'The plan may re-implement work a newer decision marked dead or off-direction. ' +
388
+ 'Remove/re-scope per the decision, or file an explicit supersession if intentional.',
389
+ });
390
+ }
391
+ }
392
+ }
393
+
394
+ return {
395
+ axis_b_drift: conflicts,
396
+ axis_b_decisions_checked: checked.length,
397
+ axis_b_memory_specs_loaded: memorySpecs.length,
398
+ };
399
+ }
400
+
401
+ /** Tokenize a spec's title + tags into a keyword set for overlap scoring. */
402
+ function specKeywords(spec) {
403
+ const set = new Set(spec.tags);
404
+ for (const m of String(spec.title).toLowerCase().matchAll(/[a-z][a-z0-9-]{2,}/g)) {
405
+ const w = m[0];
406
+ if (w.length >= 4 && !STOPWORDS.has(w)) set.add(w);
407
+ }
408
+ return set;
409
+ }
410
+
411
+ /**
412
+ * Find the relationship cue (if any) in the window of plan text surrounding a
413
+ * reference to a spec. Returns the classification override or null.
414
+ */
415
+ function classifyByProximity(planBody, refTokens, specType) {
416
+ const lower = String(planBody).toLowerCase();
417
+ // Find the earliest occurrence of any ref token, then look at a ±220 char window.
418
+ let idx = -1;
419
+ for (const tok of refTokens) {
420
+ const at = lower.indexOf(String(tok).toLowerCase());
421
+ if (at !== -1 && (idx === -1 || at < idx)) idx = at;
422
+ }
423
+ const window = idx === -1 ? lower : lower.slice(Math.max(0, idx - 220), idx + 220);
424
+
425
+ const has = (words) => words.some((w) => (w.includes('.*') ? new RegExp(w).test(window) : window.includes(w)));
426
+
427
+ if (has(SUPERSEDE_WORDS)) return 'SUPERSEDED';
428
+ if (has(SUBSUME_WORDS)) return 'SUBSUMED';
429
+ if (has(UPSTREAM_WORDS)) return 'UPSTREAM';
430
+ if ((specType === 'epic' || specType === 'milestone') && has(PARENT_WORDS)) return 'PARENT';
431
+ return null;
432
+ }
433
+
434
+ /** Extract a short evidence snippet around the first ref occurrence. */
435
+ function evidenceSnippet(planBody, refTokens) {
436
+ const lower = String(planBody).toLowerCase();
437
+ let idx = -1;
438
+ for (const tok of refTokens) {
439
+ const at = lower.indexOf(String(tok).toLowerCase());
440
+ if (at !== -1 && (idx === -1 || at < idx)) idx = at;
441
+ }
442
+ if (idx === -1) return '';
443
+ const start = Math.max(0, idx - 80);
444
+ const snippet = String(planBody).slice(start, idx + 140).replace(/\s+/g, ' ').trim();
445
+ return (start > 0 ? '…' : '') + snippet + '…';
446
+ }
447
+
448
+ /**
449
+ * Pass 2 — overlap grep + classification. Returns
450
+ * { in_flight_overlap, supersedes, canonical_doc }.
451
+ */
452
+ function runOverlapPass(planBody, scope, candidates) {
453
+ const planKeywords = new Set(scope.keywords);
454
+ const refIds = new Set(scope.spec_ids);
455
+ const refLinks = new Set(scope.wikilinks);
456
+
457
+ const overlaps = [];
458
+ const supersedes = [];
459
+
460
+ for (const spec of candidates) {
461
+ const directRef =
462
+ (spec.matchId && refIds.has(spec.matchId)) ||
463
+ refLinks.has(spec.basename) ||
464
+ scope.spec_ids.some((rid) => spec.basename.toUpperCase().includes(rid));
465
+
466
+ const sk = specKeywords(spec);
467
+ let shared = 0;
468
+ for (const k of sk) if (planKeywords.has(k)) shared += 1;
469
+ const overlapRatio = sk.size ? shared / sk.size : 0;
470
+
471
+ // Threshold: a direct reference always qualifies; otherwise require a
472
+ // meaningful keyword overlap so unrelated specs don't flood the report.
473
+ if (!directRef && (shared < 3 || overlapRatio < 0.34)) continue;
474
+
475
+ const refTokens = [spec.id, spec.matchId, spec.basename].filter(Boolean);
476
+ let classification = classifyByProximity(planBody, refTokens, spec.type);
477
+ if (!classification) {
478
+ // No relationship cue. A plan whose keywords clearly contain the spec's
479
+ // scope subsumes it; otherwise it's a sibling working the same area.
480
+ classification = overlapRatio >= 0.8 && shared >= 4 && !directRef ? 'SUBSUMED' : 'SIBLING';
481
+ }
482
+
483
+ const matchedTerms = [...sk].filter((k) => planKeywords.has(k)).slice(0, 8);
484
+ const why = directRef
485
+ ? `Plan directly references ${spec.id}; in-flight (${spec.status}). Relationship read as ${classification}.`
486
+ : `Keyword overlap with ${spec.id} (${shared} shared terms${matchedTerms.length ? ': ' + matchedTerms.join(', ') : ''}); both in-flight.`;
487
+
488
+ overlaps.push({
489
+ id: spec.id,
490
+ basename: spec.basename,
491
+ type: spec.type,
492
+ status: spec.status,
493
+ classification,
494
+ score: Number((overlapRatio || (directRef ? 1 : 0)).toFixed(2)),
495
+ direct_reference: directRef,
496
+ matched_terms: matchedTerms,
497
+ why,
498
+ });
499
+
500
+ if (classification === 'SUPERSEDED') {
501
+ supersedes.push({
502
+ id: spec.id,
503
+ basename: spec.basename,
504
+ evidence: evidenceSnippet(planBody, refTokens),
505
+ note: 'Plan text reads as deprecating this spec. If intended, follow [[supersession-protocol]] (status: superseded + superseded_by + banner) rather than deleting it.',
506
+ });
507
+ }
508
+ }
509
+
510
+ // Stable ordering: direct references first, then by score desc, then id.
511
+ overlaps.sort((a, b) => {
512
+ if (a.direct_reference !== b.direct_reference) return a.direct_reference ? -1 : 1;
513
+ if (b.score !== a.score) return b.score - a.score;
514
+ return a.id.localeCompare(b.id);
515
+ });
516
+
517
+ // Canonical detection — borrow the SRP test from doc-canonicalization-protocol.
518
+ // Among the matched specs, the canonical authority on the shared scope is the
519
+ // one carrying canonical:true, or (failing that) the highest-overlap evergreen
520
+ // doc / protocol / decision. Plan-side tasks/epics are deliverables, not the
521
+ // canonical home of a rule, so they don't win the canonical slot.
522
+ const EVERGREEN = new Set(['protocol', 'memory-decision', 'memory-lesson', 'adr', 'decision', 'reference']);
523
+ let canonicalDoc = null;
524
+ const canonCandidates = overlaps
525
+ .map((o) => ({ o, spec: candidates.find((c) => c.id === o.id && c.basename === o.basename) }))
526
+ .filter((x) => x.spec);
527
+ // Prefer an explicit canonical:true match.
528
+ let pick = canonCandidates.find((x) => x.spec.canonicalExplicit);
529
+ if (!pick) pick = canonCandidates.find((x) => EVERGREEN.has(x.spec.type) && x.o.classification !== 'SUPERSEDED');
530
+ if (pick) {
531
+ canonicalDoc = {
532
+ id: pick.spec.id,
533
+ basename: pick.spec.basename,
534
+ type: pick.spec.type,
535
+ why: pick.spec.canonicalExplicit
536
+ ? `${pick.spec.id} carries canonical:true and overlaps this plan's scope — defer to it as the authority; cite it, don't re-derive its rule.`
537
+ : `${pick.spec.id} is an evergreen ${pick.spec.type} on the shared scope — treat it as the canonical authority and align the plan to it.`,
538
+ };
539
+ }
540
+
541
+ return { in_flight_overlap: overlaps, supersedes, canonical_doc: canonicalDoc };
542
+ }
543
+
544
+ /** Recursively collect code files under a root, bounded by MAX_CODE_FILES. */
545
+ function collectCodeFiles(vaultRoot, root, acc) {
546
+ const full = join(vaultRoot, root);
547
+ if (!existsSync(full)) return;
548
+ let entries;
549
+ try {
550
+ entries = readdirSync(full, { withFileTypes: true });
551
+ } catch {
552
+ return;
553
+ }
554
+ for (const entry of entries) {
555
+ if (acc.length >= MAX_CODE_FILES) return;
556
+ if (entry.isDirectory()) {
557
+ if (SKIP_DIRS.has(entry.name)) continue;
558
+ collectCodeFiles(vaultRoot, `${root}/${entry.name}`, acc);
559
+ } else if (entry.isFile()) {
560
+ const dot = entry.name.lastIndexOf('.');
561
+ const ext = dot === -1 ? '' : entry.name.slice(dot);
562
+ if (!CODE_EXTS.has(ext)) continue;
563
+ acc.push(`${root}/${entry.name}`);
564
+ }
565
+ }
566
+ }
567
+
568
+ /**
569
+ * Pass 3 — code audit. Resolve each file path and symbol the plan names against
570
+ * the codebase. Unresolved → BLOCKER; resolved → INFO.
571
+ */
572
+ function runCodeAudit(vaultRoot, scope, planContent) {
573
+ const findings = [];
574
+
575
+ // File-path resolution — cheap existence check.
576
+ for (const f of scope.files) {
577
+ const clean = f.replace(/^\.\//, '');
578
+ const resolved = existsSync(join(vaultRoot, clean));
579
+ findings.push({
580
+ kind: 'file',
581
+ ref: f,
582
+ severity: resolved ? 'INFO' : 'BLOCKER',
583
+ note: resolved
584
+ ? `File exists at ${clean}.`
585
+ : `Plan references ${f} but no such file exists under the vault root — fix the path or add it to scope.`,
586
+ });
587
+ }
588
+
589
+ // Symbol resolution — build the code corpus once, then test each symbol.
590
+ if (scope.symbols.length) {
591
+ const files = [];
592
+ for (const root of CODE_ROOTS) collectCodeFiles(vaultRoot, root, files);
593
+ const corpus = [];
594
+ for (const rel of files) {
595
+ try {
596
+ corpus.push({ rel, content: readFileSync(join(vaultRoot, rel), 'utf8') });
597
+ } catch {
598
+ /* skip unreadable */
599
+ }
600
+ }
601
+ for (const sym of scope.symbols) {
602
+ const re = new RegExp(`\\b${sym.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`);
603
+ const where = [];
604
+ for (const file of corpus) {
605
+ if (re.test(file.content)) {
606
+ where.push(file.rel);
607
+ if (where.length >= 5) break;
608
+ }
609
+ }
610
+ findings.push({
611
+ kind: 'symbol',
612
+ ref: sym,
613
+ severity: where.length ? 'INFO' : 'BLOCKER',
614
+ where,
615
+ note: where.length
616
+ ? `Symbol resolves in ${where.length === 5 ? '5+' : where.length} file(s).`
617
+ : `Plan references \`${sym}\` but it is not defined anywhere in the scanned code roots (${CODE_ROOTS.join(', ')}) — verify the name or add the missing implementation to scope.`,
618
+ });
619
+ }
620
+ }
621
+
622
+ return findings;
623
+ }
624
+
625
+ /** Pass 4 — turn findings into actionable V2 patch hints. */
626
+ function buildPatchHints({
627
+ in_flight_overlap,
628
+ supersedes,
629
+ canonical_doc,
630
+ code_audit,
631
+ vocabulary_reconciliation,
632
+ axis_b_drift,
633
+ }) {
634
+ const hints = [];
635
+
636
+ for (const o of in_flight_overlap) {
637
+ switch (o.classification) {
638
+ case 'SUPERSEDED':
639
+ hints.push(`Plan appears to supersede ${o.id} — add an explicit supersession note and follow [[supersession-protocol]] (don't silently replace it).`);
640
+ break;
641
+ case 'SUBSUMED':
642
+ hints.push(`Plan's scope subsumes ${o.id} — fold its acceptance criteria in or mark it superseded; don't leave two specs owning the same scope.`);
643
+ break;
644
+ case 'UPSTREAM':
645
+ hints.push(`${o.id} is an upstream dependency — add it to depends_on and sequence this work after it lands.`);
646
+ break;
647
+ case 'PARENT':
648
+ hints.push(`${o.id} reads as the parent — set parent/milestone frontmatter to it rather than restating its scope.`);
649
+ break;
650
+ default:
651
+ hints.push(`Sibling in-flight work ${o.id} overlaps this scope — coordinate (link it in related:, note the boundary) so you don't stomp each other's WIP.`);
652
+ }
653
+ }
654
+
655
+ for (const s of supersedes) {
656
+ hints.push(`Supersession candidate ${s.id}: if intended, apply the [[supersession-protocol]] frontmatter (status, superseded_by, banner) as part of this work.`);
657
+ }
658
+
659
+ if (canonical_doc) {
660
+ hints.push(`Defer to canonical authority ${canonical_doc.id} on the shared scope — cite it in the plan and align to it instead of re-deriving its rule.`);
661
+ }
662
+
663
+ const blockers = code_audit.filter((c) => c.severity === 'BLOCKER');
664
+ for (const b of blockers) {
665
+ hints.push(`Resolve code-audit BLOCKER: ${b.ref} — ${b.note}`);
666
+ }
667
+
668
+ for (const v of (vocabulary_reconciliation || [])) {
669
+ hints.push(`Vocabulary: plan says "${v.plan_term}" but ${v.spec} uses "${v.vault_term}" — use the established term instead of forking the project's vocabulary.`);
670
+ }
671
+
672
+ for (const d of axis_b_drift || []) {
673
+ hints.push(
674
+ `Axis B drift vs ${d.decision_id}: plan overlaps a "${d.constraint_kind}" constraint — ` +
675
+ `"${d.constraint.slice(0, 100)}${d.constraint.length > 100 ? '…' : ''}". ` +
676
+ `Remove/re-scope this work per the decision, or supersede the decision explicitly.`,
677
+ );
678
+ }
679
+
680
+ if (!hints.length) {
681
+ hints.push('No in-flight overlap, canonical conflict, or unresolved code reference detected. Plan looks clean — proceed to V2 with a one-line "canonical pass: no competing in-flight work" note.');
682
+ }
683
+
684
+ return hints;
685
+ }
686
+
687
+ // ── Pass 5 — vocabulary reconciliation ──────────────────────────────────────
688
+ // Flags when the plan introduces a term that is a NEAR-but-not-exact variant of
689
+ // a term used by a strongly-overlapping in-flight spec — the morphological /
690
+ // identifier-drift class of "reinventing the project's words" (e.g. plan says
691
+ // "grains" while [[HS-EPIC-7CWZ]] says "grain", or "review-grain" vs
692
+ // "review_grain"). Scoped to specs the overlap pass already flagged as related
693
+ // (high precision per [[HS-RES-2QX6]] F12 — a noisy reconciliation nudge is
694
+ // worse than none). Pure string variance; it does NOT catch synonyms (grain vs
695
+ // mode) — those are surfaced by the overlap pass itself. Non-blocking (INFO).
696
+ function levenshtein(a, b) {
697
+ const m = a.length, n = b.length;
698
+ if (!m) return n;
699
+ if (!n) return m;
700
+ let prev = Array.from({ length: n + 1 }, (_, i) => i);
701
+ for (let i = 1; i <= m; i++) {
702
+ const curr = [i];
703
+ for (let j = 1; j <= n; j++) {
704
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
705
+ curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
706
+ }
707
+ prev = curr;
708
+ }
709
+ return prev[n];
710
+ }
711
+
712
+ function isTermVariant(a, b) {
713
+ if (a === b) return false;
714
+ const na = a.replace(/[-_]/g, ''), nb = b.replace(/[-_]/g, '');
715
+ if (na === nb) return true; // hyphen/underscore/spacing variant
716
+ if (na + 's' === nb || nb + 's' === na) return true; // plural
717
+ const sa = na.replace(/s$/, ''), sb = nb.replace(/s$/, '');
718
+ if (sa === sb && na !== nb) return true; // shared singular stem
719
+ // Single-edit typo/morph variant — long words only, to keep precision high.
720
+ if (Math.min(na.length, nb.length) >= 5 && levenshtein(na, nb) === 1) return true;
721
+ return false;
722
+ }
723
+
724
+ function runVocabularyPass(scope, in_flight_overlap, candidatesById) {
725
+ const planTerms = [...new Set((scope.keywords || []).filter((w) => w.length >= 4))];
726
+ const seen = new Set();
727
+ const findings = [];
728
+ for (const o of in_flight_overlap) {
729
+ const spec = candidatesById.get(o.id) || candidatesById.get(o.matchId) || candidatesById.get(o.basename);
730
+ if (!spec) continue;
731
+ const vocab = specKeywords(spec);
732
+ for (const p of planTerms) {
733
+ if (vocab.has(p)) continue; // exact match — already aligned
734
+ for (const v of vocab) {
735
+ if (!isTermVariant(p, v)) continue;
736
+ const key = `${p}|${v}|${o.id}`;
737
+ if (seen.has(key)) continue;
738
+ seen.add(key);
739
+ findings.push({
740
+ kind: 'vocabulary',
741
+ severity: 'INFO',
742
+ plan_term: p,
743
+ vault_term: v,
744
+ spec: o.id,
745
+ note: `Plan uses "${p}"; related in-flight ${o.id} uses "${v}". Reconcile to the established term rather than introducing a variant.`,
746
+ });
747
+ }
748
+ }
749
+ }
750
+ return findings.slice(0, 8);
751
+ }
752
+
753
+ /**
754
+ * Serialize a proposed epic→task breakdown into plan-review-shaped markdown so
755
+ * the overlap + code-audit passes can run without a separate tool (breakdown
756
+ * filing review — `scope: "breakdown"` on `heyspex_plan_review`).
757
+ */
758
+ export function formatBreakdownPlanContent({ epicId, epicName, proposedTasks = [] } = {}) {
759
+ const tasks = Array.isArray(proposedTasks) ? proposedTasks : [];
760
+ const lines = [
761
+ `# Breakdown — proposed ${tasks.length} task(s) under ${epicId || '(no epic)'}`,
762
+ '',
763
+ ];
764
+ if (epicName) lines.push(`Epic title: ${epicName}`, '');
765
+ if (epicId) lines.push(`Parent epic: [[${epicId}]]`, '');
766
+ lines.push('## Proposed tasks', '');
767
+ tasks.forEach((raw, i) => {
768
+ const t = raw && typeof raw === 'object' ? raw : { title: String(raw || '') };
769
+ const title = t.title || t.name || `Task ${i + 1}`;
770
+ lines.push(`### ${i + 1}. ${title}`);
771
+ const scopeText = t.scope || t.body || t.description;
772
+ if (scopeText) lines.push(String(scopeText).trim(), '');
773
+ const files = t.files || t.paths;
774
+ if (Array.isArray(files) && files.length) {
775
+ lines.push(`Touches \`${files.join('`, `')}\`.`, '');
776
+ }
777
+ if (Array.isArray(t.symbols) && t.symbols.length) {
778
+ lines.push(`Uses \`${t.symbols.join('`, `')}\`.`, '');
779
+ }
780
+ const deps = t.depends_on || t.dependsOn;
781
+ if (Array.isArray(deps) && deps.length) {
782
+ lines.push(`Depends on: ${deps.join(', ')}.`, '');
783
+ }
784
+ });
785
+ return lines.join('\n');
786
+ }
787
+
788
+ /** Closed vocabulary for Pass 7 quality findings ([[HS-TASK-CH6Y]]). */
789
+ export const QUALITY_FINDING_KINDS = new Set(['mece_gap', 'oversize', 'missing_depends_on']);
790
+
791
+ /** Scope text longer than this is flagged oversize (one-context-window heuristic). */
792
+ const OVERSIZE_SCOPE_CHARS = 1500;
793
+ const OVERSIZE_FILE_COUNT = 8;
794
+ const OVERSIZE_SYMBOL_COUNT = 10;
795
+
796
+ const ORDERING_CUE_RE =
797
+ /\b(after|once|blocked by|depends on|requires|prerequisite|must land|before filing)\b/i;
798
+
799
+ function taskTitle(raw, i) {
800
+ const t = raw && typeof raw === 'object' ? raw : { title: String(raw || '') };
801
+ return String(t.title || t.name || `Task ${i + 1}`).trim();
802
+ }
803
+
804
+ function normalizeTitleKey(title) {
805
+ return String(title).toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
806
+ }
807
+
808
+ function taskFiles(raw) {
809
+ const t = raw && typeof raw === 'object' ? raw : {};
810
+ const files = t.files || t.paths;
811
+ return Array.isArray(files) ? files.map((f) => String(f).trim()).filter(Boolean) : [];
812
+ }
813
+
814
+ function taskDependsOn(raw) {
815
+ const t = raw && typeof raw === 'object' ? raw : {};
816
+ const deps = t.depends_on || t.dependsOn;
817
+ return Array.isArray(deps) ? deps.map((d) => String(d).trim()).filter(Boolean) : [];
818
+ }
819
+
820
+ function taskScopeText(raw) {
821
+ const t = raw && typeof raw === 'object' ? raw : {};
822
+ return String(t.scope || t.body || t.description || '').trim();
823
+ }
824
+
825
+ /**
826
+ * Validate and normalize agent-supplied quality findings. HeySpex never calls
827
+ * an LLM here — the session agent judges externally and passes findings back
828
+ * ([[HS-DEC-heyspex-runs-no-in-app-ai-inference-it-m]]).
829
+ */
830
+ export function normalizeAgentQualityFindings(raw) {
831
+ if (!Array.isArray(raw)) return [];
832
+ const out = [];
833
+ for (const item of raw) {
834
+ if (!item || typeof item !== 'object') continue;
835
+ const kind = String(item.kind || '').trim();
836
+ const title = String(item.title || '').trim();
837
+ const note = String(item.note || '').trim();
838
+ if (!QUALITY_FINDING_KINDS.has(kind) || !title || !note) continue;
839
+ out.push({ kind, title, note });
840
+ }
841
+ return out;
842
+ }
843
+
844
+ /**
845
+ * Pass 7 — breakdown quality pass (MECE, sizing, ordering). Runs deterministic
846
+ * heuristics locally; optionally merges agent-supplied LLM findings. No in-app
847
+ * inference ([[HS-TASK-CH6Y]] / [[HS-EPIC-RCJK]]).
848
+ */
849
+ export function runBreakdownQualityPass(proposedTasks = [], { agentQualityFindings } = {}) {
850
+ const tasks = Array.isArray(proposedTasks) ? proposedTasks : [];
851
+ const findings = [];
852
+ const titleIndex = new Map();
853
+
854
+ for (let i = 0; i < tasks.length; i++) {
855
+ const title = taskTitle(tasks[i], i);
856
+ const key = normalizeTitleKey(title);
857
+ if (key && titleIndex.has(key)) {
858
+ findings.push({
859
+ kind: 'mece_gap',
860
+ title: `Duplicate task titles: "${title}"`,
861
+ note:
862
+ `Tasks ${titleIndex.get(key) + 1} and ${i + 1} share the same title — ` +
863
+ 'split scope or rename so each task is mutually exclusive.',
864
+ });
865
+ } else if (key) {
866
+ titleIndex.set(key, i);
867
+ }
868
+ }
869
+
870
+ const fileOwners = new Map();
871
+ for (let i = 0; i < tasks.length; i++) {
872
+ for (const f of taskFiles(tasks[i])) {
873
+ if (!fileOwners.has(f)) fileOwners.set(f, []);
874
+ fileOwners.get(f).push(i);
875
+ }
876
+ }
877
+ for (const [file, owners] of fileOwners) {
878
+ if (owners.length < 2) continue;
879
+ const labels = owners.map((idx) => `"${taskTitle(tasks[idx], idx)}"`).join(', ');
880
+ findings.push({
881
+ kind: 'mece_gap',
882
+ title: `Overlapping file ownership: ${file}`,
883
+ note:
884
+ `${owners.length} tasks (${labels}) all touch \`${file}\` — ` +
885
+ 'merge into one task or split file scope so work is MECE.',
886
+ });
887
+ }
888
+
889
+ for (let i = 0; i < tasks.length; i++) {
890
+ const scope = taskScopeText(tasks[i]);
891
+ const files = taskFiles(tasks[i]);
892
+ const t = tasks[i] && typeof tasks[i] === 'object' ? tasks[i] : {};
893
+ const symbols = Array.isArray(t.symbols) ? t.symbols : [];
894
+ const title = taskTitle(tasks[i], i);
895
+
896
+ if (
897
+ scope.length > OVERSIZE_SCOPE_CHARS ||
898
+ files.length > OVERSIZE_FILE_COUNT ||
899
+ symbols.length > OVERSIZE_SYMBOL_COUNT
900
+ ) {
901
+ findings.push({
902
+ kind: 'oversize',
903
+ title: `Task may be oversized: "${title}"`,
904
+ note:
905
+ `Scope is ${scope.length} chars / ${files.length} files / ${symbols.length} symbols — ` +
906
+ 'consider splitting into smaller tasks that fit one agent context window.',
907
+ });
908
+ }
909
+
910
+ const deps = taskDependsOn(tasks[i]);
911
+ if (ORDERING_CUE_RE.test(scope) && deps.length === 0) {
912
+ findings.push({
913
+ kind: 'missing_depends_on',
914
+ title: `Ordering cue without depends_on: "${title}"`,
915
+ note:
916
+ 'Scope mentions sequencing ("after", "depends on", "blocked by", …) but `depends_on` is empty — ' +
917
+ 'add explicit dependencies so filing order matches execution order.',
918
+ });
919
+ }
920
+
921
+ for (const dep of deps) {
922
+ const depKey = normalizeTitleKey(dep);
923
+ let depIdx = -1;
924
+ for (let j = 0; j < tasks.length; j++) {
925
+ if (normalizeTitleKey(taskTitle(tasks[j], j)) === depKey) {
926
+ depIdx = j;
927
+ break;
928
+ }
929
+ }
930
+ if (depIdx === -1) continue;
931
+ if (depIdx >= i) {
932
+ findings.push({
933
+ kind: 'missing_depends_on',
934
+ title: `Incoherent depends_on: "${title}"`,
935
+ note:
936
+ `Depends on "${dep}" which is task ${depIdx + 1} (same or later than task ${i + 1}) — ` +
937
+ 'reorder tasks or fix depends_on so prerequisites come first.',
938
+ });
939
+ }
940
+ }
941
+ }
942
+
943
+ const agentFindings = normalizeAgentQualityFindings(agentQualityFindings);
944
+ const seen = new Set();
945
+ const merged = [];
946
+ for (const f of [...findings, ...agentFindings]) {
947
+ const key = `${f.kind}|${f.title}`;
948
+ if (seen.has(key)) continue;
949
+ seen.add(key);
950
+ merged.push(f);
951
+ }
952
+ return merged;
953
+ }
954
+
955
+ /**
956
+ * Brief for the session agent when quality_pass is enabled — the agent runs
957
+ * MECE/sizing/ordering judgment on its own model and may pass findings back
958
+ * via quality_findings on a follow-up call ([[HS-DEC-heyspex-runs-no-in-app-ai-inference-it-m]]).
959
+ */
960
+ export function buildQualityJudgeBrief(proposedTasks = []) {
961
+ const tasks = Array.isArray(proposedTasks) ? proposedTasks : [];
962
+ const lines = [
963
+ 'Pass 7 quality judge (agent-as-judge — use your own model, not HeySpex inference):',
964
+ 'Check whether proposed tasks are MECE, appropriately sized, and correctly sequenced.',
965
+ 'Return findings as quality_findings: [{ kind: "mece_gap"|"oversize"|"missing_depends_on", title, note }].',
966
+ '',
967
+ `Proposed task set (${tasks.length}):`,
968
+ ];
969
+ tasks.forEach((raw, i) => {
970
+ lines.push(`- ${i + 1}. ${taskTitle(raw, i)}`);
971
+ const scope = taskScopeText(raw);
972
+ if (scope) lines.push(` scope: ${scope.slice(0, 200)}${scope.length > 200 ? '…' : ''}`);
973
+ const deps = taskDependsOn(raw);
974
+ if (deps.length) lines.push(` depends_on: ${deps.join(', ')}`);
975
+ });
976
+ return lines.join('\n');
977
+ }
978
+
979
+ /**
980
+ * Run the full plan review. Accepts either a `planPath` (read from disk,
981
+ * relative to vaultRoot or absolute) or inline `planContent`. When
982
+ * `scope === "breakdown"`, also accepts `epicId` + `proposedTasks` to
983
+ * synthesize review input from a draft task set. Writes a marker file keyed
984
+ * by the sha256 of the plan content and returns the findings.
985
+ *
986
+ * @returns { scope_terms, in_flight_overlap, canonical_doc, supersedes,
987
+ * code_audit, vocabulary_reconciliation, v2_patch_hints,
988
+ * review_marker_path, review_scope, quality_findings? } | { error, message }
989
+ */
990
+ export function runPlanReview({
991
+ vaultRoot,
992
+ planPath,
993
+ planContent,
994
+ scope = 'plan',
995
+ epicId,
996
+ epicName,
997
+ proposedTasks,
998
+ qualityPass,
999
+ agentQualityFindings,
1000
+ } = {}) {
1001
+ if (!vaultRoot) {
1002
+ return { error: 'MISSING_VAULT_ROOT', message: 'vaultRoot is required.' };
1003
+ }
1004
+
1005
+ const reviewScope = scope === 'breakdown' ? 'breakdown' : 'plan';
1006
+
1007
+ let content = planContent;
1008
+ let resolvedPlanPath = null;
1009
+ if (typeof content !== 'string') {
1010
+ if (
1011
+ reviewScope === 'breakdown' &&
1012
+ typeof epicId === 'string' &&
1013
+ epicId.trim() &&
1014
+ Array.isArray(proposedTasks) &&
1015
+ proposedTasks.length > 0
1016
+ ) {
1017
+ content = formatBreakdownPlanContent({
1018
+ epicId: epicId.trim(),
1019
+ epicName: typeof epicName === 'string' ? epicName.trim() : undefined,
1020
+ proposedTasks,
1021
+ });
1022
+ } else if (!planPath) {
1023
+ if (reviewScope === 'breakdown') {
1024
+ return {
1025
+ error: 'MISSING_BREAKDOWN_INPUT',
1026
+ message:
1027
+ 'For scope "breakdown", provide plan_path/plan_content OR epic_id + proposed_tasks (non-empty array).',
1028
+ };
1029
+ }
1030
+ return { error: 'MISSING_PLAN', message: 'Provide either plan_path or plan_content.' };
1031
+ }
1032
+ }
1033
+ if (typeof content !== 'string') {
1034
+ if (!planPath) {
1035
+ return { error: 'MISSING_PLAN', message: 'Provide either plan_path or plan_content.' };
1036
+ }
1037
+ resolvedPlanPath = planPath;
1038
+ const abs = planPath.match(/^([A-Za-z]:[\\/]|\/)/) ? planPath : join(vaultRoot, planPath);
1039
+ if (!existsSync(abs)) {
1040
+ return { error: 'PLAN_NOT_FOUND', message: `Plan file not found: ${planPath}` };
1041
+ }
1042
+ try {
1043
+ content = readFileSync(abs, 'utf8');
1044
+ } catch (err) {
1045
+ return { error: 'PLAN_UNREADABLE', message: `Could not read plan file: ${err.message}` };
1046
+ }
1047
+ }
1048
+
1049
+ if (!content.trim()) {
1050
+ return { error: 'EMPTY_PLAN', message: 'Plan content is empty.' };
1051
+ }
1052
+
1053
+ // The plan body for analysis strips its own frontmatter (if any) so a spec
1054
+ // file's frontmatter doesn't pollute scope extraction.
1055
+ const { body: planBody } = parsePlanFrontmatter(content);
1056
+ const analysisText = planBody && planBody.trim() ? planBody : content;
1057
+
1058
+ const scopeTerms = extractScopeTerms(analysisText);
1059
+ const candidates = loadOverlapCandidates(vaultRoot);
1060
+ const { in_flight_overlap, supersedes, canonical_doc } = runOverlapPass(analysisText, scopeTerms, candidates);
1061
+ const code_audit = runCodeAudit(vaultRoot, scopeTerms, content);
1062
+
1063
+ // Pass 5 — vocabulary reconciliation, scoped to the specs the overlap pass
1064
+ // already flagged (high precision). Index candidates by every id form so the
1065
+ // overlap entries resolve back to their full descriptor.
1066
+ const candidatesById = new Map();
1067
+ for (const c of candidates) {
1068
+ candidatesById.set(c.id, c);
1069
+ candidatesById.set(c.matchId, c);
1070
+ candidatesById.set(c.basename, c);
1071
+ }
1072
+ const vocabulary_reconciliation = runVocabularyPass(scopeTerms, in_flight_overlap, candidatesById);
1073
+ const memoryAxisSpecs = loadMemoryAxisSpecs(vaultRoot);
1074
+ const axis_b = runAxisBDriftPass(analysisText, scopeTerms, memoryAxisSpecs);
1075
+
1076
+ const v2_patch_hints = buildPatchHints({
1077
+ in_flight_overlap,
1078
+ supersedes,
1079
+ canonical_doc,
1080
+ code_audit,
1081
+ vocabulary_reconciliation,
1082
+ axis_b_drift: axis_b.axis_b_drift,
1083
+ });
1084
+
1085
+ const result = {
1086
+ scope_terms: scopeTerms,
1087
+ in_flight_overlap,
1088
+ canonical_doc,
1089
+ supersedes,
1090
+ code_audit,
1091
+ vocabulary_reconciliation,
1092
+ axis_b_drift: axis_b.axis_b_drift,
1093
+ axis_b_decisions_checked: axis_b.axis_b_decisions_checked,
1094
+ v2_patch_hints,
1095
+ plan_path: resolvedPlanPath,
1096
+ review_scope: reviewScope,
1097
+ };
1098
+ if (reviewScope === 'breakdown' && typeof epicId === 'string' && epicId.trim()) {
1099
+ result.epic_id = epicId.trim();
1100
+ }
1101
+
1102
+ const tasksForQuality =
1103
+ Array.isArray(proposedTasks) && proposedTasks.length > 0 ? proposedTasks : null;
1104
+
1105
+ if (qualityPass === true) {
1106
+ if (reviewScope !== 'breakdown' || !tasksForQuality) {
1107
+ return {
1108
+ error: 'QUALITY_PASS_REQUIRES_BREAKDOWN',
1109
+ message:
1110
+ 'quality_pass is only supported for scope "breakdown" with epic_id + proposed_tasks.',
1111
+ };
1112
+ }
1113
+ result.quality_findings = runBreakdownQualityPass(tasksForQuality, { agentQualityFindings });
1114
+ if (!agentQualityFindings || !agentQualityFindings.length) {
1115
+ result.quality_judge_brief = buildQualityJudgeBrief(tasksForQuality);
1116
+ }
1117
+ }
1118
+
1119
+ // Side effect: write the marker file keyed by sha256(plan content). A re-review
1120
+ // after a V2 rewrite hashes to a different filename, so each plan version gets
1121
+ // its own marker — the gate reads the marker for the current plan-content hash.
1122
+ const hash = createHash('sha256').update(content).digest('hex');
1123
+ const reviewsDirRel = join('.heyspex', 'plan-reviews');
1124
+ const reviewsDirAbs = join(vaultRoot, reviewsDirRel);
1125
+ const markerRel = join(reviewsDirRel, `${hash}.json`);
1126
+ try {
1127
+ if (!existsSync(reviewsDirAbs)) mkdirSync(reviewsDirAbs, { recursive: true });
1128
+ const marker = { ...result, plan_content_sha256: hash, reviewed_marker: true };
1129
+ writeFileSync(join(vaultRoot, markerRel), JSON.stringify(marker, null, 2), 'utf8');
1130
+ result.review_marker_path = markerRel.replace(/\\/g, '/');
1131
+ } catch (err) {
1132
+ // Marker write failure is surfaced but doesn't drop the findings — the
1133
+ // analysis is still useful even if the gate marker couldn't be persisted.
1134
+ result.review_marker_path = null;
1135
+ result.marker_write_error = err.message;
1136
+ }
1137
+
1138
+ result.plan_content_sha256 = hash;
1139
+ return result;
1140
+ }
1141
+
1142
+ /**
1143
+ * Newest breakdown-scoped plan-review marker for an epic (by file mtime).
1144
+ * Returns `{ markerRelPath, marker }` or `null`. [[HS-TASK-MEN2]]
1145
+ */
1146
+ export function findLatestBreakdownReviewMarker(vaultRoot, epicId) {
1147
+ const reviewsDir = join(vaultRoot, '.heyspex', 'plan-reviews');
1148
+ if (!existsSync(reviewsDir)) return null;
1149
+ const targetEpic = String(epicId || '').trim().toUpperCase();
1150
+ if (!targetEpic) return null;
1151
+
1152
+ let best = null;
1153
+ for (const name of readdirSync(reviewsDir)) {
1154
+ if (!name.endsWith('.json')) continue;
1155
+ const rel = join('.heyspex', 'plan-reviews', name).replace(/\\/g, '/');
1156
+ const abs = join(vaultRoot, rel);
1157
+ let marker;
1158
+ try {
1159
+ marker = JSON.parse(readFileSync(abs, 'utf8'));
1160
+ } catch {
1161
+ continue;
1162
+ }
1163
+ if (marker.review_scope !== 'breakdown') continue;
1164
+ const mid = String(marker.epic_id || '').trim().toUpperCase();
1165
+ if (mid !== targetEpic) continue;
1166
+ let mtime = 0;
1167
+ try {
1168
+ mtime = statSync(abs).mtimeMs;
1169
+ } catch {
1170
+ mtime = 0;
1171
+ }
1172
+ if (!best || mtime > best.mtime) {
1173
+ best = { markerRelPath: rel, marker, mtime };
1174
+ }
1175
+ }
1176
+ return best ? { markerRelPath: best.markerRelPath, marker: best.marker } : null;
1177
+ }
1178
+
1179
+ /**
1180
+ * Stamp `skipped_despite_findings` on a breakdown plan-review marker at
1181
+ * `heyspex_record_alignment` time — skip-rate telemetry ([[HS-TASK-MEN2]]).
1182
+ * Does not block alignment; hard enforcement is deferred.
1183
+ *
1184
+ * @returns {{ ok, review_marker_path, skipped_despite_findings, epic_id }}
1185
+ * | {{ error, message }}
1186
+ */
1187
+ export function stampSkippedDespiteFindingsOnBreakdownMarker({
1188
+ vaultRoot,
1189
+ epicId,
1190
+ skippedDespiteFindings,
1191
+ markerRelPath,
1192
+ } = {}) {
1193
+ if (!vaultRoot) {
1194
+ return { error: 'MISSING_VAULT_ROOT', message: 'vaultRoot is required.' };
1195
+ }
1196
+ if (typeof skippedDespiteFindings !== 'boolean') {
1197
+ return {
1198
+ error: 'INVALID_SKIPPED_DESPITE_FINDINGS',
1199
+ message: 'skipped_despite_findings must be a boolean (true or false).',
1200
+ };
1201
+ }
1202
+
1203
+ let rel = typeof markerRelPath === 'string' && markerRelPath.trim() ? markerRelPath.trim() : null;
1204
+ if (!rel) {
1205
+ const found = findLatestBreakdownReviewMarker(vaultRoot, epicId);
1206
+ if (!found) {
1207
+ return {
1208
+ error: 'MARKER_NOT_FOUND',
1209
+ message:
1210
+ `No breakdown plan-review marker found for epic ${epicId || '(missing)'}. ` +
1211
+ 'Run heyspex_plan_review({ scope: "breakdown", epic_id, proposed_tasks }) first.',
1212
+ };
1213
+ }
1214
+ rel = found.markerRelPath;
1215
+ }
1216
+
1217
+ const abs = join(vaultRoot, rel);
1218
+ let marker;
1219
+ try {
1220
+ marker = JSON.parse(readFileSync(abs, 'utf8'));
1221
+ } catch (err) {
1222
+ return { error: 'MARKER_UNREADABLE', message: err.message, review_marker_path: rel.replace(/\\/g, '/') };
1223
+ }
1224
+
1225
+ marker.skipped_despite_findings = skippedDespiteFindings;
1226
+ try {
1227
+ writeFileSync(abs, JSON.stringify(marker, null, 2), 'utf8');
1228
+ } catch (err) {
1229
+ return { error: 'MARKER_WRITE_FAILED', message: err.message, review_marker_path: rel.replace(/\\/g, '/') };
1230
+ }
1231
+
1232
+ return {
1233
+ ok: true,
1234
+ review_marker_path: rel.replace(/\\/g, '/'),
1235
+ skipped_despite_findings: skippedDespiteFindings,
1236
+ epic_id: marker.epic_id || epicId || null,
1237
+ };
1238
+ }
1239
+
1240
+ // Minimum auditable length for a QC findings summary. Proof-of-Use guard
1241
+ // ([[HS-RES-2QX6]] F13 / arXiv 2510.10931): a bare "tool fired" marker is
1242
+ // gameable ("decorative tool calls"), so the QC gate requires the agent to
1243
+ // record what it actually checked. Kept modest — this resists placeholders, it
1244
+ // is not a quality bar on the prose.
1245
+ const QC_SUMMARY_MIN_CHARS = 80;
1246
+ const QC_AXIS_B_CONFIRM_NOTE_MIN_CHARS = 40;
1247
+
1248
+ // NOTE: the explicit axis_b_user_confirmed override is handled entirely by
1249
+ // the caller (runPlanQc), *after* this filter runs — it clears whatever this
1250
+ // function still reports as unresolved, unconditionally, once a sufficiently
1251
+ // long axis_b_confirmation_note is present. This function only recognizes
1252
+ // the *organic* resolution path (the agent's own findings_summary prose
1253
+ // naming the decision and stating how it was resolved/re-scoped), with no
1254
+ // override flag involved. Earlier versions of this function also tried to
1255
+ // detect a confirmed override by matching hard-coded phrases ("user
1256
+ // confirmed", "confirmed override", …) and requiring the decision id/basename
1257
+ // to appear verbatim in the summary or note — that undocumented, brittle
1258
+ // contract is what let real overrides fall through and keep refusing
1259
+ // ([[HS-EP8E-C1L2-DIAG-heyspex-plan-qc-override-path-ignores-ax]]).
1260
+ function axisBDriftUnresolved(axisDrift, summary) {
1261
+ if (!axisDrift.length) return [];
1262
+
1263
+ const summaryLower = summary.toLowerCase();
1264
+
1265
+ return axisDrift.filter((c) => {
1266
+ const mentionsDecision =
1267
+ summaryLower.includes(String(c.decision_id).toLowerCase()) ||
1268
+ summaryLower.includes(String(c.decision_basename).slice(0, 28).toLowerCase());
1269
+
1270
+ const mentionsResolution =
1271
+ /\b(resolved|removed|dropped|re-scoped|superseded|defer|deferred|narrowed|cancelled|canceled|out of scope|not in scope)\b/i.test(
1272
+ summary,
1273
+ );
1274
+
1275
+ return !(mentionsResolution && mentionsDecision);
1276
+ });
1277
+ }
1278
+
1279
+ /**
1280
+ * Record that the QC gut-check ([[plan-quality-canonical-check]] / `/hs-qc`)
1281
+ * ran for a specific plan, with Proof-of-Use. Mirrors runPlanReview's marker
1282
+ * mechanics but writes `.heyspex/plan-qc/<sha256(plan)>.json` carrying the
1283
+ * agent's findings summary. The ExitPlanMode gate ([[HS-EPIC-PLRV]]) requires
1284
+ * this marker AND the plan-review marker before it allows the exit.
1285
+ *
1286
+ * @returns { qc_marker_path, plan_content_sha256, findings_summary_chars }
1287
+ * | { error, message }
1288
+ */
1289
+ export function runPlanQc({
1290
+ vaultRoot,
1291
+ planPath,
1292
+ planContent,
1293
+ findingsSummary,
1294
+ axisBUserConfirmed,
1295
+ axisBConfirmationNote,
1296
+ } = {}) {
1297
+ if (!vaultRoot) {
1298
+ return { error: 'MISSING_VAULT_ROOT', message: 'vaultRoot is required.' };
1299
+ }
1300
+
1301
+ let content = planContent;
1302
+ let resolvedPlanPath = null;
1303
+ if (typeof content !== 'string') {
1304
+ if (!planPath) {
1305
+ return { error: 'MISSING_PLAN', message: 'Provide either plan_path or plan_content.' };
1306
+ }
1307
+ resolvedPlanPath = planPath;
1308
+ const abs = planPath.match(/^([A-Za-z]:[\\/]|\/)/) ? planPath : join(vaultRoot, planPath);
1309
+ if (!existsSync(abs)) {
1310
+ return { error: 'PLAN_NOT_FOUND', message: `Plan file not found: ${planPath}` };
1311
+ }
1312
+ try {
1313
+ content = readFileSync(abs, 'utf8');
1314
+ } catch (err) {
1315
+ return { error: 'PLAN_UNREADABLE', message: `Could not read plan file: ${err.message}` };
1316
+ }
1317
+ }
1318
+
1319
+ if (!content || !content.trim()) {
1320
+ return { error: 'EMPTY_PLAN', message: 'Plan content is empty.' };
1321
+ }
1322
+
1323
+ const summary = String(findingsSummary || '').trim();
1324
+ if (summary.length < QC_SUMMARY_MIN_CHARS) {
1325
+ return {
1326
+ error: 'QC_SUMMARY_TOO_THIN',
1327
+ message:
1328
+ `findings_summary must be a real Proof-of-Use record of the QC gut-check ` +
1329
+ `(>= ${QC_SUMMARY_MIN_CHARS} chars; got ${summary.length}). Summarize what you ` +
1330
+ `actually checked — where the plan could be wrong, risks, breakages now/left-over, ` +
1331
+ `missing items, integrations, JTBD — not a placeholder.`,
1332
+ };
1333
+ }
1334
+
1335
+ const { body: qcPlanBody } = parsePlanFrontmatter(content);
1336
+ const qcAnalysisText = qcPlanBody && qcPlanBody.trim() ? qcPlanBody : content;
1337
+ const qcScope = extractScopeTerms(qcAnalysisText);
1338
+ const axisB = runAxisBDriftPass(qcAnalysisText, qcScope, loadMemoryAxisSpecs(vaultRoot));
1339
+ const userConfirmed = axisBUserConfirmed === true;
1340
+ const confirmationNote = String(axisBConfirmationNote || '').trim();
1341
+
1342
+ if (!/\baxis\s*b\s*:/i.test(summary)) {
1343
+ return {
1344
+ error: 'QC_AXIS_B_MISSING',
1345
+ message:
1346
+ 'findings_summary must include an "Axis B:" line documenting the design-supersession ' +
1347
+ 'drift pass ([[drift-is-two-axes-as-built-and-design-supersession]]). List governing ' +
1348
+ 'memory decisions checked and any conflicts removed/re-scoped.',
1349
+ axis_b_drift: axisB.axis_b_drift,
1350
+ axis_b_decisions_checked: axisB.axis_b_decisions_checked,
1351
+ requires_user_confirmation: axisB.axis_b_drift.length > 0,
1352
+ };
1353
+ }
1354
+
1355
+ const unresolved = axisBDriftUnresolved(axisB.axis_b_drift, summary);
1356
+
1357
+ if (unresolved.length > 0) {
1358
+ if (userConfirmed) {
1359
+ // Explicit override path: axis_b_user_confirmed: true clears whatever
1360
+ // is still unresolved, as documented, provided the confirmation note
1361
+ // meets the minimum audit-trail length. No magic phrasing or verbatim
1362
+ // decision-id mention in findings_summary/note is required — the
1363
+ // override is honored on its own terms, not ignored
1364
+ // ([[HS-EP8E-C1L2-DIAG-heyspex-plan-qc-override-path-ignores-ax]]).
1365
+ if (confirmationNote.length < QC_AXIS_B_CONFIRM_NOTE_MIN_CHARS) {
1366
+ return {
1367
+ error: 'QC_AXIS_B_CONFIRM_NOTE_TOO_THIN',
1368
+ message:
1369
+ `axis_b_user_confirmed requires axis_b_confirmation_note (>= ${QC_AXIS_B_CONFIRM_NOTE_MIN_CHARS} ` +
1370
+ 'chars) naming the governing decision and what the user confirmed.',
1371
+ axis_b_drift: unresolved,
1372
+ requires_user_confirmation: true,
1373
+ };
1374
+ }
1375
+ // Override accepted — fall through; axis_b_user_confirmed and
1376
+ // axis_b_confirmation_note get stamped into the QC marker below.
1377
+ } else {
1378
+ return {
1379
+ error: 'QC_AXIS_B_DRIFT',
1380
+ message:
1381
+ `Plan overlaps ${unresolved.length} governing memory constraint(s) (Axis B drift). ` +
1382
+ 'STOP — present each conflict to the user before recording QC: (1) default recommendation is ' +
1383
+ 're-scope/remove the dead work from the plan, OR (2) if the user explicitly confirms they ' +
1384
+ 'want to proceed anyway, re-call with axis_b_user_confirmed: true, axis_b_confirmation_note ' +
1385
+ '(audit trail), and findings_summary noting "user confirmed override". Do not silently ' +
1386
+ 'schedule work a memory decision marked dead/off-direction.',
1387
+ axis_b_drift: unresolved,
1388
+ axis_b_decisions_checked: axisB.axis_b_decisions_checked,
1389
+ requires_user_confirmation: true,
1390
+ };
1391
+ }
1392
+ }
1393
+
1394
+ const hash = createHash('sha256').update(content).digest('hex');
1395
+ const qcDirRel = join('.heyspex', 'plan-qc');
1396
+ const qcDirAbs = join(vaultRoot, qcDirRel);
1397
+ const markerRel = join(qcDirRel, `${hash}.json`);
1398
+ const marker = {
1399
+ plan_content_sha256: hash,
1400
+ qc_marker: true,
1401
+ findings_summary: summary,
1402
+ plan_path: resolvedPlanPath,
1403
+ axis_b_drift: axisB.axis_b_drift,
1404
+ axis_b_decisions_checked: axisB.axis_b_decisions_checked,
1405
+ axis_b_user_confirmed: userConfirmed && axisB.axis_b_drift.length > 0,
1406
+ axis_b_confirmation_note: userConfirmed && confirmationNote ? confirmationNote : undefined,
1407
+ };
1408
+ try {
1409
+ if (!existsSync(qcDirAbs)) mkdirSync(qcDirAbs, { recursive: true });
1410
+ writeFileSync(join(vaultRoot, markerRel), JSON.stringify(marker, null, 2), 'utf8');
1411
+ return {
1412
+ qc_marker_path: markerRel.replace(/\\/g, '/'),
1413
+ plan_content_sha256: hash,
1414
+ findings_summary_chars: summary.length,
1415
+ };
1416
+ } catch (err) {
1417
+ return { error: 'QC_MARKER_WRITE_FAILED', message: err.message, plan_content_sha256: hash };
1418
+ }
1419
+ }