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,1826 @@
1
+ /**
2
+ * Epic plan push + on-deck pinning for HeySpex MCP.
3
+ * Protocol: [[plan-ingest-agent-workflow]], [[vault-id-assignment-protocol]]
4
+ * Conflict detection: [[HS-EPIC-6RKN-spec-conflict-detection-pre-flight-check]]
5
+ * Design enforcement: [[HS-EPIC-W9ZE-design-system-enforcement-auto-surface-u]]
6
+ */
7
+
8
+ import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync } from 'node:fs';
9
+ import { join, relative } from 'node:path';
10
+ import { execFileSync } from 'node:child_process';
11
+ import { prepareVaultPlanContent } from './plan-ingest.mjs';
12
+ import { yamlScalar } from './yaml-scalar.mjs';
13
+ import {
14
+ allocateFileKey,
15
+ collectOccupiedFileKeysFromVault,
16
+ mintFamilyKey,
17
+ parseSpecId,
18
+ familyOf,
19
+ } from './spec-id-lib.mjs';
20
+ import {
21
+ buildSummaryRequiredMcpError,
22
+ formatSummaryFrontmatterField,
23
+ resolveAuthoredSummaryValue,
24
+ } from './summary-contract.mjs';
25
+
26
+ // ─────────────────────────────────────────────────────────────────────────────
27
+ // Milestone-parent resolution + bidirectional mirror-update (HS-TASK-T5PX)
28
+ // ─────────────────────────────────────────────────────────────────────────────
29
+
30
+ /**
31
+ * Normalize a milestone arg from the MCP caller into a canonical basename.
32
+ * Accepts `[[HS-MIL-XXX-slug]]`, `"[[HS-MIL-XXX-slug]]"`, or bare `HS-MIL-XXX-slug`.
33
+ * Returns null if the input doesn't look like an HS-MIL reference.
34
+ */
35
+ export function normalizeMilestoneBasename(raw) {
36
+ if (!raw || typeof raw !== 'string') return null;
37
+ const v = raw.trim().replace(/^['"]|['"]$/g, '');
38
+ const m = v.match(/^\[\[([^\]]+)\]\]$/);
39
+ const basename = m ? m[1] : v;
40
+ // Dual-read: accept type-first HS-MIL-<key>… and family-first HS-<key>-MIL…
41
+ // ([[HS-EPIC-DLE3]]).
42
+ if (parseSpecId(basename)?.typeLabel !== 'MIL') return null;
43
+ return basename;
44
+ }
45
+
46
+ /**
47
+ * Mirror-update a milestone file's `epics:` array to include the new epic
48
+ * basename. Idempotent — no-op if the entry is already present. Line-surgical
49
+ * (does not re-serialize YAML, per [[HS-DEC-vault-frontmatter-warn-dont-repair-on-read]]).
50
+ *
51
+ * Returns { changed, added, totalAfter }.
52
+ * Throws if the milestone file has no recognizable `epics:` field.
53
+ */
54
+ export function appendEpicToMilestone(vaultRoot, milestoneBasename, epicBasename) {
55
+ const milestonePath = join(vaultRoot, 'specs', 'milestones', `${milestoneBasename}.md`);
56
+ const raw = readFileSync(milestonePath, 'utf8');
57
+ const fmMatch = raw.match(/^(---\r?\n)([\s\S]*?)(\r?\n---\r?\n?)/);
58
+ if (!fmMatch) {
59
+ throw new Error(`Milestone ${milestoneBasename} missing frontmatter`);
60
+ }
61
+ const fmOpen = fmMatch[1];
62
+ let block = fmMatch[2];
63
+ const fmClose = fmMatch[3];
64
+ const body = raw.slice(fmMatch[0].length);
65
+
66
+ const blockListRe = /^epics:[ \t]*\r?\n((?:[ \t]+-[^\n]*\r?\n)+)/m;
67
+ const inlineEmptyRe = /^epics:[ \t]*\[[ \t]*\][ \t]*\r?\n/m;
68
+ const existing = new Set();
69
+
70
+ const blockListMatch = block.match(blockListRe);
71
+ if (blockListMatch) {
72
+ for (const line of blockListMatch[1].split(/\r?\n/)) {
73
+ const wm = line.match(/-[ \t]*["']?\[\[([^\]]+)\]\]["']?/);
74
+ if (wm) existing.add(wm[1]);
75
+ }
76
+ }
77
+
78
+ if (existing.has(epicBasename)) {
79
+ return { changed: false, added: null, totalAfter: existing.size };
80
+ }
81
+
82
+ const allBasenames = [...existing, epicBasename];
83
+ const newEpicsBlock =
84
+ 'epics:\n' + allBasenames.map((b) => ` - "[[${b}]]"`).join('\n') + '\n';
85
+
86
+ let nextBlock;
87
+ if (blockListMatch) {
88
+ nextBlock = block.replace(blockListRe, newEpicsBlock);
89
+ } else if (inlineEmptyRe.test(block)) {
90
+ nextBlock = block.replace(inlineEmptyRe, newEpicsBlock);
91
+ } else {
92
+ throw new Error(
93
+ `Milestone ${milestoneBasename} has no recognizable \`epics:\` field (expected block list or inline empty)`,
94
+ );
95
+ }
96
+
97
+ const nowIso = new Date().toISOString();
98
+ const updatedRe = /^updated:[ \t]*["']?[^\n"']+["']?/m;
99
+ if (updatedRe.test(nextBlock)) {
100
+ nextBlock = nextBlock.replace(updatedRe, `updated: "${nowIso}"`);
101
+ }
102
+
103
+ writeFileSync(milestonePath, `${fmOpen}${nextBlock}${fmClose}${body}`, 'utf8');
104
+ return { changed: true, added: epicBasename, totalAfter: allBasenames.length };
105
+ }
106
+
107
+ /**
108
+ * Resolve + validate a milestone arg from MCP input. Returns either a
109
+ * `{ basename, wikilink }` pair or a `{ error, message }` for the caller to
110
+ * propagate to the tool response.
111
+ */
112
+ export function resolveMilestoneArg(vaultRoot, raw) {
113
+ if (!raw?.trim?.()) {
114
+ return {
115
+ error: 'MISSING_MILESTONE',
116
+ message:
117
+ 'milestone is required — wikilink to parent HS-MIL-* (e.g. "[[HS-MIL-HMNJ-status-and-roadmap-surface-refactor]]"). See [[HS-TASK-T5PX]].',
118
+ };
119
+ }
120
+ const basename = normalizeMilestoneBasename(raw);
121
+ if (!basename) {
122
+ return {
123
+ error: 'INVALID_MILESTONE',
124
+ message: `milestone is not a valid HS-MIL wikilink: ${raw}`,
125
+ };
126
+ }
127
+ const milestonePath = join(vaultRoot, 'specs', 'milestones', `${basename}.md`);
128
+ if (!existsSync(milestonePath)) {
129
+ return {
130
+ error: 'MILESTONE_NOT_FOUND',
131
+ message: `milestone file does not exist: specs/milestones/${basename}.md`,
132
+ };
133
+ }
134
+ return { basename, wikilink: `[[${basename}]]` };
135
+ }
136
+
137
+ /**
138
+ * The family key of an already-resolved milestone (V3 shared-surname model —
139
+ * [[HS-ZU1Q-TASK-PNAS-flip-new-spec-minting-to-the-file-key]]): an epic filed
140
+ * under a milestone inherits the milestone's family — generalizing the
141
+ * epic→task pattern ([[vault-id-assignment-protocol]] §Family inheritance) —
142
+ * so a new epic's `family:` matches its parent milestone whenever one is
143
+ * derivable, else the caller mints a fresh key. Reads the milestone file's
144
+ * own frontmatter (explicit `family:` wins, else positional from `id:`), not
145
+ * just the basename, so a backfilled explicit field is honored.
146
+ */
147
+ /**
148
+ * Normalize a caller-supplied pillar reference to a bare `HS-PILR-<slug>` id.
149
+ * Accepts `[[HS-PILR-x]]`, `'HS-PILR-x'`, or `HS-PILR-x`; returns null for anything
150
+ * that is not shaped like a pillar id, so the caller falls back to the milestone's.
151
+ */
152
+ function normalizePillarRef(ref) {
153
+ if (typeof ref !== 'string') return null;
154
+ const bare = ref.replace(/\[\[|\]\]/g, '').trim().replace(/^["']|["']$/g, '');
155
+ return /^HS-PILR-[A-Za-z0-9-]+$/.test(bare) ? bare : null;
156
+ }
157
+
158
+ /**
159
+ * The authored `pillar:` on a milestone, as a bare id. Milestones have carried one
160
+ * since 2026-08-13 ([[pillar-assignment-protocol]] §Milestones), which makes it a
161
+ * reliable default for epics filed underneath them.
162
+ */
163
+ function resolveMilestonePillar(vaultRoot, milestoneBasename) {
164
+ try {
165
+ const raw = readFileSync(
166
+ join(vaultRoot, 'specs', 'milestones', `${milestoneBasename}.md`),
167
+ 'utf8',
168
+ );
169
+ const { frontmatter } = parseYamlFrontmatter(raw);
170
+ return normalizePillarRef(frontmatter.pillar);
171
+ } catch {
172
+ return null;
173
+ }
174
+ }
175
+
176
+ function resolveMilestoneFamily(vaultRoot, milestoneBasename) {
177
+ try {
178
+ const raw = readFileSync(
179
+ join(vaultRoot, 'specs', 'milestones', `${milestoneBasename}.md`),
180
+ 'utf8',
181
+ );
182
+ const { frontmatter } = parseYamlFrontmatter(raw);
183
+ return familyOf({
184
+ id: frontmatter.id,
185
+ basename: milestoneBasename,
186
+ frontmatter: { family: frontmatter.family },
187
+ });
188
+ } catch {
189
+ return null;
190
+ }
191
+ }
192
+
193
+ // ─────────────────────────────────────────────────────────────────────────────
194
+ // Design system decisions (HS-EPIC-W9ZE)
195
+ // ─────────────────────────────────────────────────────────────────────────────
196
+
197
+ const DESIGN_DECISIONS = {
198
+ 'scrollbar-transparent-track': {
199
+ summary: 'Use <ScrollArea> for ALL scrollable areas, not native overflow',
200
+ key_requirement: 'Import ScrollArea from @/components/scroll-area; do not use overflow: auto/scroll',
201
+ applies_to: ['console'],
202
+ },
203
+ 'supabase-design-system-reference': {
204
+ summary: 'Use CSS variables from globals.css, not hardcoded colors',
205
+ key_requirement: 'Reference --bg-canvas, --text-primary, etc. instead of hex colors',
206
+ applies_to: ['console'],
207
+ },
208
+ 'quick-panel-visual-language-windows11-alignment': {
209
+ summary: 'Square corners, 2px borders, mint accents for overlays',
210
+ key_requirement: 'No borderRadius or rounded-* classes in console components',
211
+ applies_to: ['console', 'companion'],
212
+ },
213
+ 'console-agent-quick-start-box-styling': {
214
+ summary: 'Blue/green callout variants with documented color tokens',
215
+ key_requirement: 'Use #60a5fa (blue-400) or emerald variants for callout boxes',
216
+ applies_to: ['console'],
217
+ },
218
+ };
219
+
220
+ /**
221
+ * Detect if plan content mentions web-app file paths. Matches the current
222
+ * `spex-console/` paths and the legacy `cockpit/` ones (legacy-cockpit; older plan text still
223
+ * uses the pre-rename paths).
224
+ * @param {string} content - Plan markdown or epic body
225
+ * @returns {boolean}
226
+ */
227
+ function detectConsolePaths(content) {
228
+ // Look for spex-console (and legacy-cockpit) file path patterns
229
+ const patterns = [
230
+ /(spex-console|cockpit)\/src\//i, // legacy-cockpit
231
+ /(spex-console|cockpit)\/components\//i, // legacy-cockpit
232
+ /(spex-console|cockpit)\/app\//i, // legacy-cockpit
233
+ /(spex-console|cockpit).*\.tsx/i, // legacy-cockpit
234
+ /(spex-console|cockpit).*\.ts/i, // legacy-cockpit
235
+ /\bsurface\b.*\b(tsx?|component)\b/i,
236
+ ];
237
+
238
+ return patterns.some(p => p.test(content));
239
+ }
240
+
241
+ /**
242
+ * Detect if plan content mentions companion file paths.
243
+ * @param {string} content - Plan markdown or epic body
244
+ * @returns {boolean}
245
+ */
246
+ function detectCompanionPaths(content) {
247
+ const patterns = [
248
+ /companion\/desktop\//i,
249
+ /companion\/extension\//i,
250
+ /HeySpex\.Companion/i,
251
+ /\borb\b.*\b(winforms?|cs|component)\b/i,
252
+ ];
253
+
254
+ return patterns.some(p => p.test(content));
255
+ }
256
+
257
+ /**
258
+ * Get applicable design decisions based on plan content.
259
+ * @param {string} content - Plan markdown or epic body
260
+ * @returns {Array<{decision: string, applies_because: string, key_requirement: string}>}
261
+ */
262
+ function getApplicableDesignDecisions(content) {
263
+ const decisions = [];
264
+ const touchesConsole = detectConsolePaths(content);
265
+ const touchesCompanion = detectCompanionPaths(content);
266
+
267
+ for (const [id, decision] of Object.entries(DESIGN_DECISIONS)) {
268
+ const applies = [];
269
+ if (touchesConsole && decision.applies_to.includes('console')) {
270
+ applies.push('spex-console/src/');
271
+ }
272
+ if (touchesCompanion && decision.applies_to.includes('companion')) {
273
+ applies.push('companion/');
274
+ }
275
+
276
+ if (applies.length > 0) {
277
+ decisions.push({
278
+ decision: id,
279
+ applies_because: `Plan touches ${applies.join(', ')}`,
280
+ key_requirement: decision.key_requirement,
281
+ summary: decision.summary,
282
+ });
283
+ }
284
+ }
285
+
286
+ return decisions;
287
+ }
288
+
289
+ // ─────────────────────────────────────────────────────────────────────────────
290
+ // Behavioral claim extraction and conflict detection (Phase 1)
291
+ // ─────────────────────────────────────────────────────────────────────────────
292
+
293
+ /**
294
+ * Extracts behavioral claims (action → outcome patterns) from markdown content.
295
+ * Looks for patterns in:
296
+ * - Tables with "Action/Gesture → Result/Outcome" columns
297
+ * - Bullet points with "X → Y" or "X: Y" patterns
298
+ * - NEXT STEP blocks with action descriptions
299
+ * @param {string} content - Markdown content
300
+ * @param {string} source - Source identifier for the claim
301
+ * @returns {Array<{action: string, outcome: string, source: string, section: string}>}
302
+ */
303
+ export function extractBehavioralClaims(content, source) {
304
+ const claims = [];
305
+
306
+ // Normalize action strings for comparison
307
+ // Key insight: "click orb", "click (no drag)", "left-click", "orb click" should all normalize similarly
308
+ const normalizeAction = (action) => {
309
+ let normalized = action
310
+ .toLowerCase()
311
+ .replace(/\bleft-click\b/g, 'click')
312
+ .replace(/\bright-click\b/g, 'right-click')
313
+ .replace(/\bdouble-click\b/g, 'double-click')
314
+ .replace(/\bpress\b/g, 'click')
315
+ .replace(/\btap\b/g, 'click')
316
+ // Remove parenthetical qualifiers like "(no drag)" for comparison
317
+ .replace(/\s*\([^)]*\)\s*/g, ' ')
318
+ // Remove markdown formatting
319
+ .replace(/\*\*/g, '')
320
+ .replace(/\s+/g, ' ')
321
+ .trim();
322
+
323
+ // Normalize "X click" to "click X" for consistent matching
324
+ const swapMatch = normalized.match(/^(\w+)\s+(click|right-click|double-click)$/);
325
+ if (swapMatch) {
326
+ normalized = `${swapMatch[2]} ${swapMatch[1]}`;
327
+ }
328
+
329
+ return normalized;
330
+ };
331
+
332
+ // Extract current section name from content position
333
+ const getSectionAt = (text, position) => {
334
+ const beforePos = text.slice(0, position);
335
+ const sectionMatches = [...beforePos.matchAll(/^#+\s+(.+)$/gm)];
336
+ if (sectionMatches.length > 0) {
337
+ return sectionMatches[sectionMatches.length - 1][1].trim();
338
+ }
339
+ return 'Unknown section';
340
+ };
341
+
342
+ // Pattern 1: Multi-column tables with Action and Result/Outcome columns
343
+ // Find all markdown tables by splitting on lines and looking for table patterns
344
+ const lines = content.split(/\r?\n/);
345
+ let inTable = false;
346
+ let tableHeaders = [];
347
+ let actionColIdx = -1;
348
+ let resultColIdx = -1;
349
+ let tableStartLine = 0;
350
+
351
+ for (let i = 0; i < lines.length; i++) {
352
+ const line = lines[i].trim();
353
+
354
+ // Check if this is a table header row (starts and ends with |, contains column names)
355
+ if (line.startsWith('|') && line.endsWith('|') && !line.includes('---')) {
356
+ const cols = line.split('|').filter(c => c.trim() !== '');
357
+
358
+ // Check if next line is separator row
359
+ if (i + 1 < lines.length) {
360
+ const nextLine = lines[i + 1].trim();
361
+ if (nextLine.startsWith('|') && nextLine.includes('---')) {
362
+ // This is a header row followed by separator
363
+ tableHeaders = cols.map(h => h.trim().toLowerCase());
364
+
365
+ // Find action column index (Action, Gesture, etc.)
366
+ actionColIdx = tableHeaders.findIndex(h =>
367
+ h.includes('action') || h.includes('gesture') || h.includes('input')
368
+ );
369
+
370
+ // Find result column index (Result, Outcome, Effect, etc.)
371
+ resultColIdx = tableHeaders.findIndex(h =>
372
+ h.includes('result') || h.includes('outcome') || h.includes('effect') || h.includes('behavior')
373
+ );
374
+
375
+ if (actionColIdx !== -1 && resultColIdx !== -1) {
376
+ inTable = true;
377
+ tableStartLine = i;
378
+ i++; // Skip the separator line
379
+ continue;
380
+ }
381
+ }
382
+ }
383
+ }
384
+
385
+ // Process table body rows
386
+ if (inTable && line.startsWith('|') && line.endsWith('|')) {
387
+ // Check if we've reached end of table (empty line or non-table line)
388
+ if (line.includes('---') || !line.includes('|')) {
389
+ inTable = false;
390
+ continue;
391
+ }
392
+
393
+ const cols = line.split('|').filter(c => c !== '');
394
+
395
+ if (cols.length > Math.max(actionColIdx, resultColIdx)) {
396
+ const actionRaw = cols[actionColIdx].replace(/\*\*/g, '').trim();
397
+ const resultRaw = cols[resultColIdx].replace(/\*\*/g, '').trim();
398
+
399
+ // Skip if this looks like a header or separator
400
+ if (actionRaw.includes('---') || resultRaw.includes('---')) continue;
401
+ if (actionRaw.toLowerCase() === 'action' || actionRaw.toLowerCase() === 'gesture') continue;
402
+
403
+ // Check if this looks like an action
404
+ const actionKeywords = ['click', 'drag', 'press', 'tap', 'hold', 'hotkey', 'escape', 'enter', 'right-click', 'shell'];
405
+ const lowerAction = actionRaw.toLowerCase();
406
+ if (actionKeywords.some(kw => lowerAction.includes(kw))) {
407
+ // Calculate approximate position for section lookup
408
+ const textBeforeLine = lines.slice(0, i).join('\n');
409
+ claims.push({
410
+ action: normalizeAction(actionRaw),
411
+ outcome: resultRaw,
412
+ source,
413
+ section: getSectionAt(content, textBeforeLine.length),
414
+ raw_action: actionRaw,
415
+ });
416
+ }
417
+ }
418
+ } else if (inTable && !line.startsWith('|')) {
419
+ // End of table
420
+ inTable = false;
421
+ }
422
+ }
423
+
424
+ // Pattern 1b: Simple 2-column tables (fallback for tables without clear headers)
425
+ const simpleTablePattern = /^\|\s*\*?\*?([^|*]+)\*?\*?\s*\|\s*([^|]+)\s*\|$/gm;
426
+ let simpleMatch;
427
+ while ((simpleMatch = simpleTablePattern.exec(content)) !== null) {
428
+ const col1 = simpleMatch[1].trim();
429
+ const col2 = simpleMatch[2].trim();
430
+
431
+ // Skip if already captured by multi-column parser, or if header/separator
432
+ if (col1.toLowerCase().includes('action') ||
433
+ col1.toLowerCase().includes('gesture') ||
434
+ col1.includes('---') ||
435
+ col2.includes('---')) {
436
+ continue;
437
+ }
438
+
439
+ // Skip ASCII art / UI mockups (lines with box drawing characters or many special chars)
440
+ if (col2.includes('│') || col2.includes('─') || col2.includes('═') ||
441
+ col2.includes('┌') || col2.includes('└') || col2.includes('□') ||
442
+ col2.length > 100) {
443
+ continue;
444
+ }
445
+
446
+ // Check if this looks like an action (contains click, drag, press, tap, etc.)
447
+ const actionKeywords = ['click', 'drag', 'press', 'tap', 'hold', 'hotkey', 'escape', 'enter', 'right-click', 'shell'];
448
+ const lowerCol1 = col1.toLowerCase();
449
+ if (actionKeywords.some(kw => lowerCol1.includes(kw))) {
450
+ // Check if we already have this claim (from multi-column parser)
451
+ const normalized = normalizeAction(col1);
452
+ const exists = claims.some(c => c.action === normalized && c.section === getSectionAt(content, simpleMatch.index));
453
+ if (!exists) {
454
+ claims.push({
455
+ action: normalized,
456
+ outcome: col2.replace(/\*\*/g, '').trim(),
457
+ source,
458
+ section: getSectionAt(content, simpleMatch.index),
459
+ raw_action: col1,
460
+ });
461
+ }
462
+ }
463
+ }
464
+
465
+ // Pattern 2: Bullet points with "action → outcome" or "action: outcome"
466
+ const bulletPattern = /^[\s]*[-*]\s*\*?\*?([^:\n→]+)\*?\*?\s*[→:]\s*(.+)$/gm;
467
+ let bulletMatch;
468
+ while ((bulletMatch = bulletPattern.exec(content)) !== null) {
469
+ const action = bulletMatch[1].trim();
470
+ const outcome = bulletMatch[2].trim();
471
+
472
+ const actionKeywords = ['click', 'drag', 'press', 'tap', 'hold', 'hotkey', 'left-click', 'right-click'];
473
+ const lowerAction = action.toLowerCase();
474
+ if (actionKeywords.some(kw => lowerAction.includes(kw))) {
475
+ claims.push({
476
+ action: normalizeAction(action),
477
+ outcome: outcome.replace(/\*\*/g, '').trim(),
478
+ source,
479
+ section: getSectionAt(content, bulletMatch.index),
480
+ raw_action: action,
481
+ });
482
+ }
483
+ }
484
+
485
+ // Pattern 3: Prose descriptions like "Click X toggles Y" or "X click opens Y"
486
+ const prosePatterns = [
487
+ /\b(click(?:ing)?|left-click|right-click|drag(?:ging)?|press(?:ing)?|tap(?:ping)?)\s+(\w+(?:\s+\w+)?)\s+(toggles?|opens?|shows?|hides?|closes?|activates?|triggers?|repositions?)\s+([^.;,\n│─═┌└□]+)/gi,
488
+ /\b(\w+(?:\s+\w+)?)\s+(click|left-click|right-click)\s+(toggles?|opens?|shows?|hides?|closes?|activates?|triggers?)\s+([^.;,\n│─═┌└□]+)/gi,
489
+ ];
490
+
491
+ for (const pattern of prosePatterns) {
492
+ let proseMatch;
493
+ while ((proseMatch = pattern.exec(content)) !== null) {
494
+ // Skip if we're in ASCII art (check surrounding context)
495
+ const contextStart = Math.max(0, proseMatch.index - 100);
496
+ const contextEnd = Math.min(content.length, proseMatch.index + proseMatch[0].length + 100);
497
+ const context = content.slice(contextStart, contextEnd);
498
+ if (context.includes('│') || context.includes('─') || context.includes('═') ||
499
+ context.includes('┌') || context.includes('└') || context.includes('□')) {
500
+ continue;
501
+ }
502
+
503
+ // Determine action and outcome based on capture groups
504
+ let action, outcome;
505
+ if (proseMatch[1].toLowerCase().match(/^(click|left-click|right-click|drag|press|tap)/i)) {
506
+ // Pattern 1: "click orb toggles shell"
507
+ action = `${proseMatch[1]} ${proseMatch[2]}`;
508
+ outcome = `${proseMatch[3]} ${proseMatch[4]}`;
509
+ } else {
510
+ // Pattern 2: "orb click toggles shell"
511
+ action = `${proseMatch[2]} ${proseMatch[1]}`;
512
+ outcome = `${proseMatch[3]} ${proseMatch[4]}`;
513
+ }
514
+
515
+ // Skip if outcome is too long (likely extracted garbage)
516
+ if (outcome.length > 100) continue;
517
+
518
+ claims.push({
519
+ action: normalizeAction(action),
520
+ outcome: outcome.trim(),
521
+ source,
522
+ section: getSectionAt(content, proseMatch.index),
523
+ raw_action: action.trim(),
524
+ });
525
+ }
526
+ }
527
+
528
+ // Deduplicate claims by action + outcome (keep first occurrence)
529
+ const seen = new Set();
530
+ const dedupedClaims = [];
531
+ for (const claim of claims) {
532
+ const key = `${claim.action}|${claim.outcome.slice(0, 50).toLowerCase()}`;
533
+ if (!seen.has(key)) {
534
+ seen.add(key);
535
+ dedupedClaims.push(claim);
536
+ }
537
+ }
538
+
539
+ return dedupedClaims;
540
+ }
541
+
542
+ /**
543
+ * Compares claims from epic against claims from linked protocols.
544
+ * @param {Array} epicClaims - Claims extracted from the epic
545
+ * @param {Array} protocolClaims - Claims extracted from linked protocols
546
+ * @returns {Array<{epic_claim: string, spec_claim: string, spec: string, section: string, action: string}>}
547
+ */
548
+ export function findConflicts(epicClaims, protocolClaims) {
549
+ const conflicts = [];
550
+
551
+ // Build a map of protocol claims by normalized action
552
+ const protocolByAction = new Map();
553
+ for (const claim of protocolClaims) {
554
+ const key = claim.action;
555
+ if (!protocolByAction.has(key)) {
556
+ protocolByAction.set(key, []);
557
+ }
558
+ protocolByAction.get(key).push(claim);
559
+ }
560
+
561
+ // Helper: check if two actions are semantically the same
562
+ const actionsMatch = (a1, a2) => {
563
+ if (a1 === a2) return true;
564
+
565
+ // Extract the core action (click, drag, etc.) and target (orb, shell, etc.)
566
+ const extractParts = (action) => {
567
+ const verb = action.match(/^(click|right-click|double-click|drag|press|tap|hold|hotkey)/)?.[1] || '';
568
+ const target = action.replace(/^(click|right-click|double-click|drag|press|tap|hold|hotkey)\s*/, '').trim();
569
+ return { verb, target };
570
+ };
571
+
572
+ const p1 = extractParts(a1);
573
+ const p2 = extractParts(a2);
574
+
575
+ // Same verb and overlapping target
576
+ if (p1.verb === p2.verb) {
577
+ // "click orb" matches "click" if one is more specific
578
+ if (!p1.target || !p2.target) return true;
579
+ // Both have targets - check for overlap
580
+ return p1.target.includes(p2.target) || p2.target.includes(p1.target);
581
+ }
582
+
583
+ return false;
584
+ };
585
+
586
+ // Compare each epic claim against protocol claims
587
+ for (const epicClaim of epicClaims) {
588
+ // Find matching protocol claims (by exact match or semantic similarity)
589
+ const matchingProtocolClaims = [];
590
+
591
+ for (const [protoAction, protoClaims] of protocolByAction.entries()) {
592
+ if (actionsMatch(epicClaim.action, protoAction)) {
593
+ matchingProtocolClaims.push(...protoClaims);
594
+ }
595
+ }
596
+
597
+ for (const protoClaim of matchingProtocolClaims) {
598
+ // Check if outcomes conflict (different verbs or targets)
599
+ const epicOutcome = epicClaim.outcome.toLowerCase();
600
+ const protoOutcome = protoClaim.outcome.toLowerCase();
601
+
602
+ // Define synonym groups for verbs
603
+ const verbSynonyms = {
604
+ 'move': ['move', 'reposition', 'drag', 'relocate'],
605
+ 'open': ['open', 'show', 'display', 'reveal'],
606
+ 'close': ['close', 'hide', 'dismiss'],
607
+ 'toggle': ['toggle'],
608
+ 'activate': ['activate', 'trigger', 'start', 'enable'],
609
+ };
610
+
611
+ // Normalize verb to its canonical form
612
+ const normalizeVerb = (verb) => {
613
+ if (!verb) return null;
614
+ verb = verb.replace(/s$/, '').toLowerCase();
615
+ for (const [canonical, synonyms] of Object.entries(verbSynonyms)) {
616
+ if (synonyms.includes(verb)) return canonical;
617
+ }
618
+ return verb;
619
+ };
620
+
621
+ // Extract main verb from outcomes
622
+ const extractVerb = (outcome) => {
623
+ const verbMatch = outcome.match(/\b(toggles?|opens?|shows?|hides?|closes?|activates?|triggers?|repositions?|moves?|displays?|dismisses?)\b/i);
624
+ return verbMatch ? normalizeVerb(verbMatch[1]) : null;
625
+ };
626
+
627
+ // Extract main target from outcomes (what is being opened/toggled/etc.)
628
+ const extractTarget = (outcome) => {
629
+ // Look for key UI elements
630
+ const targetMatch = outcome.match(/\b(shell|action panel|panel|window|menu|settings|inbox|form|orb)\b/i);
631
+ return targetMatch ? targetMatch[1].toLowerCase() : null;
632
+ };
633
+
634
+ const epicVerb = extractVerb(epicOutcome);
635
+ const protoVerb = extractVerb(protoOutcome);
636
+ const epicTarget = extractTarget(epicOutcome);
637
+ const protoTarget = extractTarget(protoOutcome);
638
+
639
+ // No conflict if verbs are in the same synonym group
640
+ if (epicVerb && protoVerb && epicVerb === protoVerb) {
641
+ // Same canonical verb - check if targets conflict
642
+ if (!epicTarget || !protoTarget) continue; // Can't determine target, no conflict
643
+ if (epicTarget === protoTarget) continue; // Same target, no conflict
644
+ if (epicTarget === 'orb' || protoTarget === 'orb') continue; // "orb" is the subject, not a target
645
+ }
646
+
647
+ // Conflict scenarios:
648
+ // 1. Different verbs for the same action (toggle vs open)
649
+ const verbConflict = epicVerb && protoVerb && epicVerb !== protoVerb;
650
+
651
+ // 2. Same verb but different targets (opens shell vs opens action panel)
652
+ const targetConflict = epicVerb && protoVerb && epicVerb === protoVerb &&
653
+ epicTarget && protoTarget && epicTarget !== protoTarget &&
654
+ epicTarget !== 'orb' && protoTarget !== 'orb';
655
+
656
+ // 3. One says toggle and the other says open (these are different behaviors)
657
+ const semanticConflict = (epicVerb === 'toggle' && protoVerb === 'open') ||
658
+ (epicVerb === 'open' && protoVerb === 'toggle');
659
+
660
+ if (verbConflict || targetConflict || semanticConflict) {
661
+ // Avoid duplicate conflicts
662
+ const conflictKey = `${epicClaim.action}|${protoClaim.source}`;
663
+ const exists = conflicts.some(c =>
664
+ `${c.action}|${c.spec}` === conflictKey
665
+ );
666
+
667
+ if (!exists) {
668
+ conflicts.push({
669
+ action: epicClaim.raw_action || epicClaim.action,
670
+ epic_claim: `${epicClaim.raw_action || epicClaim.action} → ${epicClaim.outcome}`,
671
+ spec_claim: `${protoClaim.raw_action || protoClaim.action} → ${protoClaim.outcome}`,
672
+ spec: protoClaim.source,
673
+ section: `§${protoClaim.section}`,
674
+ epic_section: `§${epicClaim.section}`,
675
+ conflict_type: verbConflict ? 'verb_mismatch' : (targetConflict ? 'target_mismatch' : 'semantic'),
676
+ });
677
+ }
678
+ }
679
+ }
680
+ }
681
+
682
+ return conflicts;
683
+ }
684
+
685
+ /**
686
+ * Extracts wikilinks from a related: frontmatter array or markdown content.
687
+ * @param {Array|string} related - The related field from frontmatter or markdown body
688
+ * @returns {string[]} - Array of basenames (without [[ ]])
689
+ */
690
+ export function extractWikilinks(related) {
691
+ const links = [];
692
+
693
+ if (Array.isArray(related)) {
694
+ for (const item of related) {
695
+ const match = String(item).match(/\[\[([^\]]+)\]\]/);
696
+ if (match) {
697
+ links.push(match[1]);
698
+ }
699
+ }
700
+ } else if (typeof related === 'string') {
701
+ const matches = related.matchAll(/\[\[([^\]]+)\]\]/g);
702
+ for (const match of matches) {
703
+ links.push(match[1]);
704
+ }
705
+ }
706
+
707
+ return links;
708
+ }
709
+
710
+ /** Wikilinks under `## Related` in spec body markdown. */
711
+ export function extractRelatedSectionWikilinks(body) {
712
+ const section = body.match(/## Related\s*\n([\s\S]*?)(?=\n## |\n---|\Z)/i);
713
+ if (!section?.[1]) return [];
714
+ const links = [];
715
+ const regex = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
716
+ let match;
717
+ while ((match = regex.exec(section[1])) !== null) {
718
+ links.push(String(match[1]).trim().replace(/\.md$/i, ''));
719
+ }
720
+ return links;
721
+ }
722
+
723
+ /** Merge frontmatter `related:` with body `## Related` wikilinks (deduped). */
724
+ export function collectEpicRelatedLinks(frontmatterRelated, body) {
725
+ const seen = new Set();
726
+ const out = [];
727
+ for (const link of [
728
+ ...extractWikilinks(frontmatterRelated),
729
+ ...extractRelatedSectionWikilinks(body || ''),
730
+ ]) {
731
+ const key = link.toLowerCase();
732
+ if (seen.has(key)) continue;
733
+ seen.add(key);
734
+ out.push(link);
735
+ }
736
+ return out;
737
+ }
738
+
739
+ /** NEXT STEP + acceptance criteria — scope for conflict checks (not problem narrative). */
740
+ export function extractConflictCheckContent(body) {
741
+ const parts = [];
742
+ const nextStep = body.match(/```[^\n]*NEXT STEP[^\n]*\n([\s\S]*?)```/i);
743
+ if (nextStep?.[1]?.trim()) parts.push(nextStep[1].trim());
744
+ const acceptance = body.match(
745
+ /## Acceptance criteria\s*\n([\s\S]*?)(?=\n## |\n---|\Z)/i,
746
+ );
747
+ if (acceptance?.[1]?.trim()) parts.push(acceptance[1].trim());
748
+ return parts.join('\n\n');
749
+ }
750
+
751
+ /**
752
+ * Resolves a wikilink basename to a file path in the vault.
753
+ * @param {string} vaultRoot - Root of the vault
754
+ * @param {string} basename - The wikilink basename
755
+ * @returns {string|null} - Full path if found, null otherwise
756
+ */
757
+ export function resolveWikilink(vaultRoot, basename) {
758
+ // Check common locations for protocols and specs
759
+ const locations = [
760
+ `specs/protocols/${basename}.md`,
761
+ `specs/tasks/${basename}.md`,
762
+ `specs/epics/${basename}.md`,
763
+ `specs/memory/decisions/${basename}.md`,
764
+ `specs/memory/lessons/${basename}.md`,
765
+ `specs/memory/references/${basename}.md`,
766
+ `specs/research/${basename}.md`,
767
+ ];
768
+
769
+ for (const loc of locations) {
770
+ const fullPath = join(vaultRoot, loc);
771
+ if (existsSync(fullPath)) {
772
+ return fullPath;
773
+ }
774
+ }
775
+
776
+ // Search by prefix in specs directories
777
+ const specDirs = ['specs/protocols', 'specs/tasks', 'specs/epics'];
778
+ for (const dir of specDirs) {
779
+ const dirPath = join(vaultRoot, dir);
780
+ if (!existsSync(dirPath)) continue;
781
+
782
+ try {
783
+ const files = readdirSync(dirPath);
784
+ for (const file of files) {
785
+ if (file.startsWith(basename) && file.endsWith('.md')) {
786
+ return join(dirPath, file);
787
+ }
788
+ }
789
+ } catch {
790
+ // Directory doesn't exist or isn't readable
791
+ }
792
+ }
793
+
794
+ return null;
795
+ }
796
+
797
+ /**
798
+ * Detects behavioral conflicts between an epic and its linked protocols.
799
+ * @param {string} vaultRoot - Root of the vault
800
+ * @param {string} epicContent - The full markdown content of the epic
801
+ * @param {Array} relatedLinks - Array of related wikilink basenames
802
+ * @returns {{conflicts: Array, linked_specs_checked: string[]}}
803
+ */
804
+ export function detectSpecConflicts(vaultRoot, epicContent, relatedLinks) {
805
+ const conflicts = [];
806
+ const linkedSpecsChecked = [];
807
+
808
+ const epicClaims = extractBehavioralClaims(
809
+ extractConflictCheckContent(epicContent),
810
+ 'epic',
811
+ );
812
+
813
+ if (epicClaims.length === 0) {
814
+ return { conflicts: [], linked_specs_checked: [] };
815
+ }
816
+
817
+ // Check each linked protocol/spec for conflicts
818
+ for (const basename of relatedLinks) {
819
+ const specPath = resolveWikilink(vaultRoot, basename);
820
+ if (!specPath) continue;
821
+
822
+ try {
823
+ const specContent = readFileSync(specPath, 'utf8');
824
+ linkedSpecsChecked.push(basename);
825
+
826
+ // Extract claims from the protocol
827
+ const protocolClaims = extractBehavioralClaims(specContent, basename);
828
+
829
+ // Find conflicts
830
+ const specConflicts = findConflicts(epicClaims, protocolClaims);
831
+ conflicts.push(...specConflicts);
832
+ } catch {
833
+ // Skip files that can't be read
834
+ }
835
+ }
836
+
837
+ return { conflicts, linked_specs_checked: linkedSpecsChecked };
838
+ }
839
+
840
+ const VAULT_ID_CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
841
+ const VAULT_ID_MIN_SUFFIX_LENGTH = 4;
842
+ // Family-first ([[HS-EPIC-DLE3]]): mint HS-<key>-<TYPE>; parse ids dual-read.
843
+ const KIND_TYPE_LABEL = { task: 'TASK', epic: 'EPIC' };
844
+
845
+ import { deriveSlug } from './promotion-helpers.mjs';
846
+ export { deriveSlug };
847
+
848
+ function randomSuffix(length, charset = VAULT_ID_CHARSET) {
849
+ let out = '';
850
+ for (let i = 0; i < length; i++) {
851
+ out += charset[Math.floor(Math.random() * charset.length)];
852
+ }
853
+ return out;
854
+ }
855
+
856
+ function capacity(charset, length) {
857
+ return charset.length ** length;
858
+ }
859
+
860
+ function suffixLengthForId(kind, id) {
861
+ const p = parseSpecId(id);
862
+ return p && p.typeLabel === KIND_TYPE_LABEL[kind] ? p.familyKey.length : null;
863
+ }
864
+
865
+ function countIdsAtSuffixLength(kind, occupied, length) {
866
+ let n = 0;
867
+ for (const id of occupied) {
868
+ if (suffixLengthForId(kind, id) === length) n++;
869
+ }
870
+ return n;
871
+ }
872
+
873
+ export function allocateRandomVaultId(kind, occupied) {
874
+ let length = VAULT_ID_MIN_SUFFIX_LENGTH;
875
+ const typeLabel = KIND_TYPE_LABEL[kind];
876
+
877
+ while (true) {
878
+ const usedAtLength = countIdsAtSuffixLength(kind, occupied, length);
879
+ if (usedAtLength >= capacity(VAULT_ID_CHARSET, length)) {
880
+ length++;
881
+ continue;
882
+ }
883
+ const maxAttempts = Math.min(capacity(VAULT_ID_CHARSET, length) - usedAtLength, 10_000);
884
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
885
+ const suffix = randomSuffix(length);
886
+ const id = `HS-${suffix}-${typeLabel}`; // family-first
887
+ if (!occupied.has(id)) return id;
888
+ }
889
+ length++;
890
+ }
891
+ }
892
+
893
+ export function collectOccupiedVaultIds(vaultRoot) {
894
+ const tasks = new Set();
895
+ const epics = new Set();
896
+
897
+ // Normalize ids/basenames of EITHER ordering to the canonical family-first form
898
+ // so suffix-collision detection works mid-migration ([[HS-EPIC-DLE3]]).
899
+ const scanDir = (subdir, kind) => {
900
+ const dir = join(vaultRoot, 'specs', subdir);
901
+ if (!existsSync(dir)) return;
902
+ const target = kind === 'task' ? tasks : epics;
903
+ const add = (value) => {
904
+ const p = parseSpecId(value);
905
+ if (p && p.typeLabel === KIND_TYPE_LABEL[kind]) target.add(`HS-${p.familyKey}-${p.typeLabel}`);
906
+ };
907
+ for (const file of readdirSync(dir)) {
908
+ if (!file.endsWith('.md') || file.endsWith('.review.md')) continue;
909
+ add(file.replace(/\.md$/i, ''));
910
+ try {
911
+ const raw = readFileSync(join(dir, file), 'utf8');
912
+ const idMatch = raw.match(/^id:\s*(\S+)/m);
913
+ if (idMatch) add(idMatch[1].replace(/^['"]|['"]$/g, ''));
914
+ } catch {
915
+ // skip
916
+ }
917
+ }
918
+ };
919
+
920
+ scanDir('tasks', 'task');
921
+ scanDir('epics', 'epic');
922
+ return { tasks, epics };
923
+ }
924
+
925
+ function isoNow() {
926
+ return new Date().toISOString();
927
+ }
928
+
929
+ /**
930
+ * Parses YAML frontmatter from a markdown file.
931
+ * @param {string} content - Full markdown file content
932
+ * @returns {{frontmatter: object, body: string, raw: string}}
933
+ */
934
+ function parseYamlFrontmatter(content) {
935
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
936
+ if (!match) return { frontmatter: {}, body: content, raw: '' };
937
+
938
+ const rawFm = match[1];
939
+ const body = match[2];
940
+
941
+ // Simple YAML parser for our use case
942
+ const frontmatter = {};
943
+ const lines = rawFm.split(/\r?\n/);
944
+ let currentKey = null;
945
+ let currentArray = null;
946
+ let inNestedObject = false;
947
+ let nestedKey = null;
948
+ let nestedArray = null;
949
+
950
+ for (const line of lines) {
951
+ // Skip empty lines
952
+ if (!line.trim()) continue;
953
+
954
+ // Check indentation level
955
+ const indent = line.match(/^(\s*)/)[1].length;
956
+
957
+ if (indent === 0 && line.includes(':')) {
958
+ // Top-level key
959
+ const [key, ...valueParts] = line.split(':');
960
+ const value = valueParts.join(':').trim();
961
+ currentKey = key.trim();
962
+
963
+ if (value === '' || value === '[]') {
964
+ frontmatter[currentKey] = [];
965
+ currentArray = frontmatter[currentKey];
966
+ } else if (value.startsWith("'") || value.startsWith('"')) {
967
+ frontmatter[currentKey] = value.replace(/^['"]|['"]$/g, '');
968
+ currentArray = null;
969
+ } else {
970
+ frontmatter[currentKey] = value;
971
+ currentArray = null;
972
+ }
973
+ inNestedObject = false;
974
+ } else if (indent > 0 && line.trim().startsWith('- ')) {
975
+ // Array item
976
+ const itemValue = line.trim().slice(2).trim().replace(/^['"]|['"]$/g, '');
977
+ if (currentArray) {
978
+ currentArray.push(itemValue);
979
+ }
980
+ }
981
+ }
982
+
983
+ return { frontmatter, body, raw: rawFm };
984
+ }
985
+
986
+ /**
987
+ * Adds an epic ID to a phase in a roadmap manifest.
988
+ * @param {string} vaultRoot - Root of the vault
989
+ * @param {string} roadmapId - Roadmap ID (e.g., 'HS-ROADMAP-PRODUCT')
990
+ * @param {string} phaseId - Phase ID (e.g., 'P0')
991
+ * @param {string} epicBasename - Epic basename to add (e.g., 'HS-EPIC-W5X8-some-slug')
992
+ * @returns {{success: boolean, message: string}}
993
+ */
994
+ export function addEpicToRoadmapPhase(vaultRoot, roadmapId, phaseId, epicBasename) {
995
+ // Find the roadmap file
996
+ const roadmapDir = join(vaultRoot, 'specs', 'roadmaps');
997
+ if (!existsSync(roadmapDir)) {
998
+ return { success: false, message: 'specs/roadmaps directory not found' };
999
+ }
1000
+
1001
+ const files = readdirSync(roadmapDir);
1002
+ let roadmapFile = null;
1003
+
1004
+ for (const file of files) {
1005
+ if (!file.endsWith('.md')) continue;
1006
+ const filePath = join(roadmapDir, file);
1007
+ const content = readFileSync(filePath, 'utf8');
1008
+ if (content.includes(`id: ${roadmapId}`) || content.includes(`id: '${roadmapId}'`)) {
1009
+ roadmapFile = filePath;
1010
+ break;
1011
+ }
1012
+ }
1013
+
1014
+ if (!roadmapFile) {
1015
+ return { success: false, message: `Roadmap ${roadmapId} not found` };
1016
+ }
1017
+
1018
+ const content = readFileSync(roadmapFile, 'utf8');
1019
+
1020
+ // Find the phase in the roadmap_manifest
1021
+ // Look for the pattern: - id: P0\n name: ...\n tasks:\n - task1\n - task2
1022
+ const phasePattern = new RegExp(
1023
+ `(- id: ${phaseId}\\s*\\n\\s*name:[^\\n]*\\n\\s*summary:[^\\n]*\\n\\s*tasks:\\s*\\n)((?:\\s*- [^\\n]+\\n)*)`,
1024
+ 'm'
1025
+ );
1026
+
1027
+ const match = content.match(phasePattern);
1028
+ if (!match) {
1029
+ return { success: false, message: `Phase ${phaseId} not found in roadmap ${roadmapId}` };
1030
+ }
1031
+
1032
+ // Check if epic is already in the phase
1033
+ if (match[2].includes(epicBasename)) {
1034
+ return { success: true, message: `Epic ${epicBasename} already in phase ${phaseId}` };
1035
+ }
1036
+
1037
+ // Add the epic to the end of the tasks list
1038
+ const existingTasks = match[2];
1039
+ const indent = existingTasks.match(/^(\s*)-/m)?.[1] || ' ';
1040
+ const newTasks = existingTasks + `${indent}- ${epicBasename}\n`;
1041
+
1042
+ const updatedContent = content.replace(phasePattern, `$1${newTasks}`);
1043
+ writeFileSync(roadmapFile, updatedContent, 'utf8');
1044
+
1045
+ return { success: true, message: `Added ${epicBasename} to phase ${phaseId} in ${roadmapId}` };
1046
+ }
1047
+
1048
+ function todayDate() {
1049
+ return isoNow().slice(0, 10);
1050
+ }
1051
+
1052
+ // YAML scalar quoting lives in the shared, dependency-free helper so every
1053
+ // frontmatter writer uses one correct rule ([[yaml-scalar]]). The old inline
1054
+ // regex only quoted when a value *contained* `:`/`"`, letting values that
1055
+ // START with a backtick or quote corrupt the frontmatter.
1056
+ const yamlQuote = yamlScalar;
1057
+
1058
+ export function buildPushWorkflowMarkdown(options = {}) {
1059
+ const onDeck = options.on_deck === true;
1060
+ const title = onDeck ? 'hs-push-next' : 'hs-push';
1061
+ const lines = [
1062
+ `# HeySpex ${title} — push epic plan to the vault`,
1063
+ '',
1064
+ 'Use the **implementation plan from this conversation** (or the user paste below) as source material.',
1065
+ '',
1066
+ '### Plan ingest (required)',
1067
+ '',
1068
+ 'External plans (Cursor `~/.cursor/plans/*.plan.md`, repo `.cursor/plans/`, or `source_plan_paths`) are **read and copied into the vault spec** under `## Canonical implementation plan (HeySpex)`. Do not leave specs that only point at proprietary paths — Claude Code cannot read them.',
1069
+ '',
1070
+ 'Pass `source_plan_paths` when the plan file path is known (e.g. from Cursor Plan mode). The tool also auto-detects `.plan.md` references inside `plan_markdown` / `implementation_prompt`.',
1071
+ '',
1072
+ '## Workflow',
1073
+ '',
1074
+ '### 1. Write specs ([[plan-ingest-agent-workflow]])',
1075
+ '',
1076
+ '**Preferred:** call MCP tool `heyspex_push_plan` with structured fields (epic_name, **summary**, plan_markdown, implementation_prompt, source_plan_paths[], on_deck). **Do not pass `children`** — tasks are filed just-in-time via `/hs-next` breakdown + `heyspex_file_spec` ([[spec-hierarchy]] §empty-state grain-split).',
1077
+ '',
1078
+ '**Or hand-author (plan ingest — epic/milestone only):**',
1079
+ '',
1080
+ '- Epic: `specs/epics/HS-EPIC-<id>-<slug>.md` — use `POST /api/specs` or random ID per [[vault-id-assignment-protocol]]; **`children: []`**',
1081
+ '- Milestone: `specs/milestones/HS-MIL-<id>-<slug>.md` when filing a milestone in the same planning wave',
1082
+ '- Do **not** hand-author task files here — tasks come from `/hs-next` breakdown + `heyspex_file_spec`',
1083
+ '- Do **not** set duplicate numeric epic slots',
1084
+ '',
1085
+ '### 2. Validate IDs',
1086
+ '',
1087
+ '```bash',
1088
+ 'npm run validate:vault-ids',
1089
+ '```',
1090
+ '',
1091
+ '### 3. Linkage + roadmap',
1092
+ '',
1093
+ '- `heyspex_validate_linkage` — fix orphans',
1094
+ '- `heyspex_register_spec` — add epic (or milestone) to manifest phase when appropriate',
1095
+ '',
1096
+ ];
1097
+
1098
+ if (onDeck) {
1099
+ lines.push(
1100
+ '### 4. On deck ([[HS-TASK-HS0U-local-mcp-hs-next-on-deck-prompt]])',
1101
+ '',
1102
+ 'When `on_deck: true` (hs-push-next), the push tool also:',
1103
+ '',
1104
+ '- Sets epic `status: in_progress`',
1105
+ '- Writes `.heyspex/focus.yaml` (`kind: epic`, `spec_path`)',
1106
+ '- Adds epic **NEXT STEP** fenced block (` ```NEXT STEP `) with `implementation_prompt`',
1107
+ '- Adds On deck callout at top of epic body',
1108
+ '',
1109
+ 'Then run `heyspex_next_on_deck` or `/hs-next` to pull the prompt.',
1110
+ '',
1111
+ 'Record the WIP ledger (`heyspex_record_wip`) for the epic if this supersedes prior focus — the prose next-session-handoff.md is retired (HS-EPIC-HNDR).',
1112
+ '',
1113
+ '### 5. Conflict pre-flight ([[HS-EPIC-6RKN-spec-conflict-detection-pre-flight-check]])',
1114
+ '',
1115
+ 'Before any files are written, the tool extracts behavioral claims (click/drag/hotkey patterns) from the NEXT STEP + acceptance criteria and compares them against the `related:` linked specs. If contradictions are found:',
1116
+ '',
1117
+ '- The push is **blocked** with `error: "CONFLICTS_DETECTED"` and a `conflicts[]` payload — **no files written, no focus pinned**.',
1118
+ '- Resolution: edit the plan to match the linked spec, edit the linked spec to match the plan, or re-run with `skip_conflict_check: true` to force.',
1119
+ '- After landing, dismissed conflicts can be recorded via the console `Dismiss with reason…` action (writes `conflict_override:` to epic frontmatter).',
1120
+ '',
1121
+ );
1122
+ }
1123
+
1124
+ lines.push(
1125
+ '## Rules',
1126
+ '',
1127
+ '- Vault is the only queue — track work in specs, not ad-hoc todos',
1128
+ '- Do not commit unless the user asks',
1129
+ '- After push, offer to run validate + linkage tools',
1130
+ '',
1131
+ );
1132
+
1133
+ if (options.epic_name) {
1134
+ lines.push(`## Target epic name`, '', options.epic_name, '');
1135
+ }
1136
+ if (options.plan_hint) {
1137
+ lines.push('## Plan hint', '', options.plan_hint, '');
1138
+ }
1139
+
1140
+ return lines.join('\n');
1141
+ }
1142
+
1143
+ function buildNextStepSection(implementationPrompt) {
1144
+ const body = implementationPrompt?.trim() || [
1145
+ '# Task: Implement this epic',
1146
+ '',
1147
+ 'CONTEXT: (fill from epic)',
1148
+ '',
1149
+ 'Read the epic body and [[CLAUDE.md]]. Break into tasks via `/hs-next` if `children: []`.',
1150
+ '',
1151
+ 'VALIDATION: npm run validate:vault-ids; relevant tests',
1152
+ '',
1153
+ 'OUTPUT: summary + spec updates. Do not commit unless asked.',
1154
+ ].join('\n');
1155
+
1156
+ return [
1157
+ '',
1158
+ '## NEXT STEP: Ready-to-paste prompt',
1159
+ '',
1160
+ 'Copy the fenced block below into a **new agent session**, or run `heyspex_next_on_deck` / `/hs-next` when focus is pinned.',
1161
+ '',
1162
+ '```NEXT STEP',
1163
+ body,
1164
+ '```',
1165
+ '',
1166
+ ].join('\n');
1167
+ }
1168
+
1169
+ function buildOnDeckBanner(reason) {
1170
+ return [
1171
+ '> [!important] On deck',
1172
+ `> **Status:** \`in_progress\` — console On Deck + MCP (\`heyspex_next_on_deck\`, \`/hs-next\`).`,
1173
+ `> Pin: \`.heyspex/focus.yaml\`${reason ? ` — ${reason}` : ''}.`,
1174
+ '',
1175
+ ].join('\n');
1176
+ }
1177
+
1178
+ // Focus is persisted to Neon `focus_selection` by the async caller
1179
+ // (cli.mjs `writeFocusSelectionVendor` → PUT /api/control/focus-selection),
1180
+ // which also records the focus-history event. There is no `.heyspex/focus.yaml`
1181
+ // / `focus-history.jsonl` writer here anymore ([[no-fallbacks-by-default]]; the
1182
+ // focus.yaml-fallback incident).
1183
+
1184
+ export function executePushPlan(vaultRoot, args) {
1185
+ const {
1186
+ epic_name: epicName,
1187
+ summary: epicSummaryArg,
1188
+ plan_markdown: planMarkdown = '',
1189
+ implementation_prompt: implementationPrompt,
1190
+ source_plan_paths: sourcePlanPaths = [],
1191
+ children = [],
1192
+ on_deck: onDeck = false,
1193
+ focus_reason: focusReason,
1194
+ kind = 'feature',
1195
+ effort = 3,
1196
+ tags = [],
1197
+ related = [],
1198
+ skip_conflict_check: skipConflictCheck = false,
1199
+ milestone,
1200
+ } = args;
1201
+
1202
+ if (!epicName?.trim()) {
1203
+ return { error: 'MISSING_EPIC_NAME', message: 'epic_name is required' };
1204
+ }
1205
+
1206
+ const epicSummary = resolveAuthoredSummaryValue(epicSummaryArg);
1207
+ if (!epicSummary) {
1208
+ return buildSummaryRequiredMcpError('epic');
1209
+ }
1210
+
1211
+ if (Array.isArray(children) && children.some((c) => c?.name?.trim())) {
1212
+ return {
1213
+ error: 'CHILDREN_NOT_SUPPORTED',
1214
+ message:
1215
+ 'heyspex_push_plan files the epic only (`children: []`). File tasks just-in-time from the What\'s Next `/hs-next` breakdown via `heyspex_file_spec` with `summary` — not via push_plan ([[spec-hierarchy]] §empty-state grain-split).',
1216
+ procedure: 'spec-hierarchy',
1217
+ };
1218
+ }
1219
+
1220
+ const milestoneResolved = resolveMilestoneArg(vaultRoot, milestone);
1221
+ if (milestoneResolved.error) {
1222
+ return milestoneResolved;
1223
+ }
1224
+
1225
+ const prepared = prepareVaultPlanContent(vaultRoot, {
1226
+ plan_markdown: planMarkdown,
1227
+ implementation_prompt: implementationPrompt,
1228
+ source_plan_paths: sourcePlanPaths,
1229
+ });
1230
+
1231
+ // V3 file-key-first minting ([[HS-ZU1Q-TASK-PNAS-flip-new-spec-minting-to-the-file-key]]):
1232
+ // `id:` keys onto the file key alone; the epic inherits its milestone
1233
+ // parent's family (falls back to a fresh mint if the milestone has none —
1234
+ // defensive, should not occur for a real milestone).
1235
+ const epicFamilyKey =
1236
+ resolveMilestoneFamily(vaultRoot, milestoneResolved.basename) ?? mintFamilyKey(vaultRoot);
1237
+ // Per-file FILE KEY (owner ruling 2026-07-10) — filename + `file_id:` SSOT,
1238
+ // and (V3) the anchor for `id:` itself.
1239
+ const epicFileKey = allocateFileKey(collectOccupiedFileKeysFromVault(vaultRoot));
1240
+ const epicId = `HS-${epicFileKey}`;
1241
+
1242
+ const epicSlug = deriveSlug(epicName);
1243
+ const epicBasename = `${epicId}-${epicFamilyKey}-EPIC-${epicSlug}`;
1244
+ const epicRelPath = `specs/epics/${epicBasename}.md`;
1245
+ const now = isoNow();
1246
+ const summaryYaml = formatSummaryFrontmatterField(epicSummary);
1247
+ let epicBody =
1248
+ prepared.plan_markdown.trim() || `# ${epicName}\n\n## Goal\n\n_Describe outcome._\n`;
1249
+ const preparedPrompt = prepared.implementation_prompt;
1250
+ if (onDeck) {
1251
+ epicBody = `${buildOnDeckBanner(focusReason)}\n${epicBody}`;
1252
+ // [[HS-TASK-G0HF]] the `## NEXT STEP` block is now an OPTIONAL authored
1253
+ // override — only emit it when an implementation_prompt was actually
1254
+ // supplied. Without one, `/hs-next` derives the brief from the epic's
1255
+ // acceptance criteria (no required NEXT STEP block, no NO_NEXT_STEP error).
1256
+ if (preparedPrompt?.trim()) {
1257
+ epicBody += buildNextStepSection(preparedPrompt);
1258
+ }
1259
+ }
1260
+
1261
+ const childrenYaml = 'children: []';
1262
+
1263
+ const tagsYaml =
1264
+ tags.length > 0 ? `tags:\n${tags.map((t) => ` - ${t}`).join('\n')}` : 'tags:\n - epic';
1265
+
1266
+ // Build related links array (user-provided + defaults)
1267
+ const userRelatedLinks = Array.isArray(related) ? related : [];
1268
+ const allRelatedLinks = [
1269
+ ...userRelatedLinks.map(r => {
1270
+ // Normalize to wikilink format
1271
+ const basename = String(r).replace(/^\[\[|\]\]$/g, '');
1272
+ return `[[${basename}]]`;
1273
+ }),
1274
+ '[[plan-ingest-agent-workflow]]',
1275
+ '[[vault-id-assignment-protocol]]',
1276
+ ];
1277
+ // Dedupe while preserving order
1278
+ const uniqueRelated = [...new Set(allRelatedLinks)];
1279
+ const relatedYaml = uniqueRelated.length > 0
1280
+ ? `related:\n${uniqueRelated.map(r => ` - '${r}'`).join('\n')}`
1281
+ : 'related: []';
1282
+
1283
+ // Pre-flight conflict detection (HS-EPIC-6RKN Phase 3).
1284
+ // For on_deck pushes, block before writing files so the agent fixes the
1285
+ // plan first. /hs-push (no on_deck) reports conflicts in next_steps but
1286
+ // doesn't block — the spec is still useful as a draft.
1287
+ let preflightConflicts = [];
1288
+ let preflightLinkedSpecs = [];
1289
+ if (!skipConflictCheck && userRelatedLinks.length > 0) {
1290
+ const fullEpicContent = [epicBody, preparedPrompt || ''].join('\n');
1291
+ const relatedBasenames = userRelatedLinks.map((r) =>
1292
+ String(r).replace(/^\[\[|\]\]$/g, ''),
1293
+ );
1294
+ const conflictResult = detectSpecConflicts(
1295
+ vaultRoot,
1296
+ fullEpicContent,
1297
+ relatedBasenames,
1298
+ );
1299
+ preflightConflicts = conflictResult.conflicts;
1300
+ preflightLinkedSpecs = conflictResult.linked_specs_checked;
1301
+
1302
+ if (onDeck && preflightConflicts.length > 0) {
1303
+ return {
1304
+ error: 'CONFLICTS_DETECTED',
1305
+ success: false,
1306
+ on_deck: true,
1307
+ blocked: true,
1308
+ message: `${preflightConflicts.length} behavioral conflict(s) detected against linked spec(s). Resolve them or pass skip_conflict_check: true to force the push.`,
1309
+ conflicts: preflightConflicts,
1310
+ linked_specs_checked: preflightLinkedSpecs,
1311
+ has_conflicts: true,
1312
+ next_steps: [
1313
+ 'Review the conflicts below — each shows a behavioral claim in this plan that contradicts a linked spec.',
1314
+ 'Resolution options:',
1315
+ ' • Edit the NEXT STEP / acceptance criteria in plan_markdown to match the linked spec, then re-run heyspex_push_plan.',
1316
+ ' • Edit the linked spec (its Interaction model section) to match this plan, then re-run heyspex_push_plan.',
1317
+ ' • Pass skip_conflict_check: true to force the push (a conflict_override row can be recorded later via the console Dismiss-with-reason action).',
1318
+ ],
1319
+ };
1320
+ }
1321
+ }
1322
+
1323
+ const epicContent = `---
1324
+ id: ${epicId}
1325
+ file_id: ${epicFileKey}
1326
+ family: ${epicFamilyKey}
1327
+ name: ${yamlQuote(epicName)}
1328
+ ${summaryYaml}
1329
+ type: epic
1330
+ kind: ${kind}
1331
+ effort: ${effort}
1332
+ requires_user_testing: false
1333
+ status: ${onDeck ? 'in_progress' : 'open'}
1334
+ owner: pwher
1335
+ created: '${now}'
1336
+ updated: '${now}'
1337
+ milestone: "${milestoneResolved.wikilink}"
1338
+ ${tagsYaml}
1339
+ ${childrenYaml}
1340
+ ${relatedYaml}
1341
+ ---
1342
+
1343
+ ${epicBody}
1344
+
1345
+ ## Change log
1346
+
1347
+ | Date | Author | Change |
1348
+ |------|--------|--------|
1349
+ | ${todayDate()} | pwher (with agent via heyspex_push_plan) | Epic created via hs-push${onDeck ? '-next' : ''}. |
1350
+ `;
1351
+
1352
+ const epicDir = join(vaultRoot, 'specs', 'epics');
1353
+ mkdirSync(epicDir, { recursive: true });
1354
+ writeFileSync(join(vaultRoot, epicRelPath), epicContent, 'utf8');
1355
+
1356
+ // Mirror-update milestone's epics: array (bidirectional integrity per HS-TASK-T5PX).
1357
+ try {
1358
+ appendEpicToMilestone(vaultRoot, milestoneResolved.basename, epicBasename);
1359
+ } catch (err) {
1360
+ return {
1361
+ error: 'MILESTONE_MIRROR_UPDATE_FAILED',
1362
+ message: `epic file written but failed to mirror-update milestone: ${err.message}`,
1363
+ epic_id: epicId,
1364
+ epic_path: epicRelPath,
1365
+ };
1366
+ }
1367
+
1368
+ // Focus write is owned by the async caller (cli.mjs `handlePushPlan` →
1369
+ // `writeFocusSelectionVendor`) so it can go to Neon `focus_selection` via the
1370
+ // console endpoint. Neon is the only focus store — no file fallback
1371
+ // ([[HS-TASK-FSEL]]; [[no-fallbacks-by-default]]). `executePushPlan` is sync
1372
+ // and must not touch Neon, so it surfaces `on_deck` + `epic.spec_path` +
1373
+ // `focus_reason` and lets the caller act.
1374
+ const computedFocusReason = onDeck
1375
+ ? focusReason || `On deck: ${epicName} (hs-push-next)`
1376
+ : null;
1377
+
1378
+ let validateOk = null;
1379
+ let validateMessage = null;
1380
+ const validateScript = join(vaultRoot, 'scripts', 'validate-vault-ids.mjs');
1381
+ if (existsSync(validateScript)) {
1382
+ try {
1383
+ execFileSync(process.execPath, [validateScript], {
1384
+ cwd: vaultRoot,
1385
+ encoding: 'utf8',
1386
+ stdio: ['ignore', 'pipe', 'pipe'],
1387
+ });
1388
+ validateOk = true;
1389
+ } catch (e) {
1390
+ validateOk = false;
1391
+ validateMessage = e.stderr?.toString() || e.message;
1392
+ }
1393
+ } else {
1394
+ validateOk = null;
1395
+ validateMessage = 'skipped (scripts/validate-vault-ids.mjs not in vault root)';
1396
+ }
1397
+
1398
+ // Reuse the pre-flight conflict detection result computed before file writes
1399
+ // (HS-EPIC-6RKN Phase 3). The on_deck path would have returned early above
1400
+ // when conflicts existed; we still surface them in the response for /hs-push
1401
+ // (no on_deck) and for the forced-push case.
1402
+ const conflicts = preflightConflicts;
1403
+ const linkedSpecsChecked = preflightLinkedSpecs;
1404
+
1405
+ // Design decision detection (HS-EPIC-W9ZE)
1406
+ const fullContent = [epicBody, preparedPrompt || ''].join('\n');
1407
+ const designDecisions = getApplicableDesignDecisions(fullContent);
1408
+
1409
+ const result = {
1410
+ success: true,
1411
+ on_deck: onDeck,
1412
+ epic: {
1413
+ id: epicId,
1414
+ basename: epicBasename,
1415
+ spec_path: epicRelPath,
1416
+ },
1417
+ children: [],
1418
+ focus_written: onDeck,
1419
+ focus_reason: computedFocusReason,
1420
+ validate_vault_ids: validateOk,
1421
+ validate_message: validateMessage,
1422
+ // Conflict detection results (HS-EPIC-6RKN)
1423
+ conflicts,
1424
+ linked_specs_checked: linkedSpecsChecked,
1425
+ has_conflicts: conflicts.length > 0,
1426
+ // Design system decisions (HS-EPIC-W9ZE)
1427
+ design_decisions: designDecisions,
1428
+ has_design_decisions: designDecisions.length > 0,
1429
+ plans_ingested: prepared.plans_ingested,
1430
+ ingested_plans: prepared.ingested_plans,
1431
+ missing_plans: prepared.missing_plans,
1432
+ next_steps: [
1433
+ 'heyspex_validate_linkage',
1434
+ onDeck
1435
+ ? 'heyspex_next_on_deck or /hs-next — break into tasks via the breakdown brief (heyspex_file_spec with summary)'
1436
+ : 'Optional: heyspex_push_plan on_deck after review',
1437
+ 'heyspex_register_spec if epic should appear in roadmap manifest',
1438
+ 'Record the WIP ledger (heyspex_record_wip) for the epic if this is the new focus (next-session-handoff.md retired — HS-EPIC-HNDR)',
1439
+ ],
1440
+ };
1441
+
1442
+ // Add conflict warning to next_steps if conflicts found
1443
+ if (conflicts.length > 0) {
1444
+ result.next_steps.unshift(
1445
+ `⚠️ ${conflicts.length} spec conflict(s) detected — review before implementation`
1446
+ );
1447
+ }
1448
+
1449
+ // Add design decision reminder if applicable
1450
+ if (designDecisions.length > 0) {
1451
+ result.next_steps.unshift(
1452
+ `📐 ${designDecisions.length} design decision(s) apply — review before implementation`
1453
+ );
1454
+ }
1455
+
1456
+ return result;
1457
+ }
1458
+
1459
+ /**
1460
+ * Retroactively create an epic + child tasks for completed work.
1461
+ * All specs are created with status: in_review and a review doc is auto-generated.
1462
+ */
1463
+ export function executeShipEpic(vaultRoot, args) {
1464
+ const {
1465
+ epic_name: epicName,
1466
+ summary = '',
1467
+ plan_markdown: planMarkdown = '',
1468
+ children = [],
1469
+ files_changed: filesChanged = [],
1470
+ kind = 'feature',
1471
+ effort = 3,
1472
+ tags = [],
1473
+ related = [],
1474
+ roadmap_id: roadmapId = 'HS-ROADMAP-PRODUCT',
1475
+ roadmap_phase: roadmapPhase = 'P0',
1476
+ skip_roadmap_registration: skipRoadmapRegistration = false,
1477
+ milestone,
1478
+ pillar,
1479
+ } = args;
1480
+
1481
+ if (!epicName?.trim()) {
1482
+ return { error: 'MISSING_EPIC_NAME', message: 'epic_name is required' };
1483
+ }
1484
+
1485
+ const epicSummary = resolveAuthoredSummaryValue(summary);
1486
+ if (!epicSummary) {
1487
+ return buildSummaryRequiredMcpError('epic');
1488
+ }
1489
+
1490
+ const milestoneResolved = resolveMilestoneArg(vaultRoot, milestone);
1491
+ if (milestoneResolved.error) {
1492
+ return milestoneResolved;
1493
+ }
1494
+
1495
+ // Every epic must carry a resolvable `pillar:` ([[pillar-assignment-protocol]]) — the
1496
+ // vault gate errors on one that does not, and before 2026-08-13 this tool emitted none
1497
+ // at all, so every backfilled epic landed red (caught when backfilling the pillar work
1498
+ // itself). Default to the parent milestone's authored pillar: milestones have carried
1499
+ // one since the same date, and an epic sharing its milestone's theme is the overwhelming
1500
+ // common case. An explicit `pillar` arg wins when the epic's own subject differs.
1501
+ const resolvedPillar =
1502
+ normalizePillarRef(pillar) ??
1503
+ resolveMilestonePillar(vaultRoot, milestoneResolved.basename) ??
1504
+ null;
1505
+ if (!resolvedPillar) {
1506
+ return {
1507
+ error: 'PILLAR_REQUIRED',
1508
+ message:
1509
+ `Cannot resolve a pillar for this epic: parent milestone "${milestoneResolved.basename}" has no \`pillar:\` and none was passed. ` +
1510
+ 'Pass `pillar: "[[HS-PILR-<slug>]]"`, or assign the milestone a pillar first. ' +
1511
+ 'Use heyspex_suggest_pillar to pick one against each pillar\'s written charter — never by name ([[pillar-assignment-protocol]]).',
1512
+ };
1513
+ }
1514
+
1515
+ // V3 file-key-first minting ([[HS-ZU1Q-TASK-PNAS-flip-new-spec-minting-to-the-file-key]]):
1516
+ // the epic inherits its milestone parent's family; each backfilled child
1517
+ // task then inherits the EPIC's family (the "original template" epic→task
1518
+ // pattern — vault-id-assignment-protocol §Family inheritance).
1519
+ const epicFamilyKey =
1520
+ resolveMilestoneFamily(vaultRoot, milestoneResolved.basename) ?? mintFamilyKey(vaultRoot);
1521
+ // Per-file FILE KEYS (owner ruling 2026-07-10) — one occupied set across the
1522
+ // whole run (epic + backfilled children are not on disk yet), and (V3) the
1523
+ // anchor for every minted `id:` here.
1524
+ const occupiedFileKeys = collectOccupiedFileKeysFromVault(vaultRoot);
1525
+ const epicFileKey = allocateFileKey(occupiedFileKeys);
1526
+ occupiedFileKeys.add(epicFileKey);
1527
+ const epicId = `HS-${epicFileKey}`;
1528
+
1529
+ const epicSlug = deriveSlug(epicName);
1530
+ const epicBasename = `${epicId}-${epicFamilyKey}-EPIC-${epicSlug}`;
1531
+ const epicRelPath = `specs/epics/${epicBasename}.md`;
1532
+ const now = isoNow();
1533
+ const childWikilinks = [];
1534
+
1535
+ const childSpecs = [];
1536
+ for (const child of children) {
1537
+ if (!child?.name?.trim()) continue;
1538
+ const childSummary = resolveAuthoredSummaryValue(child.summary);
1539
+ if (!childSummary) {
1540
+ return {
1541
+ ...buildSummaryRequiredMcpError('task'),
1542
+ message: `Child task "${child.name}" requires a plain-language \`summary\` when backfilled — pass \`summary\` on each child per spec-summary-authoring.`,
1543
+ };
1544
+ }
1545
+ const taskFileKey = allocateFileKey(occupiedFileKeys);
1546
+ occupiedFileKeys.add(taskFileKey);
1547
+ const taskId = `HS-${taskFileKey}`;
1548
+ const taskSlug = deriveSlug(child.name);
1549
+ // Inherits the epic's family — the epic→task pattern.
1550
+ const taskBasename = `${taskId}-${epicFamilyKey}-TASK-${taskSlug}`;
1551
+ const taskRelPath = `specs/tasks/${taskBasename}.md`;
1552
+ childWikilinks.push(`[[${taskBasename}]]`);
1553
+ childSpecs.push({
1554
+ id: taskId,
1555
+ fileKey: taskFileKey,
1556
+ familyKey: epicFamilyKey,
1557
+ basename: taskBasename,
1558
+ relPath: taskRelPath,
1559
+ name: child.name,
1560
+ summary: childSummary,
1561
+ });
1562
+ }
1563
+
1564
+ // Build epic body
1565
+ let epicBody = planMarkdown.trim() || `# ${epicName}\n\n${epicSummary}\n`;
1566
+ const epicSummaryYaml = formatSummaryFrontmatterField(epicSummary);
1567
+
1568
+ const childrenYaml =
1569
+ childWikilinks.length > 0
1570
+ ? `children:\n${childWikilinks.map((c) => ` - '${c}'`).join('\n')}`
1571
+ : 'children: []';
1572
+
1573
+ const allTags = tags.length > 0 ? tags : ['epic'];
1574
+ const tagsYaml = `tags:\n${allTags.map((t) => ` - ${t}`).join('\n')}`;
1575
+
1576
+ const userRelatedLinks = Array.isArray(related) ? related : [];
1577
+ const allRelatedLinks = userRelatedLinks.map(r => {
1578
+ const basename = String(r).replace(/^\[\[|\]\]$/g, '');
1579
+ return `[[${basename}]]`;
1580
+ });
1581
+ const relatedYaml = allRelatedLinks.length > 0
1582
+ ? `related:\n${allRelatedLinks.map(r => ` - '${r}'`).join('\n')}`
1583
+ : 'related: []';
1584
+
1585
+ const epicContent = `---
1586
+ id: ${epicId}
1587
+ file_id: ${epicFileKey}
1588
+ family: ${epicFamilyKey}
1589
+ name: ${yamlQuote(epicName)}
1590
+ ${epicSummaryYaml}
1591
+ type: epic
1592
+ kind: ${kind}
1593
+ effort: ${effort}
1594
+ requires_user_testing: false
1595
+ status: in_review
1596
+ owner: pwher
1597
+ created: '${now}'
1598
+ updated: '${now}'
1599
+ milestone: "${milestoneResolved.wikilink}"
1600
+ pillar: '[[${resolvedPillar}]]'
1601
+ ${tagsYaml}
1602
+ ${childrenYaml}
1603
+ ${relatedYaml}
1604
+ ---
1605
+
1606
+ ${epicBody}
1607
+
1608
+ ## Change log
1609
+
1610
+ | Date | Author | Change |
1611
+ |------|--------|--------|
1612
+ | ${todayDate()} | pwher (with agent via heyspex_backfill_epic) | Epic created retroactively, backfilled to review. |
1613
+ `;
1614
+
1615
+ const epicDir = join(vaultRoot, 'specs', 'epics');
1616
+ mkdirSync(epicDir, { recursive: true });
1617
+ writeFileSync(join(vaultRoot, epicRelPath), epicContent, 'utf8');
1618
+
1619
+ // Mirror-update milestone's epics: array (bidirectional integrity per HS-TASK-T5PX).
1620
+ try {
1621
+ appendEpicToMilestone(vaultRoot, milestoneResolved.basename, epicBasename);
1622
+ } catch (err) {
1623
+ return {
1624
+ error: 'MILESTONE_MIRROR_UPDATE_FAILED',
1625
+ message: `epic file written but failed to mirror-update milestone: ${err.message}`,
1626
+ epic_id: epicId,
1627
+ epic_path: epicRelPath,
1628
+ };
1629
+ }
1630
+
1631
+ // Write child tasks with status: in_review
1632
+ for (const child of childSpecs) {
1633
+ const taskSummaryYaml = formatSummaryFrontmatterField(child.summary);
1634
+ const taskBody =
1635
+ `# ${child.name}\n\n_Completed as part of [[${epicBasename}]]._\n`;
1636
+
1637
+ const taskContent = `---
1638
+ id: ${child.id}
1639
+ file_id: ${child.fileKey}
1640
+ family: ${child.familyKey}
1641
+ name: ${yamlQuote(child.name)}
1642
+ ${taskSummaryYaml}
1643
+ type: task
1644
+ kind: ${kind}
1645
+ effort: ${effort}
1646
+ requires_user_testing: false
1647
+ status: in_review
1648
+ owner: pwher
1649
+ created: '${now}'
1650
+ updated: '${now}'
1651
+ tags:
1652
+ - shipped
1653
+ parent: '[[${epicBasename}]]'
1654
+ ---
1655
+
1656
+ ${taskBody}
1657
+
1658
+ ## Change log
1659
+
1660
+ | Date | Author | Change |
1661
+ |------|--------|--------|
1662
+ | ${todayDate()} | pwher (with agent via heyspex_backfill_epic) | Task backfilled to review as part of [[${epicBasename}]]. |
1663
+ `;
1664
+ mkdirSync(join(vaultRoot, 'specs', 'tasks'), { recursive: true });
1665
+ writeFileSync(join(vaultRoot, child.relPath), taskContent, 'utf8');
1666
+ }
1667
+
1668
+ // Generate review doc for the epic
1669
+ const reviewRelPath = `specs/epics/${epicBasename}.review.md`;
1670
+ const filesTable = filesChanged.length > 0
1671
+ ? filesChanged.map(f => `| ${f} | Modified |`).join('\n')
1672
+ : '| _No files listed_ | - |';
1673
+
1674
+ const reviewContent = `---
1675
+ type: review
1676
+ for: '[[${epicBasename}]]'
1677
+ created: '${now}'
1678
+ ---
1679
+
1680
+ # Review: ${epicName}
1681
+
1682
+ ## Pre-Review Checklist
1683
+
1684
+ - [ ] Code changes reviewed
1685
+ - [ ] Tests passing (if applicable)
1686
+ - [ ] No regressions introduced
1687
+ - [ ] Documentation updated (if applicable)
1688
+
1689
+ ## Files Changed
1690
+
1691
+ | File | Change Type |
1692
+ |------|-------------|
1693
+ ${filesTable}
1694
+
1695
+ ## Summary
1696
+
1697
+ ${epicSummary}
1698
+
1699
+ ## Child Tasks
1700
+
1701
+ ${childWikilinks.length > 0 ? childWikilinks.map(c => `- ${c}`).join('\n') : '_No child tasks._'}
1702
+
1703
+ ## Notes
1704
+
1705
+ _Space for reviewer comments._
1706
+ `;
1707
+
1708
+ writeFileSync(join(vaultRoot, reviewRelPath), reviewContent, 'utf8');
1709
+
1710
+ // Register epic in roadmap manifest (so it appears in kanban board)
1711
+ let roadmapRegistration = null;
1712
+ if (!skipRoadmapRegistration) {
1713
+ roadmapRegistration = addEpicToRoadmapPhase(vaultRoot, roadmapId, roadmapPhase, epicBasename);
1714
+ } else {
1715
+ roadmapRegistration = { success: true, message: 'Skipped (skip_roadmap_registration: true)' };
1716
+ }
1717
+
1718
+ // Validate vault IDs
1719
+ let validateOk = null;
1720
+ let validateMessage = null;
1721
+ const validateScript = join(vaultRoot, 'scripts', 'validate-vault-ids.mjs');
1722
+ if (existsSync(validateScript)) {
1723
+ try {
1724
+ execFileSync(process.execPath, [validateScript], {
1725
+ cwd: vaultRoot,
1726
+ encoding: 'utf8',
1727
+ stdio: ['ignore', 'pipe', 'pipe'],
1728
+ });
1729
+ validateOk = true;
1730
+ } catch (e) {
1731
+ validateOk = false;
1732
+ validateMessage = e.stderr?.toString() || e.message;
1733
+ }
1734
+ } else {
1735
+ validateOk = null;
1736
+ validateMessage = 'skipped (scripts/validate-vault-ids.mjs not in vault root)';
1737
+ }
1738
+
1739
+ return {
1740
+ success: true,
1741
+ shipped_to_review: true,
1742
+ epic: {
1743
+ id: epicId,
1744
+ basename: epicBasename,
1745
+ spec_path: epicRelPath,
1746
+ },
1747
+ children: childSpecs,
1748
+ review_doc: reviewRelPath,
1749
+ roadmap_registration: roadmapRegistration,
1750
+ validate_vault_ids: validateOk,
1751
+ validate_message: validateMessage,
1752
+ next_steps: [
1753
+ roadmapRegistration?.success
1754
+ ? `Epic added to ${roadmapPhase} in ${roadmapId} — visible in Review column`
1755
+ : `⚠️ Roadmap registration failed: ${roadmapRegistration?.message}`,
1756
+ 'Human reviews the epic and child tasks',
1757
+ 'Move to status: closed after verification',
1758
+ 'Record the WIP ledger (heyspex_record_wip) for the epic if needed (next-session-handoff.md retired — HS-EPIC-HNDR)',
1759
+ ],
1760
+ };
1761
+ }
1762
+
1763
+ /**
1764
+ * Build markdown workflow instructions for /hs-backfill prompt.
1765
+ */
1766
+ export function buildShipWorkflowMarkdown(options = {}) {
1767
+ const lines = [
1768
+ '# HeySpex hs-backfill — retroactively backfill completed work into Review',
1769
+ '',
1770
+ 'Use this when you completed implementation work **without a pre-existing epic** and want to record it in the vault.',
1771
+ '',
1772
+ '## What it does',
1773
+ '',
1774
+ '1. Creates epic spec with `status: in_review`',
1775
+ '2. Creates child task specs with `status: in_review`',
1776
+ '3. Generates `{epic}.review.md` companion file',
1777
+ '4. **Registers epic in roadmap manifest** (default: `P0` of `HS-ROADMAP-PRODUCT`)',
1778
+ '',
1779
+ '> **Roadmap registration** ensures the epic appears in the kanban Review column. Without this, epics are "orphans" and won\'t show up in the board view.',
1780
+ '',
1781
+ '## Workflow',
1782
+ '',
1783
+ '### 1. Gather information',
1784
+ '',
1785
+ 'Collect from the conversation:',
1786
+ '- Epic name (what was accomplished)',
1787
+ '- Summary (brief description)',
1788
+ '- Child tasks (breakdown of work done)',
1789
+ '- Files changed (for review doc)',
1790
+ '',
1791
+ '### 2. Call MCP tool',
1792
+ '',
1793
+ '```json',
1794
+ '{',
1795
+ ' "tool": "heyspex_backfill_epic",',
1796
+ ' "arguments": {',
1797
+ ' "epic_name": "Feature Name",',
1798
+ ' "summary": "Brief description of what was done",',
1799
+ ' "plan_markdown": "## Goal\\n\\nDescription...\\n\\n## What was done\\n\\n- Item 1\\n- Item 2",',
1800
+ ' "children": [',
1801
+ ' { "name": "Task 1", "summary": "What was done" },',
1802
+ ' { "name": "Task 2", "summary": "What was done" }',
1803
+ ' ],',
1804
+ ' "files_changed": [',
1805
+ ' "path/to/file1.ts",',
1806
+ ' "path/to/file2.tsx"',
1807
+ ' ]',
1808
+ ' }',
1809
+ '}',
1810
+ '```',
1811
+ '',
1812
+ '### 3. Commit and push',
1813
+ '',
1814
+ '```bash',
1815
+ 'git add specs/',
1816
+ 'git commit -m "feat(epic): {epic-name} - backfilled to review (hs-backfill)"',
1817
+ 'git push',
1818
+ '```',
1819
+ '',
1820
+ '## Result',
1821
+ '',
1822
+ 'After running, the human can review the epic in the console Review column.',
1823
+ ];
1824
+
1825
+ return lines.join('\n');
1826
+ }