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.
- package/README.md +157 -0
- package/bin/hs.mjs +1028 -0
- package/bin/postinstall.mjs +88 -0
- package/dev-factory/lib/fleet-readiness.mjs +274 -0
- package/dev-factory/lib/fleet-state.mjs +250 -0
- package/dev-factory/lib/sufficiency-score.mjs +441 -0
- package/dist/cli/hs-check.mjs +747 -0
- package/dist/cli/hs-claim.mjs +541 -0
- package/dist/cli/hs-connect.mjs +3436 -0
- package/dist/cli/hs-defer.mjs +8455 -0
- package/dist/cli/hs-env.mjs +36845 -0
- package/dist/cli/hs-init.mjs +1145 -0
- package/dist/cli/hs-install.mjs +1390 -0
- package/dist/cli/hs-join.mjs +38553 -0
- package/dist/cli/hs-vault-pull.mjs +7782 -0
- package/dist/cli/hs-vault.mjs +4166 -0
- package/dist/cli/hs-workspace.mjs +2898 -0
- package/dist/cli-templates/customer-vault/specs/entity-model.md +1735 -0
- package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +531 -0
- package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +48 -0
- package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +548 -0
- package/docs/mcp-client-config.md +200 -0
- package/package.json +124 -0
- package/scripts/check-mcp-freshness.mjs +379 -0
- package/scripts/dev-server-status.mjs +722 -0
- package/scripts/gateway-skills-lib.mjs +421 -0
- package/scripts/lib/console-url-resolver.mjs +152 -0
- package/scripts/lib/dev-mode.mjs +369 -0
- package/scripts/lib/hs-link.mjs +154 -0
- package/scripts/lib/hs-source-commit.mjs +207 -0
- package/scripts/lib/hs-source-log.mjs +366 -0
- package/scripts/lib/hs-source-preflight.mjs +402 -0
- package/scripts/lib/hs-source-push.mjs +307 -0
- package/scripts/lib/hs-source-secret-scan.mjs +263 -0
- package/scripts/lib/standalone-doctor.mjs +89 -0
- package/scripts/vault-id-lib.mjs +173 -0
- package/vendor/heyspex-mcp/alignment-gate.mjs +1347 -0
- package/vendor/heyspex-mcp/blocker-resolver.mjs +891 -0
- package/vendor/heyspex-mcp/breakdown-ladder.mjs +309 -0
- package/vendor/heyspex-mcp/breakdown-source.mjs +236 -0
- package/vendor/heyspex-mcp/breakdown-writer.mjs +524 -0
- package/vendor/heyspex-mcp/brief-observability.mjs +67 -0
- package/vendor/heyspex-mcp/canvas-inbox.mjs +130 -0
- package/vendor/heyspex-mcp/capture-active-rehydrate.mjs +76 -0
- package/vendor/heyspex-mcp/capture-buffer-patches.mjs +147 -0
- package/vendor/heyspex-mcp/capture-channel.mjs +577 -0
- package/vendor/heyspex-mcp/capture-console-client.mjs +290 -0
- package/vendor/heyspex-mcp/capture-focus.mjs +1366 -0
- package/vendor/heyspex-mcp/capture-kind-suggest.mjs +121 -0
- package/vendor/heyspex-mcp/capture-outbox.mjs +239 -0
- package/vendor/heyspex-mcp/capture-stage-gate.mjs +160 -0
- package/vendor/heyspex-mcp/cli.mjs +15754 -0
- package/vendor/heyspex-mcp/config-settings.mjs +419 -0
- package/vendor/heyspex-mcp/console-fetch.mjs +342 -0
- package/vendor/heyspex-mcp/defer-spec.mjs +527 -0
- package/vendor/heyspex-mcp/deferred-reclaim.mjs +284 -0
- package/vendor/heyspex-mcp/design-tools.mjs +449 -0
- package/vendor/heyspex-mcp/dev-factory-bridge.mjs +66 -0
- package/vendor/heyspex-mcp/diagnose-skeleton.mjs +114 -0
- package/vendor/heyspex-mcp/fleet-queue-tools.mjs +241 -0
- package/vendor/heyspex-mcp/hooks-engine.mjs +399 -0
- package/vendor/heyspex-mcp/login-handoff.mjs +438 -0
- package/vendor/heyspex-mcp/mcp-subprocess.mjs +136 -0
- package/vendor/heyspex-mcp/mvp-mode.mjs +101 -0
- package/vendor/heyspex-mcp/next-resolution.mjs +240 -0
- package/vendor/heyspex-mcp/next-review.mjs +548 -0
- package/vendor/heyspex-mcp/peer-presence.mjs +430 -0
- package/vendor/heyspex-mcp/plan-ground-conflicts.mjs +321 -0
- package/vendor/heyspex-mcp/plan-ground.mjs +2001 -0
- package/vendor/heyspex-mcp/plan-ingest.mjs +476 -0
- package/vendor/heyspex-mcp/plan-push.mjs +1826 -0
- package/vendor/heyspex-mcp/plan-review.mjs +1419 -0
- package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
- package/vendor/heyspex-mcp/procedure-tools.mjs +970 -0
- package/vendor/heyspex-mcp/promote-capture.mjs +1280 -0
- package/vendor/heyspex-mcp/promote-diagnose.mjs +187 -0
- package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +338 -0
- package/vendor/heyspex-mcp/promote-research.mjs +1553 -0
- package/vendor/heyspex-mcp/promotion-helpers.mjs +280 -0
- package/vendor/heyspex-mcp/read-path.mjs +138 -0
- package/vendor/heyspex-mcp/recipe-tools.mjs +258 -0
- package/vendor/heyspex-mcp/regen-tasks.mjs +326 -0
- package/vendor/heyspex-mcp/register-spec.mjs +417 -0
- package/vendor/heyspex-mcp/research-focus.mjs +476 -0
- package/vendor/heyspex-mcp/research-tools.mjs +109 -0
- package/vendor/heyspex-mcp/research-type-suggest.mjs +112 -0
- package/vendor/heyspex-mcp/resolution-focus.mjs +45 -0
- package/vendor/heyspex-mcp/review-focus.mjs +55 -0
- package/vendor/heyspex-mcp/review-rollup.mjs +47 -0
- package/vendor/heyspex-mcp/run-front.mjs +60 -0
- package/vendor/heyspex-mcp/scheduler.mjs +813 -0
- package/vendor/heyspex-mcp/seat-identity.mjs +281 -0
- package/vendor/heyspex-mcp/settings-tools.mjs +119 -0
- package/vendor/heyspex-mcp/spec-id-lib.mjs +790 -0
- package/vendor/heyspex-mcp/split-research.mjs +832 -0
- package/vendor/heyspex-mcp/submit-for-review.mjs +190 -0
- package/vendor/heyspex-mcp/summary-contract.mjs +162 -0
- package/vendor/heyspex-mcp/test-helpers.mjs +43 -0
- package/vendor/heyspex-mcp/validate-linkage.mjs +574 -0
- package/vendor/heyspex-mcp/vault-resolve.mjs +297 -0
- package/vendor/heyspex-mcp/vault-settings.mjs +87 -0
- package/vendor/heyspex-mcp/verify-reproduction.mjs +314 -0
- package/vendor/heyspex-mcp/why-now.mjs +64 -0
- 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
|
+
}
|