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,1347 @@
1
+ /**
2
+ * Pre-execution alignment gate — pure logic backing the hierarchy-aware
3
+ * breakdown + corpus-drift surface that runs as the FIRST pre-flight on
4
+ * /hs-next, upstream of conflict resolution ([[pre-execution-alignment-gate]]).
5
+ *
6
+ * The gate exists because **AI agents are good at executing the wrong thing
7
+ * perfectly** ([[heyspex-north-star]]). Two surfaces:
8
+ *
9
+ * - **No children → HARD breakdown gate.** Decomposition brief replaces the
10
+ * task result. The agent must file children before executing.
11
+ * - **Children + corpus drift → SOFT drift questions.** Prepended to the
12
+ * task brief; the agent poses keep/update/drop questions to the user per
13
+ * affected child, then calls `heyspex_record_alignment` to reset the
14
+ * review timestamp. Never blocks execution.
15
+ *
16
+ * "Corpus drift" = the epic's reference corpus (`related:` + `depends_on:` +
17
+ * `milestone:`) has changed (file mtime / `updated:` frontmatter newer than
18
+ * `alignment_reviewed_at`). AGV2 redefined drift away from internal completeness
19
+ * (the v1 detectTaskDrift heuristics produced false positives) toward temporal
20
+ * staleness of *linked plans*. AGV2/DSIG removed signature hashing entirely —
21
+ * it was non-deterministic and produced false signature_changed re-fires.
22
+ *
23
+ * Pure-ish: every vault read is injected via `loadRef(idOrBasename) => spec|null`
24
+ * (same contract as blocker-resolver's `loadTask`); mtime reads via injected
25
+ * `lastModified(idOrBasename) => ms|null`. The module stays hermetic / unit-
26
+ * testable with synthetic loaders.
27
+ */
28
+
29
+ import { execFileSync } from 'node:child_process';
30
+ import { readFileSync, existsSync } from 'node:fs';
31
+ import { wikilinkTarget } from './blocker-resolver.mjs';
32
+ import { spexConsoleSettingsCandidates } from './vault-settings.mjs';
33
+
34
+ export const ALIGNMENT_FUSE_DAYS = 7;
35
+ export const DEFAULT_FOCUS_PIN_EXPIRATION_DAYS = 7;
36
+
37
+ // ─────────────────────────────────────────────────────────────────────────────
38
+ // Git-time helpers — SSOT for "when did this spec enter the vault?" The
39
+ // commit timestamp (`%ct`, seconds-since-epoch in committer's local timezone)
40
+ // is hand-edit-immune, unlike frontmatter `updated:` which agents and humans
41
+ // can spoof. Used by computeWorldStateDelta + canSkipAlignment.
42
+ // ─────────────────────────────────────────────────────────────────────────────
43
+
44
+ function runGit(vaultRoot, args) {
45
+ try {
46
+ return execFileSync('git', args, {
47
+ cwd: vaultRoot,
48
+ encoding: 'utf8',
49
+ stdio: ['ignore', 'pipe', 'ignore'],
50
+ });
51
+ } catch {
52
+ return '';
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Unix-ms when `path` was first committed under `vaultRoot`. Returns `null`
58
+ * when git has no history for it (e.g. untracked or never-committed file).
59
+ */
60
+ export function gitFirstCommitTime(vaultRoot, path) {
61
+ if (!vaultRoot || !path) return null;
62
+ const out = runGit(vaultRoot, [
63
+ 'log',
64
+ '--diff-filter=A',
65
+ '--follow',
66
+ '--format=%ct',
67
+ '--reverse',
68
+ '--',
69
+ path,
70
+ ]);
71
+ const first = out.split('\n').map((l) => l.trim()).filter(Boolean)[0];
72
+ if (!first) return null;
73
+ const secs = Number(first);
74
+ return Number.isFinite(secs) ? secs * 1000 : null;
75
+ }
76
+
77
+ /** Unix-ms of the most recent commit touching `path`. `null` when untracked. */
78
+ export function gitLastCommitTime(vaultRoot, path) {
79
+ if (!vaultRoot || !path) return null;
80
+ const out = runGit(vaultRoot, ['log', '-1', '--format=%ct', '--', path]);
81
+ const last = out.trim();
82
+ if (!last) return null;
83
+ const secs = Number(last);
84
+ return Number.isFinite(secs) ? secs * 1000 : null;
85
+ }
86
+
87
+ /**
88
+ * Sorted unique list of `specs/` paths first committed after `sinceTs` (ms).
89
+ * Backs `computeWorldStateDelta` and the `new_specs_since_breakdown` skip
90
+ * reason. `since` of `0` or NaN returns `[]` (no baseline → no delta).
91
+ */
92
+ export function gitFilesAddedSince(vaultRoot, sinceTs) {
93
+ if (!vaultRoot) return [];
94
+ if (!Number.isFinite(sinceTs) || sinceTs <= 0) return [];
95
+ const sinceSecs = Math.floor(sinceTs / 1000);
96
+ const out = runGit(vaultRoot, [
97
+ 'log',
98
+ `--since=${sinceSecs}`,
99
+ '--diff-filter=A',
100
+ '--name-only',
101
+ '--pretty=format:',
102
+ '--',
103
+ 'specs/',
104
+ ]);
105
+ return [
106
+ ...new Set(out.split('\n').map((l) => l.trim()).filter(Boolean)),
107
+ ].sort();
108
+ }
109
+
110
+ // ─────────────────────────────────────────────────────────────────────────────
111
+ // djb2 — deterministic, dependency-free hash. AGV2/DSIG retired the old
112
+ // signature scheme because it hashed mutable frontmatter; this one hashes
113
+ // only stable inputs (sorted child IDs + first-commit timestamps + git-derived
114
+ // world-state hash), so it can be safely persisted and compared.
115
+ // ─────────────────────────────────────────────────────────────────────────────
116
+
117
+ export function djb2(str) {
118
+ const s = String(str);
119
+ let h = 5381;
120
+ for (let i = 0; i < s.length; i++) {
121
+ h = ((h << 5) + h + s.charCodeAt(i)) | 0;
122
+ }
123
+ return (h >>> 0).toString(16);
124
+ }
125
+
126
+ /**
127
+ * `{ addedPaths, hash }` snapshot of new spec files added since `sinceTs` (ms).
128
+ * Hash is stable for the same input set and changes when a new spec is added
129
+ * (AC #5 for T1). When `sinceTs` is missing / invalid, returns the empty
130
+ * baseline so callers can treat it as "no delta detected yet."
131
+ */
132
+ export function computeWorldStateDelta(vaultRoot, sinceTs) {
133
+ const addedPaths = gitFilesAddedSince(vaultRoot, sinceTs);
134
+ return { addedPaths, hash: djb2(addedPaths.join('|')) };
135
+ }
136
+
137
+ /**
138
+ * Three-component alignment signature. `epicUpdated` is the parent epic's
139
+ * frontmatter `updated:` (string) — its inclusion catches deliberate scope
140
+ * edits even when no child IDs moved. `worldStateHash` ties the signature to
141
+ * the git-derived added-files snapshot.
142
+ */
143
+ export function computeAlignmentSignature({ childIds, epicUpdated, worldStateHash }) {
144
+ const sortedChildren = (Array.isArray(childIds) ? [...childIds] : [])
145
+ .map((c) => String(c).trim())
146
+ .filter(Boolean)
147
+ .sort()
148
+ .join('|');
149
+ return djb2(`${sortedChildren}|${epicUpdated || ''}|${worldStateHash || ''}`);
150
+ }
151
+
152
+ // ─────────────────────────────────────────────────────────────────────────────
153
+ // Settings-driven fuse — read spex-console/settings.json (or .heyspex/ mirror) so
154
+ // disabling `focusPinExpirationEnabled` in the UI also disables the alignment
155
+ // fuse. Returns `{ enabled, days }`; when `enabled === false`, callers must
156
+ // drop fuse_expired from the skip-reason ladder.
157
+ // ─────────────────────────────────────────────────────────────────────────────
158
+
159
+ function readJsonSafe(path) {
160
+ try {
161
+ if (!existsSync(path)) return null;
162
+ return JSON.parse(readFileSync(path, 'utf8'));
163
+ } catch {
164
+ return null;
165
+ }
166
+ }
167
+
168
+ export function readAlignmentFuseSettings(vaultRoot) {
169
+ const fallback = { enabled: true, days: DEFAULT_FOCUS_PIN_EXPIRATION_DAYS };
170
+ for (const p of spexConsoleSettingsCandidates(vaultRoot)) {
171
+ const raw = readJsonSafe(p);
172
+ if (!raw || typeof raw !== 'object') continue;
173
+ const enabled = raw.focusPinExpirationEnabled !== false; // default true
174
+ const daysRaw = raw.focusPinExpirationDays;
175
+ const days =
176
+ typeof daysRaw === 'number' && Number.isFinite(daysRaw) && daysRaw > 0
177
+ ? daysRaw
178
+ : DEFAULT_FOCUS_PIN_EXPIRATION_DAYS;
179
+ return { enabled, days };
180
+ }
181
+ return fallback;
182
+ }
183
+
184
+ // ─────────────────────────────────────────────────────────────────────────────
185
+ // canSkipAlignment — binary skip rule layered over evaluateCheckpoint. Returns
186
+ // `{ skip: true, reason: "binary_skip_rule" }` only when ALL components agree
187
+ // (no signature drift, no children predating the parent epic, no specs added
188
+ // since the breakdown, fuse not blown). Otherwise the first failing reason.
189
+ //
190
+ // Reasons: 'signature_drift' | 'older_children' | 'new_specs_since_breakdown'
191
+ // | 'fuse_expired' | 'no_checkpoint' | 'binary_skip_rule'
192
+ // ─────────────────────────────────────────────────────────────────────────────
193
+
194
+ export function canSkipAlignment(epic, vaultRoot, options = {}) {
195
+ const fm = epic?.frontmatter || {};
196
+ const reviewedAt = fm.alignment_reviewed_at;
197
+ const reviewedMs = reviewedAt ? Date.parse(String(reviewedAt)) : NaN;
198
+ if (!Number.isFinite(reviewedMs)) {
199
+ return { skip: false, reason: 'no_checkpoint' };
200
+ }
201
+
202
+ const fuse = options.fuse ?? readAlignmentFuseSettings(vaultRoot);
203
+ const now = typeof options.now === 'number' ? options.now : Date.now();
204
+ const fuseDays = options.fuseDays || fuse.days || ALIGNMENT_FUSE_DAYS;
205
+ if (fuse.enabled !== false && now - reviewedMs > fuseDays * 86_400_000) {
206
+ return { skip: false, reason: 'fuse_expired' };
207
+ }
208
+
209
+ const grain = options.grain || 'epic';
210
+ const childIds = getChildRefs(fm, grain);
211
+
212
+ // older_children: any child first-committed before the parent epic. Catches
213
+ // retroactive structuring (parent filed over pre-existing tasks) — the
214
+ // breakdown decision wasn't taken at the time the children were created, so
215
+ // alignment should be reviewed. Requires `childResolver(id) -> path` and
216
+ // `epicPath` to walk git history; without them this check is silent.
217
+ const epicPath = options.epicPath;
218
+ const childResolver = typeof options.childResolver === 'function' ? options.childResolver : null;
219
+ if (epicPath && childResolver) {
220
+ const epicFirst = gitFirstCommitTime(vaultRoot, epicPath);
221
+ if (Number.isFinite(epicFirst)) {
222
+ for (const cid of childIds) {
223
+ const childPath = childResolver(cid);
224
+ if (!childPath) continue;
225
+ const childFirst = gitFirstCommitTime(vaultRoot, childPath);
226
+ if (Number.isFinite(childFirst) && childFirst < epicFirst) {
227
+ return { skip: false, reason: 'older_children', child_id: cid };
228
+ }
229
+ }
230
+ }
231
+ }
232
+
233
+ // new_specs_since_breakdown — git-truth, immune to frontmatter spoofing.
234
+ const worldStateDelta =
235
+ options.worldStateDelta ?? computeWorldStateDelta(vaultRoot, reviewedMs);
236
+ if (worldStateDelta.addedPaths.length > 0) {
237
+ return {
238
+ skip: false,
239
+ reason: 'new_specs_since_breakdown',
240
+ world_state_delta: worldStateDelta,
241
+ };
242
+ }
243
+
244
+ // signature_drift — only fires when the epic stored a signature on its last
245
+ // alignment record AND it disagrees with the current inputs. A fresh epic
246
+ // with no `alignment_signature:` skips this check.
247
+ const currentSignature = computeAlignmentSignature({
248
+ childIds,
249
+ epicUpdated: fm.updated,
250
+ worldStateHash: worldStateDelta.hash,
251
+ });
252
+ if (fm.alignment_signature && fm.alignment_signature !== currentSignature) {
253
+ return {
254
+ skip: false,
255
+ reason: 'signature_drift',
256
+ previous: fm.alignment_signature,
257
+ current: currentSignature,
258
+ };
259
+ }
260
+
261
+ return { skip: true, reason: 'binary_skip_rule', signature: currentSignature };
262
+ }
263
+
264
+ /** Epic children live in `children:`; milestone children live in `epics:`. */
265
+ export function childRefsField(grain) {
266
+ return grain === 'milestone' ? 'epics' : 'children';
267
+ }
268
+
269
+ /** The child entity kind for a given parent grain. */
270
+ export function childKindFor(grain) {
271
+ return grain === 'milestone' ? 'epic' : 'task';
272
+ }
273
+
274
+ /** Pull the raw child-ref array (children: for epics, epics: for milestones). */
275
+ export function getChildRefs(frontmatter, grain) {
276
+ const raw = frontmatter?.[childRefsField(grain)];
277
+ if (!Array.isArray(raw)) return [];
278
+ return raw.map(wikilinkTarget).filter(Boolean);
279
+ }
280
+
281
+ // ─────────────────────────────────────────────────────────────────────────────
282
+ // Markdown section helpers (deterministic — no LLM, no network)
283
+ // ─────────────────────────────────────────────────────────────────────────────
284
+
285
+ /**
286
+ * Extract the body text under a `## Heading` (level-2) up to the next level-2
287
+ * heading. `headingRe` matches the heading text (case-insensitive). Returns ''
288
+ * when not found.
289
+ */
290
+ export function extractSection(body, headingRe) {
291
+ if (typeof body !== 'string') return '';
292
+ const lines = body.split(/\r?\n/);
293
+ let capturing = false;
294
+ const out = [];
295
+ for (const line of lines) {
296
+ const h2 = line.match(/^##\s+(.+?)\s*$/);
297
+ if (h2 && !line.startsWith('###')) {
298
+ if (capturing) break; // next level-2 heading ends the section
299
+ if (headingRe.test(h2[1])) {
300
+ capturing = true;
301
+ continue;
302
+ }
303
+ }
304
+ if (capturing) out.push(line);
305
+ }
306
+ return out.join('\n').trim();
307
+ }
308
+
309
+ /** Bullet lines (`-`/`*`, checkbox, or numbered `1.` / `1)`) → trimmed text array. */
310
+ export function extractBullets(text) {
311
+ if (typeof text !== 'string') return [];
312
+ const out = [];
313
+ for (const line of text.split(/\r?\n/)) {
314
+ const m = line.match(/^\s*(?:[-*]\s+(?:\[[ xX]\]\s+)?|\d+[.)]\s+)(.+?)\s*$/);
315
+ if (m) out.push(m[1].trim());
316
+ }
317
+ return out;
318
+ }
319
+
320
+ /**
321
+ * Parse the `## Acceptance criteria` block into decomposition seeds. When the
322
+ * block has `### subheadings` each becomes one seed (the natural one-task-per-
323
+ * section convention used across the vault, e.g. HS-EPIC-MILE → Entity type /
324
+ * Validator + MCP / All Docs filter / Specs filter / Bootstrap consistency).
325
+ * When the block is a flat checklist, returns a single seed carrying the
326
+ * bullets so the agent can split them.
327
+ *
328
+ * Returns `{ sections: [{ title, bullets }], hasSubsections }`.
329
+ */
330
+ export function parseAcceptanceSubsections(body) {
331
+ // Match both the canonical `## Acceptance criteria` and the bare `## Acceptance`
332
+ // heading variant (~48 vault specs use the latter — and the console parser
333
+ // already accepts it, so anchoring exactly here broke cross-surface parity and
334
+ // wrongly routed those specs to the Tier-3 clarification floor). [[HS-TASK-I2Y8]]
335
+ const section = extractSection(body, /^acceptance(?:\s+criteria)?$/i);
336
+ if (!section) return { sections: [], hasSubsections: false };
337
+
338
+ const lines = section.split(/\r?\n/);
339
+ const subs = [];
340
+ let current = null;
341
+ for (const line of lines) {
342
+ const h3 = line.match(/^###\s+(.+?)\s*$/);
343
+ if (h3) {
344
+ current = { title: h3[1].trim(), bullets: [] };
345
+ subs.push(current);
346
+ continue;
347
+ }
348
+ if (current) {
349
+ const b = line.match(/^\s*(?:[-*]\s+(?:\[[ xX]\]\s+)?|\d+[.)]\s+)(.+?)\s*$/);
350
+ if (b) current.bullets.push(b[1].trim());
351
+ }
352
+ }
353
+
354
+ if (subs.length > 0) {
355
+ return { sections: subs, hasSubsections: true };
356
+ }
357
+ // Flat checklist — one implicit section carrying all bullets.
358
+ const bullets = extractBullets(section);
359
+ if (bullets.length === 0) return { sections: [], hasSubsections: false };
360
+ return {
361
+ sections: [{ title: 'Acceptance criteria', bullets }],
362
+ hasSubsections: false,
363
+ };
364
+ }
365
+
366
+ /**
367
+ * First non-empty paragraph stating the spec's intent: `## Goal`, else
368
+ * `## Problem`, else `## Scope`. Scope is included because it is this vault's
369
+ * dominant task/epic-template section (most specs carry `## Scope`, not
370
+ * `## Goal`), and the readiness floor's "non-trivial goal/scope" check
371
+ * ([[HS-TASK-RW3B]]) depends on it.
372
+ */
373
+ export function extractGoal(body) {
374
+ const goal =
375
+ extractSection(body, /^goal$/i) ||
376
+ extractSection(body, /^problem$/i) ||
377
+ extractSection(body, /^scope$/i);
378
+ if (!goal) return '';
379
+ const para = goal.split(/\n\s*\n/)[0] || goal;
380
+ return para.replace(/\s+/g, ' ').trim();
381
+ }
382
+
383
+ /**
384
+ * Pull the `## Expected Breakage & Consequences` section out of a spec body
385
+ * verbatim ([[spec-hierarchy]] §Expected Breakage & Consequences).
386
+ */
387
+ function extractBreakageSection(body) {
388
+ if (!body) return null;
389
+ const lines = body.split('\n');
390
+ let start = -1;
391
+ for (let i = 0; i < lines.length; i++) {
392
+ if (/^##\s+Expected Breakage & Consequences\s*$/i.test(lines[i].trim())) {
393
+ start = i;
394
+ break;
395
+ }
396
+ }
397
+ if (start === -1) return null;
398
+ let end = lines.length;
399
+ for (let i = start + 1; i < lines.length; i++) {
400
+ if (/^#{1,2}\s+/.test(lines[i])) {
401
+ end = i;
402
+ break;
403
+ }
404
+ }
405
+ const section = lines.slice(start, end).join('\n').trim();
406
+ return section.length > 0 ? section : null;
407
+ }
408
+
409
+ // ─────────────────────────────────────────────────────────────────────────────
410
+ // Hierarchy-aware decomposition context
411
+ // ─────────────────────────────────────────────────────────────────────────────
412
+
413
+ function shapeRef(loadRef, ref) {
414
+ const target = wikilinkTarget(ref);
415
+ if (!target) return null;
416
+ const spec = loadRef(target);
417
+ if (!spec) return { ref: target, resolved: false };
418
+ const fm = spec.frontmatter || {};
419
+ return {
420
+ ref: target,
421
+ resolved: true,
422
+ id: spec.id || fm.id || target,
423
+ name: spec.name || fm.name || target,
424
+ type: spec.type || fm.type || 'spec',
425
+ status: spec.status || fm.status || 'unknown',
426
+ };
427
+ }
428
+
429
+ /**
430
+ * Gather the deterministic context an agent needs to break down (or re-align)
431
+ * a node WITHOUT open-ended search. Resolves the milestone parent + sibling
432
+ * epics so the agent gets the whole chain handed to it.
433
+ *
434
+ * `node` is `{ id, name, frontmatter, body }`. `grain` is 'epic' | 'milestone'.
435
+ */
436
+ export function extractDecompositionContext(node, loadRef, grain = 'epic') {
437
+ const fm = node.frontmatter || {};
438
+ const body = node.body || '';
439
+
440
+ const acceptance = parseAcceptanceSubsections(body);
441
+ const outOfScope = extractBullets(extractSection(body, /^out of scope/i));
442
+
443
+ const depBullets = extractBullets(extractSection(body, /^dependencies$/i));
444
+ const fmDeps = []
445
+ .concat(Array.isArray(fm.depends_on) ? fm.depends_on : [])
446
+ .concat(Array.isArray(fm.blocked_by) ? fm.blocked_by : [])
447
+ .map(wikilinkTarget)
448
+ .filter(Boolean);
449
+
450
+ const related = (Array.isArray(fm.related) ? fm.related : [])
451
+ .map((r) => shapeRef(loadRef, r))
452
+ .filter(Boolean);
453
+
454
+ // Milestone chain. For an epic, walk up via `milestone:`. For a milestone
455
+ // node itself, there's no parent — siblings are its own epics.
456
+ let milestone = null;
457
+ let milestoneBreakage = null;
458
+ if (grain === 'epic' && fm.milestone) {
459
+ const m = shapeRef(loadRef, fm.milestone);
460
+ if (m && m.resolved) {
461
+ const mSpec = loadRef(m.ref);
462
+ const mFm = mSpec?.frontmatter || {};
463
+ if (mSpec?.body) milestoneBreakage = extractBreakageSection(mSpec.body);
464
+ const selfId = (node.id || fm.id || '').toLowerCase();
465
+ const siblings = (Array.isArray(mFm.epics) ? mFm.epics : [])
466
+ .map((e) => shapeRef(loadRef, e))
467
+ .filter(Boolean)
468
+ .filter((e) => (e.id || e.ref || '').toLowerCase() !== selfId);
469
+ milestone = { ...m, siblings };
470
+ } else if (m) {
471
+ milestone = { ...m, siblings: [] };
472
+ }
473
+ }
474
+
475
+ return {
476
+ grain,
477
+ childKind: childKindFor(grain),
478
+ goal: extractGoal(body),
479
+ acceptance,
480
+ outOfScope,
481
+ dependencies: { fromBody: depBullets, fromFrontmatter: fmDeps },
482
+ related,
483
+ milestone,
484
+ tags: Array.isArray(fm.tags) ? fm.tags : [],
485
+ expectedBreakage: {
486
+ focus: extractBreakageSection(body),
487
+ milestone: milestoneBreakage,
488
+ },
489
+ };
490
+ }
491
+
492
+ // ─────────────────────────────────────────────────────────────────────────────
493
+ // Corpus-drift detection (temporal staleness of linked plans — AGV2/DCRP)
494
+ // ─────────────────────────────────────────────────────────────────────────────
495
+
496
+ /**
497
+ * Detect temporal corpus-staleness: any doc in the epic's reference corpus
498
+ * (`related:` + `depends_on:` + `milestone:`) whose last-modified time is newer
499
+ * than `alignment_reviewed_at`. Pure with injected loaders so it stays unit-
500
+ * testable; the cli wires `loadRef` to the vault loader and `lastModified` to
501
+ * `fs.statSync(...).mtimeMs` for fallback.
502
+ *
503
+ * epic.frontmatter — the focus spec's frontmatter (read `milestone`, `related`,
504
+ * `depends_on`).
505
+ * reviewedAt — ISO string. If missing or unparseable, returns no drift
506
+ * (you can't detect change without a baseline; the user records alignment
507
+ * once to establish one).
508
+ * loadRef(ref) — corpus-doc loader. Returns a spec with optional
509
+ * `frontmatter.updated` (preferred timestamp source).
510
+ * lastModified(ref)— mtime fallback when `frontmatter.updated` is missing or
511
+ * unparseable. Returns ms epoch.
512
+ *
513
+ * Returns `{ stale, hasDrift }`. `stale` items are
514
+ * `{ ref, source, lastModified }` where source ∈ 'milestone' | 'related' |
515
+ * 'depends_on' and `lastModified` is the resolved newest timestamp (ms).
516
+ */
517
+ export function detectCorpusDrift({ epic, reviewedAt, loadRef, lastModified }) {
518
+ const stale = [];
519
+ const reviewedMs = reviewedAt ? Date.parse(String(reviewedAt)) : NaN;
520
+ if (!Number.isFinite(reviewedMs)) return { stale, hasDrift: false };
521
+
522
+ const fm = epic?.frontmatter || {};
523
+ const refs = [];
524
+ if (fm.milestone) {
525
+ const t = wikilinkTarget(fm.milestone);
526
+ if (t) refs.push({ ref: t, source: 'milestone' });
527
+ }
528
+ for (const r of Array.isArray(fm.related) ? fm.related : []) {
529
+ const t = wikilinkTarget(r);
530
+ if (t) refs.push({ ref: t, source: 'related' });
531
+ }
532
+ for (const r of Array.isArray(fm.depends_on) ? fm.depends_on : []) {
533
+ const t = wikilinkTarget(r);
534
+ if (t) refs.push({ ref: t, source: 'depends_on' });
535
+ }
536
+
537
+ // De-duplicate by ref (a doc may appear in multiple lists; keep the first
538
+ // source we saw — milestone > related > depends_on by enumeration order).
539
+ const seen = new Set();
540
+ for (const item of refs) {
541
+ if (seen.has(item.ref)) continue;
542
+ seen.add(item.ref);
543
+
544
+ let ts = null;
545
+ if (loadRef) {
546
+ const spec = loadRef(item.ref);
547
+ const updated = spec?.frontmatter?.updated;
548
+ if (updated) {
549
+ const ms = Date.parse(String(updated));
550
+ if (Number.isFinite(ms)) ts = ms;
551
+ }
552
+ }
553
+ if (ts === null && typeof lastModified === 'function') {
554
+ const mtime = lastModified(item.ref);
555
+ if (Number.isFinite(mtime)) ts = mtime;
556
+ }
557
+ if (ts !== null && ts > reviewedMs) {
558
+ stale.push({ ref: item.ref, source: item.source, lastModified: ts });
559
+ }
560
+ }
561
+
562
+ return { stale, hasDrift: stale.length > 0 };
563
+ }
564
+
565
+ // ─────────────────────────────────────────────────────────────────────────────
566
+ // Checkpoint state machine (frontmatter-backed, fuse-invalidated)
567
+ // ─────────────────────────────────────────────────────────────────────────────
568
+
569
+ /**
570
+ * Decide whether the gate fires based on the timestamp checkpoint + fuse.
571
+ * Signature hashing was removed in AGV2 (HS-TASK-DSIG) — it was non-deterministic
572
+ * across reads (mutable `frontmatter.updated`) and produced false-positive
573
+ * re-fires of `signature_changed`. "Has the breakdown changed since review?" is
574
+ * now answered by corpus-staleness detection (HS-TASK-DCRP), not by hashing.
575
+ *
576
+ * `now` is injected (ms epoch) so the module stays deterministic in tests /
577
+ * harnesses where Date.now is unavailable.
578
+ * reason: 'no_checkpoint' | 'fuse_expired' | 'ok'
579
+ */
580
+ export function evaluateCheckpoint({
581
+ frontmatter,
582
+ fuseDays = ALIGNMENT_FUSE_DAYS,
583
+ now,
584
+ }) {
585
+ const fm = frontmatter || {};
586
+ const reviewedAt = fm.alignment_reviewed_at;
587
+ if (!reviewedAt) return { fire: true, reason: 'no_checkpoint' };
588
+ const reviewedMs = Date.parse(reviewedAt);
589
+ const nowMs = typeof now === 'number' ? now : Date.parse(reviewedAt);
590
+ if (Number.isFinite(reviewedMs) && Number.isFinite(nowMs)) {
591
+ if (nowMs - reviewedMs > fuseDays * 24 * 60 * 60 * 1000) {
592
+ return { fire: true, reason: 'fuse_expired' };
593
+ }
594
+ }
595
+ return { fire: false, reason: 'ok' };
596
+ }
597
+
598
+ // ─────────────────────────────────────────────────────────────────────────────
599
+ // Frontmatter checkpoint upsert (raw-text surgical edit — no lossy reserialize)
600
+ // ─────────────────────────────────────────────────────────────────────────────
601
+
602
+ /**
603
+ * Insert/replace top-level scalar keys inside the first `---…---` frontmatter
604
+ * block of `content`, preserving everything else byte-for-byte. Used by
605
+ * heyspex_record_alignment to stamp the checkpoint without round-tripping the
606
+ * whole YAML through the lossy parseSimpleYaml. Values are written as plain
607
+ * scalars (quote them yourself if they need it).
608
+ */
609
+ export function upsertFrontmatterKeys(content, kv) {
610
+ const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
611
+ if (!m) return content; // no frontmatter block — refuse to mangle the file
612
+ let block = m[1];
613
+ const eol = content.includes('\r\n') ? '\r\n' : '\n';
614
+ for (const [key, value] of Object.entries(kv)) {
615
+ const line = `${key}: ${value}`;
616
+ const keyRe = new RegExp(`^${key}:.*$`, 'm');
617
+ if (keyRe.test(block)) {
618
+ block = block.replace(keyRe, line);
619
+ } else {
620
+ block = `${block}${eol}${line}`;
621
+ }
622
+ }
623
+ return content.replace(m[0], `---${eol}${block}${eol}---`);
624
+ }
625
+
626
+ // ─────────────────────────────────────────────────────────────────────────────
627
+ // Prompt builders (the agent-facing briefs)
628
+ // ─────────────────────────────────────────────────────────────────────────────
629
+
630
+ function regroundChecklist(context, idForRecall) {
631
+ const tagList = context.tags.length
632
+ ? `[${context.tags.map((t) => `"${t}"`).join(', ')}]`
633
+ : '[]';
634
+ return [
635
+ '### 0 · Re-ground (do this first — cheap, prevents expensive mistakes)',
636
+ '',
637
+ '- Read [[heyspex-north-star]], [[architecture-foundations]], [[entity-model]] if not already fresh in context.',
638
+ `- \`heyspex_recall_memories({ tags: ${tagList}, limit: 10 })\` — surface prior lessons/decisions for this area.`,
639
+ `- \`heyspex_preflight({ intent: "file_${context.childKind}" })\` then \`heyspex_get_procedure\` on each required row (start with \`spec-hierarchy\`, then \`spec-summary-authoring\`).`,
640
+ '',
641
+ ];
642
+ }
643
+
644
+ function hierarchyContextBlock(id, name, context, opts = {}) {
645
+ const outOfScopeNote = opts.outOfScopeNote || 'do NOT file tasks for these';
646
+ const lines = ['### Hierarchy context (handed to you — no search needed)', ''];
647
+ if (context.milestone) {
648
+ const m = context.milestone;
649
+ lines.push(`- **Milestone:** \`${m.id || m.ref}\` — ${m.name || '(unresolved)'} · ${m.status || '?'}`);
650
+ if (m.siblings && m.siblings.length) {
651
+ lines.push('- **Sibling epics in this milestone:**');
652
+ for (const s of m.siblings.slice(0, 12)) {
653
+ lines.push(` - \`${s.id || s.ref}\`${s.name ? ` — ${s.name}` : ''} · ${s.status || '?'}`);
654
+ }
655
+ }
656
+ } else {
657
+ lines.push(`- **Milestone:** _none linked_ — confirm whether \`${id}\` should declare a \`milestone:\` parent.`);
658
+ }
659
+ lines.push(`- **This ${context.grain}:** \`${id}\` — ${name}`);
660
+ if (context.goal) lines.push(`- **Goal:** ${context.goal}`);
661
+ if (context.outOfScope.length) {
662
+ lines.push(`- **Out of scope (${outOfScopeNote}):**`);
663
+ for (const o of context.outOfScope.slice(0, 10)) lines.push(` - ${o}`);
664
+ }
665
+ const deps = [
666
+ ...context.dependencies.fromFrontmatter,
667
+ ...context.dependencies.fromBody,
668
+ ];
669
+ if (deps.length) {
670
+ lines.push(`- **Dependencies:** ${deps.map((d) => `\`${d}\``).join(', ')}`);
671
+ }
672
+ lines.push('');
673
+ return lines;
674
+ }
675
+
676
+ function readingListBlock(context) {
677
+ const lines = ['### Targeted reading list (read these — they bound the work)', ''];
678
+ if (context.related.length === 0) {
679
+ lines.push('_No `related:` wikilinks on this spec. Add the protocols/specs this work touches before filing._');
680
+ } else {
681
+ for (const r of context.related) {
682
+ if (r.resolved) {
683
+ lines.push(`- [[${r.ref}]] — ${r.name} · ${r.type} · ${r.status}`);
684
+ } else {
685
+ lines.push(`- [[${r.ref}]] — ⚠️ unresolved (fix this wikilink)`);
686
+ }
687
+ }
688
+ lines.push('');
689
+ lines.push('Read these FIRST. Avoid open-ended repo search — the list above is the scope.');
690
+ }
691
+ lines.push('');
692
+ return lines;
693
+ }
694
+
695
+ function parentBreakageContextBlock(id, context) {
696
+ const childKind = context.childKind;
697
+ const eb = context.expectedBreakage || {};
698
+ const lines = [
699
+ '### Expected Breakage — parent context (read before filing children)',
700
+ '',
701
+ `When authoring each child ${childKind}'s \`## Expected Breakage & Consequences\`, **pre-allocate fix-up scope from these parent sections** — drill down to concrete files/fixtures/call sites; do not paste parent prose or invent breakage in isolation ([[spec-hierarchy]] §Expected Breakage cascade).`,
702
+ '',
703
+ ];
704
+
705
+ if (childKind === 'task') {
706
+ if (eb.focus) {
707
+ lines.push(`**Epic (\`${id}\`):**`, '', eb.focus, '');
708
+ } else {
709
+ lines.push(
710
+ `**Epic (\`${id}\`):** _No \`## Expected Breakage & Consequences\` section found — fill the epic's breakage before breakdown, or note explicitly in each task why breakage is none._`,
711
+ '',
712
+ );
713
+ }
714
+ if (context.milestone) {
715
+ const mid = context.milestone.id || context.milestone.ref;
716
+ if (eb.milestone) {
717
+ lines.push(`**Milestone (\`${mid}\`):**`, '', eb.milestone, '');
718
+ } else {
719
+ lines.push(
720
+ `**Milestone (\`${mid}\`):** _No Expected Breakage section found — fill the milestone's cross-epic seam breakage at plan ingest, or note in each task._`,
721
+ '',
722
+ );
723
+ }
724
+ }
725
+ } else if (childKind === 'epic') {
726
+ if (eb.focus) {
727
+ lines.push(`**Milestone (\`${id}\`):**`, '', eb.focus, '');
728
+ } else {
729
+ lines.push(
730
+ `**Milestone (\`${id}\`):** _No \`## Expected Breakage & Consequences\` section found — author cross-epic seam breakage on this milestone before filing epics._`,
731
+ '',
732
+ );
733
+ }
734
+ }
735
+
736
+ return lines;
737
+ }
738
+
739
+ function seedBlock(_id, context) {
740
+ const lines = [`### Candidate ${context.childKind} seeds (deterministic — adjust with judgment)`, ''];
741
+ if (context.acceptance.sections.length === 0) {
742
+ lines.push(
743
+ `_No \`## Acceptance criteria\` subsections found. Derive ${context.childKind}s from the goal + the reading list, then add an Acceptance criteria section to this ${context.grain}._`,
744
+ );
745
+ lines.push('');
746
+ return lines;
747
+ }
748
+ lines.push(
749
+ `One ${context.childKind} per acceptance area below is the vault convention. These are seeds, not gospel — split, merge, or reorder as the work demands.`,
750
+ '',
751
+ );
752
+ context.acceptance.sections.forEach((s, i) => {
753
+ lines.push(`${i + 1}. **${s.title}**`);
754
+ for (const b of s.bullets.slice(0, 6)) lines.push(` - ${b}`);
755
+ if (s.bullets.length > 6) lines.push(` - …(${s.bullets.length - 6} more)`);
756
+ });
757
+ lines.push('');
758
+ lines.push(
759
+ context.childKind === 'task'
760
+ ? `Order them by \`depends_on\` so the foundation lands first. File each task via \`heyspex_file_spec\` per **How to file** below — include \`summary\` in the same call. **Tasks are filed only from this What's Next Launch \`/hs-next\` breakdown** — never during plan ingest and never alongside epics.`
761
+ : `Order them by \`depends_on\` so the foundation lands first. File each epic via \`heyspex_file_spec\` per **How to file** below — include \`summary\` in the same call. **Milestones and epics are filed in the planning phase** — never co-filed with tasks.`,
762
+ );
763
+ lines.push('');
764
+ return lines;
765
+ }
766
+
767
+ /**
768
+ * Count BLOCKER + SIBLING findings from a `runPlanReview({ scope: "breakdown" })`
769
+ * return value. Pure — no vault I/O. Callers pass the review result they already
770
+ * hold when composing the decomposition brief ([[HS-TASK-SQWL]]).
771
+ */
772
+ export function countBreakdownFindings(planReviewResult) {
773
+ if (!planReviewResult || typeof planReviewResult !== 'object') {
774
+ return { blockers: 0, siblings: 0 };
775
+ }
776
+ const blockers = Array.isArray(planReviewResult.code_audit)
777
+ ? planReviewResult.code_audit.filter((c) => c?.severity === 'BLOCKER').length
778
+ : 0;
779
+ const siblings = Array.isArray(planReviewResult.in_flight_overlap)
780
+ ? planReviewResult.in_flight_overlap.filter((o) => o?.classification === 'SIBLING').length
781
+ : 0;
782
+ return { blockers, siblings };
783
+ }
784
+
785
+ function formatBreakdownFindingsInline(planReviewResult) {
786
+ const { blockers, siblings } = countBreakdownFindings(planReviewResult);
787
+ if (blockers === 0 && siblings === 0) return '';
788
+ return `⚠ ${blockers} BLOCKER(s), ${siblings} SIBLING(s) — resolve before filing. `;
789
+ }
790
+
791
+ function filingBlock(id, context, focusSpecPath, breakdownReview) {
792
+ const childKind = context.childKind;
793
+ const milestoneRef = context.milestone ? `[[${context.milestone.id || context.milestone.ref}]]` : '(none)';
794
+ const lines = [
795
+ '### How to file (then the gate clears)',
796
+ '',
797
+ `1. For each ${childKind}: call \`heyspex_file_spec({ type: "${childKind}", name: "<title>", summary: "<plain-language summary per spec-summary-authoring>", frontmatter: { parent: "[[${id}]]"${context.grain === 'epic' ? `, milestone: "${milestoneRef}"` : ''}, depends_on: [/* ordering */] } })\` — the server allocates the \`id\`; author \`summary\` in this call (required — do not defer).${childKind === 'task' ? ' Tasks are never filed during plan ingest.' : ' Epics are never filed alongside tasks.'} Do **not** copy a sibling's frontmatter.`,
798
+ ];
799
+ if (childKind === 'task') {
800
+ const findingsInline = formatBreakdownFindingsInline(breakdownReview);
801
+ lines.push(
802
+ `1b. **Breakdown review (advisory — v1)** — once titles/scopes are drafted, call \`heyspex_plan_review({ scope: "breakdown", epic_id: "${id}", proposed_tasks: [{ title: "...", scope: "...", files: ["vendor/..."], symbols: ["fnName"] }, ...] })\`. ${findingsInline}Resolve code-audit BLOCKERs and SIBLING overlaps before filing. Advisory in v1 — does not block \`heyspex_record_alignment\`. At step 3, pass \`skipped_despite_findings: false\` if you resolved all findings before filing, or \`skipped_despite_findings: true\` if you filed despite unresolved BLOCKER/SIBLING findings — this stamps the plan-review marker for skip-rate telemetry ([[HS-TASK-MEN2]]).`,
803
+ `1c. **Expected Breakage (required per task)** — read **Expected Breakage — parent context** above. Each task body must include \`## Expected Breakage & Consequences\` naming the concrete cascade (files/fixtures/call sites) that realizes its slice of the epic's blast radius, allocated from the epic + milestone sections. Include baseline→exit ([[spec-hierarchy]] §Expected Breakage).`,
804
+ `1d. **Builder brief (required per task)** — each task must carry fleet-calibrated dispatch hints so the local Ollama builder gets the right context (not too much, not truncated). Consult \`heyspex_get_procedure({ name: "builder-fleet-capability-reference" })\` or \`node dev-factory/lib/fleet-manifest.mjs --self-test\` + \`buildFleetManifest()\` before filing.`,
805
+ '',
806
+ '**Frontmatter (optional but recommended):** `target:`, `suggested_model:`, `suggested_machine:`, `recommended_num_ctx:` (16384|32768|65536|131072), `estimated_context_tokens:`, `context_files:` (YAML list → builder `--extra`).',
807
+ '',
808
+ '**Body section `## Builder brief` (required):**',
809
+ '',
810
+ '```markdown',
811
+ '## Builder brief',
812
+ '- **Target:** `path/to/file.ts`',
813
+ '- **Context files:** `types/foo.ts`, `lib/bar.ts` (explicit — builder hallucinates without them)',
814
+ '- **Estimated context:** ~45,000 tokens',
815
+ '- **recommended_num_ctx:** 65536',
816
+ '- **suggested_model:** qwen3-coder:30b',
817
+ '- **suggested_machine:** specops-gpu1',
818
+ '- **Do not include:** entire epic body — link [[HS-…-TASK-sibling]] instead',
819
+ '```',
820
+ '',
821
+ 'Copy fleet fields from the breakdown JSON draft (`suggested_model`, `suggested_machine`, `estimated_context_tokens`, `recommended_num_ctx`, `context_files`, `fit_assessment`). For epics >~100K estimated context, split tasks — do not assign one monolithic local build.',
822
+ '',
823
+ );
824
+ } else if (childKind === 'epic') {
825
+ lines.push(
826
+ `1b. **Expected Breakage (required per epic)** — read **Expected Breakage — parent context** above. Each epic body must include \`## Expected Breakage & Consequences\` naming subsystem blast radius allocated from the milestone's cross-epic seams ([[spec-hierarchy]] §Expected Breakage cascade).`,
827
+ '',
828
+ );
829
+ }
830
+ lines.push(
831
+ `2. Update \`${id}\` \`${childRefsField(context.grain)}:\` to list every new ${childKind} as a \`[[wikilink]]\`.`,
832
+ `3. Record the checkpoint — the response carries the next on-deck brief inline (no \`/hs-next\` re-invoke needed):`,
833
+ ` \`heyspex_record_alignment({ spec_id: "${id}", summary: "<what you broke it into>", skipped_despite_findings: false })\` — set \`skipped_despite_findings: true\` only when you filed despite unresolved BLOCKER/SIBLING findings from step 1b.`,
834
+ '',
835
+ `_Pinned via \`.heyspex/focus.yaml\` (\`${focusSpecPath || 'n/a'}\`). This ${context.grain} has no ${childKind}s — surfacing a breakdown brief instead of a body excerpt._`,
836
+ );
837
+ return lines;
838
+ }
839
+
840
+ // ─────────────────────────────────────────────────────────────────────────────
841
+ // world-state delta sections — surfaced in BOTH the decomposition brief and
842
+ // the drift brief when the canSkipAlignment pre-pass found a non-empty world-
843
+ // state delta (new files or graph-linked changes since alignment_reviewed_at).
844
+ // Deterministic enumeration: agents read this list instead of grepping.
845
+ // ─────────────────────────────────────────────────────────────────────────────
846
+
847
+ const KIND_FROM_PATH = [
848
+ { match: /^specs\/research\//i, label: 'research' },
849
+ { match: /^specs\/memory\/decisions\//i, label: 'decisions' },
850
+ { match: /^specs\/memory\/lessons\//i, label: 'lessons' },
851
+ { match: /^specs\/memory\/references\//i, label: 'references' },
852
+ { match: /^specs\/memory\/ui-patterns\//i, label: 'ui-patterns' },
853
+ { match: /^specs\/tasks\//i, label: 'sibling Tasks' },
854
+ { match: /^specs\/epics\//i, label: 'sibling Epics' },
855
+ { match: /^specs\/milestones?\//i, label: 'sibling Milestones' },
856
+ { match: /^specs\/protocols\//i, label: 'protocols' },
857
+ { match: /^specs\/adr\//i, label: 'ADRs' },
858
+ ];
859
+
860
+ function kindForPath(p) {
861
+ for (const { match, label } of KIND_FROM_PATH) {
862
+ if (match.test(p)) return label;
863
+ }
864
+ return 'other';
865
+ }
866
+
867
+ function groupByKind(paths) {
868
+ const groups = new Map();
869
+ for (const p of paths) {
870
+ const k = kindForPath(p);
871
+ if (!groups.has(k)) groups.set(k, []);
872
+ groups.get(k).push(p);
873
+ }
874
+ return groups;
875
+ }
876
+
877
+ /**
878
+ * Body-grep helper: does `body` wikilink any of `targetIds`? Cheap substring
879
+ * match — wikilinks are unique enough that false positives are rare. Used by
880
+ * worldStateDeltaSections to compute the graph_delta subset.
881
+ */
882
+ function bodyLinksAny(body, targetIds) {
883
+ if (typeof body !== 'string' || body.length === 0) return false;
884
+ for (const id of targetIds) {
885
+ if (!id) continue;
886
+ if (body.includes(`[[${id}]]`) || body.includes(`[[${id}-`)) return true;
887
+ }
888
+ return false;
889
+ }
890
+
891
+ /**
892
+ * Render the three world-state delta sections (when non-empty) plus the
893
+ * 4-option menu. `delta.addedPaths` is the git-derived list of `specs/` files
894
+ * first committed since `alignment_reviewed_at`. `delta.modifiedPaths` is the
895
+ * (optional) caller-supplied list of files touched since baseline.
896
+ * `loadByPath(path) -> spec|null` enables the graph_delta carve-out: specs
897
+ * whose body wikilinks the focus epic or any of its children.
898
+ */
899
+ export function buildWorldStateDeltaSections({
900
+ delta,
901
+ focusId,
902
+ childIds = [],
903
+ loadByPath,
904
+ }) {
905
+ if (!delta || (!delta.addedPaths?.length && !delta.modifiedPaths?.length)) {
906
+ return [];
907
+ }
908
+ const addedPaths = Array.isArray(delta.addedPaths) ? delta.addedPaths : [];
909
+ const modifiedPaths = Array.isArray(delta.modifiedPaths) ? delta.modifiedPaths : [];
910
+ const lines = [];
911
+
912
+ if (addedPaths.length) {
913
+ lines.push('### world_state_new (specs added since the breakdown)', '');
914
+ const grouped = groupByKind(addedPaths);
915
+ const labelsInOrder = [
916
+ 'research',
917
+ 'decisions',
918
+ 'lessons',
919
+ 'references',
920
+ 'ui-patterns',
921
+ 'sibling Tasks',
922
+ 'sibling Epics',
923
+ 'sibling Milestones',
924
+ 'protocols',
925
+ 'ADRs',
926
+ 'other',
927
+ ];
928
+ for (const label of labelsInOrder) {
929
+ const arr = grouped.get(label);
930
+ if (!arr || arr.length === 0) continue;
931
+ lines.push(`- **${label}** (${arr.length})`);
932
+ for (const p of arr.slice(0, 12)) lines.push(` - \`${p}\``);
933
+ if (arr.length > 12) lines.push(` - …(${arr.length - 12} more)`);
934
+ }
935
+ lines.push('');
936
+ }
937
+
938
+ if (modifiedPaths.length) {
939
+ lines.push('### world_state_modified (existing specs touched since the breakdown)', '');
940
+ for (const p of modifiedPaths.slice(0, 20)) lines.push(`- \`${p}\``);
941
+ if (modifiedPaths.length > 20) lines.push(`- …(${modifiedPaths.length - 20} more)`);
942
+ lines.push('');
943
+ }
944
+
945
+ // graph_delta — subset of addedPaths whose body wikilinks the focus epic or
946
+ // any of its existing children. Optional (requires a loader); when absent,
947
+ // the section is skipped.
948
+ if (addedPaths.length && typeof loadByPath === 'function') {
949
+ const targets = [focusId, ...childIds].filter(Boolean);
950
+ const graphHits = [];
951
+ for (const p of addedPaths) {
952
+ let spec = null;
953
+ try {
954
+ spec = loadByPath(p);
955
+ } catch {
956
+ spec = null;
957
+ }
958
+ if (spec && bodyLinksAny(spec.body || '', targets)) {
959
+ graphHits.push({ path: p, id: spec.id || null, name: spec.name || null });
960
+ }
961
+ }
962
+ if (graphHits.length) {
963
+ lines.push(
964
+ `### graph_delta (new specs that wikilink \`${focusId || 'this'}\` or its children)`,
965
+ '',
966
+ );
967
+ for (const h of graphHits.slice(0, 20)) {
968
+ const label = h.id ? `\`${h.id}\`${h.name ? ` — ${h.name}` : ''}` : `\`${h.path}\``;
969
+ lines.push(`- ${label}`);
970
+ }
971
+ if (graphHits.length > 20) lines.push(`- …(${graphHits.length - 20} more)`);
972
+ lines.push('');
973
+ }
974
+ }
975
+
976
+ return lines;
977
+ }
978
+
979
+ /**
980
+ * 4-option menu rendered at the end of the brief — see [[HS-TASK-SQGT]] (skill
981
+ * reply parser). The agent surfaces these options to the user; the user picks
982
+ * one verbatim. Options [2]/[3] dispatch the HSN1 regen tools ([[HS-TASK-0CKW]])
983
+ * once they land; until then the skill returns a stub.
984
+ */
985
+ export function buildFourOptionMenu({ focusId, claimableId } = {}) {
986
+ const target = claimableId || focusId || 'this spec';
987
+ return [
988
+ '### Your options',
989
+ '',
990
+ `- **[1] proceed** — accept the brief, claim \`${target}\`, start executing. Reply: \`1\` / \`proceed\` / \`yes\`.`,
991
+ '- **[2] regen_task** — re-derive this task from its parent epic + linked plans. Old version is soft-deleted to `.trash/`. Reply: `2` / `refresh task`.',
992
+ '- **[3] regen_tasks_for_epic** — regenerate every task under the focus epic (Stage 1 generate → Stage 2 reconcile → Stage 3 retire). Reply: `3` / `refresh epic`.',
993
+ '- **[4] defer** — close the brief without claiming. Reply: `4` / `defer` / `skip`.',
994
+ '',
995
+ ];
996
+ }
997
+
998
+ /**
999
+ * Robust HARD-gate prompt for the no-children case (epic_empty /
1000
+ * milestone_needs_breakdown). Re-ground → hierarchy context → seeds →
1001
+ * reading list → filing.
1002
+ */
1003
+ export function buildDecompositionPromptMarkdown({
1004
+ grain,
1005
+ id,
1006
+ name,
1007
+ context,
1008
+ roadmapId,
1009
+ focusSpecPath,
1010
+ worldStateDelta,
1011
+ childIds,
1012
+ loadByPath,
1013
+ includeOptionsMenu,
1014
+ breakdownReview,
1015
+ }) {
1016
+ const childKind = context.childKind;
1017
+ const lines = [
1018
+ `## ⛔ needs_breakdown — ${id}`,
1019
+ '',
1020
+ `**${grain[0].toUpperCase()}${grain.slice(1)}:** ${name}`,
1021
+ roadmapId ? `**Roadmap:** ${roadmapId}` : '',
1022
+ '',
1023
+ `> **Execution is blocked.** This ${grain} has **no ${childKind}s**. Do not start implementing — your job this session is to decompose it. _AI agents are good at executing the wrong thing perfectly_ ([[heyspex-north-star]]); a clean breakdown is the cheapest defense.`,
1024
+ '',
1025
+ ].filter((l) => l !== '');
1026
+
1027
+ lines.push(...regroundChecklist(context, id));
1028
+ lines.push(...hierarchyContextBlock(id, name, context));
1029
+ lines.push(...parentBreakageContextBlock(id, context));
1030
+ lines.push(...seedBlock(id, context));
1031
+ lines.push(...readingListBlock(context));
1032
+ lines.push(
1033
+ ...buildWorldStateDeltaSections({
1034
+ delta: worldStateDelta,
1035
+ focusId: id,
1036
+ childIds,
1037
+ loadByPath,
1038
+ }),
1039
+ );
1040
+ lines.push(...filingBlock(id, context, focusSpecPath, breakdownReview));
1041
+ if (includeOptionsMenu) {
1042
+ lines.push(...buildFourOptionMenu({ focusId: id }));
1043
+ }
1044
+ return lines.join('\n');
1045
+ }
1046
+
1047
+ /**
1048
+ * The acceptance criteria rendered AS THE CONTRACT to satisfy (Tier 1), or the
1049
+ * Tier-2 minimal fallback with an inline gap marker when none exist. Distinct
1050
+ * from `seedBlock`, which frames the same data as "candidates to split" for a
1051
+ * breakdown — here the agent EXECUTES against them.
1052
+ */
1053
+ function executionContractBlock(context) {
1054
+ const lines = ['### Acceptance criteria — your contract (close-out verifies these)', ''];
1055
+ const sections = context.acceptance?.sections || [];
1056
+ if (sections.length === 0) {
1057
+ // Tier 2 — no acceptance criteria. Mark the gap inline and point at the
1058
+ // goal/reading list (rendered below) as the working contract; never error.
1059
+ lines.push(
1060
+ context.goal
1061
+ ? '**[NEEDS CLARIFICATION: no `## Acceptance criteria` on this spec — treat the Goal (in Hierarchy context below) + the reading list as the contract, and add explicit criteria.]**'
1062
+ : '**[NEEDS CLARIFICATION: no `## Acceptance criteria` and no `## Goal` — too thin to derive a contract. Add at least a goal before claiming.]**',
1063
+ '',
1064
+ );
1065
+ return lines;
1066
+ }
1067
+ for (const s of sections) {
1068
+ lines.push(`**${s.title}**`);
1069
+ for (const b of s.bullets) lines.push(`- [ ] ${b}`);
1070
+ lines.push('');
1071
+ }
1072
+ return lines;
1073
+ }
1074
+
1075
+ // ─────────────────────────────────────────────────────────────────────────────
1076
+ // Tier-3 readiness floor ([[HS-TASK-RW3B]]) — a separable pre-synthesis check
1077
+ // that runs BEFORE buildExecutionBrief. Below a minimum-viable-input threshold,
1078
+ // the caller routes to a recoverable clarification surface naming the specific
1079
+ // missing fields, never a thin brief and never a hard throw. Decoupled from the
1080
+ // composer so it is independently testable (epic §6).
1081
+ // ─────────────────────────────────────────────────────────────────────────────
1082
+
1083
+ /**
1084
+ * Synthesizability floor. A spec is brief-able at Tier 1/2 when it has a title
1085
+ * AND at least one of: an `## Acceptance criteria` section carrying
1086
+ * ≥ `minAcceptanceBullets` bullets, OR a non-trivial goal/scope of
1087
+ * ≥ `minGoalChars` characters. Config-surfaced via `readReadinessFloorSettings`
1088
+ * so it is tunable without a deploy (AC #3).
1089
+ */
1090
+ export const DEFAULT_READINESS_FLOOR = { minAcceptanceBullets: 1, minGoalChars: 12 };
1091
+
1092
+ /**
1093
+ * Read the (tunable) readiness floor from spex-console/settings.json or its
1094
+ * .heyspex mirror under a `briefReadinessFloor: { minAcceptanceBullets,
1095
+ * minGoalChars }` key. Missing / malformed values fall back per-field to
1096
+ * DEFAULT_READINESS_FLOOR. Mirrors readAlignmentFuseSettings' lookup order.
1097
+ */
1098
+ export function readReadinessFloorSettings(vaultRoot) {
1099
+ for (const p of spexConsoleSettingsCandidates(vaultRoot)) {
1100
+ const raw = readJsonSafe(p);
1101
+ const floor = raw && typeof raw === 'object' ? raw.briefReadinessFloor : null;
1102
+ if (!floor || typeof floor !== 'object') continue;
1103
+ const pick = (v, fallback) =>
1104
+ typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : fallback;
1105
+ return {
1106
+ minAcceptanceBullets: pick(floor.minAcceptanceBullets, DEFAULT_READINESS_FLOOR.minAcceptanceBullets),
1107
+ minGoalChars: pick(floor.minGoalChars, DEFAULT_READINESS_FLOOR.minGoalChars),
1108
+ };
1109
+ }
1110
+ return { ...DEFAULT_READINESS_FLOOR };
1111
+ }
1112
+
1113
+ /**
1114
+ * Assess whether a spec carries enough structured content to derive a reliable
1115
+ * execution brief. Pure: reads only `name` + the `context` produced by
1116
+ * `extractDecompositionContext`. Returns
1117
+ * `{ ready, missing: [{ field, need }], floor, metrics }`
1118
+ * `missing` is empty iff `ready`; each entry names a field and the concrete
1119
+ * thing to add, so the clarification surface can render them verbatim (AC #2).
1120
+ */
1121
+ export function assessBriefReadiness({ name, context, floor } = {}) {
1122
+ const f = { ...DEFAULT_READINESS_FLOOR, ...(floor || {}) };
1123
+ const missing = [];
1124
+
1125
+ const title = typeof name === 'string' ? name.trim() : '';
1126
+ if (!title) {
1127
+ missing.push({ field: 'title', need: 'a non-empty title/name on the spec' });
1128
+ }
1129
+
1130
+ const sections = context?.acceptance?.sections || [];
1131
+ const acceptanceBullets = sections.reduce(
1132
+ (n, s) => n + (Array.isArray(s.bullets) ? s.bullets.length : 0),
1133
+ 0,
1134
+ );
1135
+ const goal = typeof context?.goal === 'string' ? context.goal.trim() : '';
1136
+ const goalChars = goal.length;
1137
+
1138
+ const hasAcceptance = acceptanceBullets >= f.minAcceptanceBullets;
1139
+ const hasGoal = goalChars >= f.minGoalChars;
1140
+ if (!hasAcceptance && !hasGoal) {
1141
+ missing.push({
1142
+ field: 'goal_or_acceptance',
1143
+ need:
1144
+ `at least one of: a \`## Acceptance criteria\` section with ≥${f.minAcceptanceBullets} bullet(s), ` +
1145
+ `or a non-trivial \`## Goal\`/scope (≥${f.minGoalChars} characters)`,
1146
+ });
1147
+ }
1148
+
1149
+ return {
1150
+ ready: missing.length === 0,
1151
+ missing,
1152
+ floor: f,
1153
+ metrics: { acceptanceBullets, goalChars },
1154
+ };
1155
+ }
1156
+
1157
+ /**
1158
+ * Classify which synthesis tier a brief was served at ([[HS-TASK-68NO]]). Pure —
1159
+ * the observability counterpart records/logs the returned label. The ladder:
1160
+ * 'override' — Tier 0: an authored `## NEXT STEP` override is present.
1161
+ * 'derived' — Tier 1: an `## Acceptance criteria` contract was derived.
1162
+ * 'minimal' — Tier 2: no acceptance criteria; goal-only gap-marked brief.
1163
+ * 'skip' — Tier 3: below the readiness floor → clarification surface.
1164
+ * 'minimal' and 'skip' are the *degraded* tiers (below 'derived').
1165
+ */
1166
+ export function classifyBriefTier({ override, context, ready } = {}) {
1167
+ if (ready === false) return 'skip';
1168
+ const hasOverride = typeof override === 'string' && override.trim().length > 0;
1169
+ if (hasOverride) return 'override';
1170
+ const sections = context?.acceptance?.sections || [];
1171
+ if (sections.length > 0) return 'derived';
1172
+ return 'minimal';
1173
+ }
1174
+
1175
+ /** The tiers served below 'derived' — the degraded-mode set for the SLO signal. */
1176
+ export const DEGRADED_BRIEF_TIERS = ['minimal', 'skip'];
1177
+
1178
+ /**
1179
+ * Tier-3 clarification surface. Replaces the brief when `assessBriefReadiness`
1180
+ * fails: a friendly, recoverable note naming exactly what to add (AC #2/#4),
1181
+ * never an error/throw. Same shape as the other prompt builders so callers can
1182
+ * drop it straight into `prompt_markdown`.
1183
+ */
1184
+ export function buildClarificationPrompt({ id, name, status, roadmapId, missing, focusSpecPath, grain = 'task' } = {}) {
1185
+ const grainLabel =
1186
+ grain === 'milestone' ? 'Milestone' : grain === 'epic' ? 'Epic' : 'Task';
1187
+ const lines = [
1188
+ `## 🟡 needs_clarification — ${id || '(unknown spec)'}`,
1189
+ '',
1190
+ `**${grainLabel}:** ${name || '(untitled)'}`,
1191
+ status ? `**Status:** ${status}` : '',
1192
+ roadmapId ? `**Roadmap:** ${roadmapId}` : '',
1193
+ '',
1194
+ `> **This spec is too thin to derive a reliable execution brief.** Rather than hand you a low-quality brief, here is exactly what to add. Fill these in, then re-run \`/hs-next\` — the brief builds itself once the floor is met.`,
1195
+ '',
1196
+ "### What's missing",
1197
+ '',
1198
+ ].filter((l) => l !== '');
1199
+
1200
+ const items = Array.isArray(missing) ? missing : [];
1201
+ if (items.length === 0) {
1202
+ lines.push('- _(no specific fields flagged — re-run `/hs-next` to retry)_');
1203
+ } else {
1204
+ for (const m of items) {
1205
+ lines.push(`- **${m.field}** — ${m.need}`);
1206
+ }
1207
+ }
1208
+
1209
+ lines.push(
1210
+ '',
1211
+ '### How to recover',
1212
+ '',
1213
+ `1. Open the spec on disk (\`${focusSpecPath || 'n/a'}\`) and add the fields above.`,
1214
+ '2. Re-run `/hs-next` (or `heyspex_next_on_deck`) — the readiness floor re-checks and the brief is derived automatically once the threshold is met.',
1215
+ '',
1216
+ );
1217
+ return lines.join('\n');
1218
+ }
1219
+
1220
+ /**
1221
+ * Derive an agent EXECUTION brief from a spec's structured content — the
1222
+ * deterministic, on-read replacement for hand-authored NEXT STEP blocks
1223
+ * ([[HS-EPIC-DXBR]] / [[HS-TASK-ULKL]]). Pure: composes only from the passed-in
1224
+ * `context` + `override`; no fs/Neon/index reads, no frontmatter writes. Same
1225
+ * input ⇒ byte-identical output.
1226
+ *
1227
+ * Synthesis chain Tiers 0–2 (the Tier-3 readiness floor lives in the caller):
1228
+ * Tier 0 — `override` (a pre-extracted `## NEXT STEP` block) is PREPENDED under
1229
+ * a labeled heading with the derived contract below it. Never drops,
1230
+ * never replaces — a stale override stays visible beside the contract.
1231
+ * Tier 1 — derive from `context.acceptance` (the executable contract) + goal +
1232
+ * out-of-scope + dependencies + reading list.
1233
+ * Tier 2 — no acceptance criteria ⇒ derive from goal + reading list and mark
1234
+ * the gap inline; no error, no disclaimer.
1235
+ *
1236
+ * `context` is the output of `extractDecompositionContext(node, loadRef, grain)`.
1237
+ * `override` is the verbatim NEXT STEP body, or null/'' when absent.
1238
+ */
1239
+ export function buildExecutionBrief({ id, name, status, roadmapId, context, override }) {
1240
+ const lines = [
1241
+ `## Execution brief — ${id}`,
1242
+ '',
1243
+ `**Task:** ${name}`,
1244
+ status ? `**Status:** ${status}` : '',
1245
+ roadmapId ? `**Roadmap:** ${roadmapId}` : '',
1246
+ '',
1247
+ ].filter((l) => l !== '');
1248
+
1249
+ // Tier 0 — authored override, prepended (never replaces the contract).
1250
+ const overrideText = typeof override === 'string' ? override.trim() : '';
1251
+ if (overrideText) {
1252
+ lines.push(
1253
+ '### Authored override (`## NEXT STEP`)',
1254
+ '',
1255
+ '_A human pinned this prompt. Follow it — the derived contract below stays visible so you can spot any drift between the two._',
1256
+ '',
1257
+ overrideText,
1258
+ '',
1259
+ '---',
1260
+ '',
1261
+ );
1262
+ }
1263
+
1264
+ lines.push(...executionContractBlock(context));
1265
+ lines.push(...hierarchyContextBlock(id, name, context, { outOfScopeNote: 'do NOT implement these' }));
1266
+ lines.push(...readingListBlock(context));
1267
+ return lines.join('\n');
1268
+ }
1269
+
1270
+ /**
1271
+ * SOFT corpus-drift prompt (children exist, reference corpus moved). Prepended
1272
+ * to the task brief, NEVER a hard block. The agent reads each stale doc,
1273
+ * identifies what changed, and poses keep/update/drop to the user per affected
1274
+ * child task. Then `heyspex_record_alignment` resets the timestamp so the next
1275
+ * `/hs-next` is quiet.
1276
+ *
1277
+ * stale — `[{ ref, source, lastModified }]` from detectCorpusDrift.
1278
+ */
1279
+ export function buildDriftResolutionPrompt({
1280
+ grain,
1281
+ id,
1282
+ name,
1283
+ context,
1284
+ existingChildren,
1285
+ stale,
1286
+ roadmapId,
1287
+ focusSpecPath,
1288
+ worldStateDelta,
1289
+ childIds,
1290
+ loadByPath,
1291
+ includeOptionsMenu,
1292
+ }) {
1293
+ const childKind = context.childKind;
1294
+ const lines = [
1295
+ `## ⚠️ corpus_drift — ${id}`,
1296
+ '',
1297
+ `**${grain[0].toUpperCase()}${grain.slice(1)}:** ${name}`,
1298
+ roadmapId ? `**Roadmap:** ${roadmapId}` : '',
1299
+ '',
1300
+ `> **Soft re-ground (not a block).** ${stale.length} doc(s) in this ${grain}'s reference corpus moved since the last alignment review. Read each one, identify what's stale or contradicted in the breakdown, then pose **keep / update / drop** to the user per affected ${childKind}. Apply the answers and reset the checkpoint — then the task brief below is yours to execute.`,
1301
+ '',
1302
+ ].filter((l) => l !== '');
1303
+
1304
+ lines.push('### Stale corpus (newer than alignment_reviewed_at)', '');
1305
+ for (const s of stale) {
1306
+ const ts = Number.isFinite(s.lastModified) ? new Date(s.lastModified).toISOString() : 'unknown';
1307
+ lines.push(`- [[${s.ref}]] — ${s.source} · last-modified \`${ts}\``);
1308
+ }
1309
+ lines.push('');
1310
+
1311
+ lines.push(`### Existing ${childKind}s (use these as the resolution surface)`, '');
1312
+ for (const c of existingChildren) {
1313
+ lines.push(`- \`${c.id}\`${c.name ? ` — ${c.name}` : ''} · ${c.status || '?'}`);
1314
+ }
1315
+ lines.push('');
1316
+
1317
+ lines.push(
1318
+ '### Resolution loop (then the drift section clears)',
1319
+ '',
1320
+ `1. Read each stale doc above and identify what changed — direction shifts, new acceptance, retired scope, renamed concepts.`,
1321
+ `2. For each ${childKind} potentially affected, pose to the user: **keep** (no change needed), **update** (open the ${childKind}, restate scope), or **drop** (retire it, add a replacement if needed).`,
1322
+ `3. Apply the user's answers (edit ${childKind}s, file new ones, retire stale ones).`,
1323
+ `4. Record the new baseline: \`heyspex_record_alignment({ spec_id: "${id}", summary: "<one-line resolution>" })\` — this resets \`alignment_reviewed_at\` so \`/hs-next\` is quiet on the next call.`,
1324
+ `5. **False positive?** Audit trail: \`heyspex_record_alignment({ spec_id: "${id}", override_reason: "<why this drift is not real>" })\`.`,
1325
+ '',
1326
+ `_Pinned via \`.heyspex/focus.yaml\` (\`${focusSpecPath || 'n/a'}\`). The task brief follows below — work it once drift questions are resolved._`,
1327
+ );
1328
+
1329
+ const deltaLines = buildWorldStateDeltaSections({
1330
+ delta: worldStateDelta,
1331
+ focusId: id,
1332
+ childIds: childIds || (Array.isArray(existingChildren) ? existingChildren.map((c) => c.id) : []),
1333
+ loadByPath,
1334
+ });
1335
+ if (deltaLines.length) {
1336
+ lines.push('', ...deltaLines);
1337
+ }
1338
+ if (includeOptionsMenu) {
1339
+ lines.push(
1340
+ ...buildFourOptionMenu({
1341
+ focusId: id,
1342
+ claimableId: existingChildren?.[0]?.id || id,
1343
+ }),
1344
+ );
1345
+ }
1346
+ return lines.join('\n');
1347
+ }