heyspex 0.2.6 → 0.2.7

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 (85) hide show
  1. package/bin/hs.mjs +85 -25
  2. package/bin/spexcode.mjs +31 -0
  3. package/dev-factory/lib/local-firecrawl.mjs +294 -0
  4. package/dev-factory/lib/seat-context.mjs +20 -4
  5. package/dev-factory/model-catalog.json +222 -222
  6. package/dev-factory/spexcrawl/firecrawl-cli.mjs +755 -0
  7. package/dist/cli/hs-connect.mjs +20 -2
  8. package/dist/cli/hs-defer.mjs +19 -1
  9. package/dist/cli/hs-index-rebuild.mjs +19 -1
  10. package/dist/cli/hs-index-reconcile.mjs +19 -1
  11. package/dist/cli/hs-init.mjs +2 -2
  12. package/dist/cli/hs-install.mjs +2 -2
  13. package/dist/cli/hs-join.mjs +20 -2
  14. package/dist/cli/hs-next-launch.mjs +89 -10
  15. package/dist/cli/hs-spawn.mjs +27 -1
  16. package/dist/cli/hs-vault-pull.mjs +19 -1
  17. package/dist/cli/hs-vault-push.mjs +19 -1
  18. package/dist/cli/hs-vault.mjs +21 -7
  19. package/dist/cli/hs-workspace.mjs +19 -1
  20. package/dist/cli-templates/customer-vault/specs/entity-model.md +1728 -1728
  21. package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +464 -464
  22. package/dist/cli-templates/customer-vault/specs/protocols/destructive-action-checklist.md +122 -122
  23. package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +46 -46
  24. package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +530 -530
  25. package/dist/cli-templates/customer-vault/specs/protocols/spec-summary-authoring.md +83 -83
  26. package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -28
  27. package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -62
  28. package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -29
  29. package/dist/cli-templates/harness-commands/claude/hs-ground.md +30 -30
  30. package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -29
  31. package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -70
  32. package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -35
  33. package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -27
  34. package/dist/cli-templates/harness-commands/claude/hs-res.md +20 -8
  35. package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -23
  36. package/dist/cli-templates/harness-commands/claude/hs-view.md +28 -28
  37. package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -32
  38. package/dist/cli-templates/harness-commands/claude/prototype.md +91 -91
  39. package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +22 -22
  40. package/dist/cli-templates/harness-commands/cursor/hs-capture.md +56 -56
  41. package/dist/cli-templates/harness-commands/cursor/hs-defer.md +23 -23
  42. package/dist/cli-templates/harness-commands/cursor/hs-ground.md +24 -24
  43. package/dist/cli-templates/harness-commands/cursor/hs-next.md +23 -23
  44. package/dist/cli-templates/harness-commands/cursor/hs-promote.md +64 -64
  45. package/dist/cli-templates/harness-commands/cursor/hs-push.md +29 -29
  46. package/dist/cli-templates/harness-commands/cursor/hs-qc.md +21 -21
  47. package/dist/cli-templates/harness-commands/cursor/hs-res.md +20 -8
  48. package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +17 -17
  49. package/dist/cli-templates/harness-commands/cursor/hs-view.md +23 -23
  50. package/dist/cli-templates/harness-commands/cursor/kill-ports.md +26 -26
  51. package/dist/cli-templates/harness-commands/cursor/prototype.md +86 -86
  52. package/docs/copilot-plugin-install.md +7 -0
  53. package/docs/mcp-client-config.md +6 -0
  54. package/package.json +7 -2
  55. package/plugins/heyspex/README.md +6 -0
  56. package/plugins/heyspex/bin/launch-mcp.mjs +17 -0
  57. package/plugins/heyspex/hooks/hooks.json +418 -418
  58. package/scripts/claude-hooks/_intent-classifier-semantic.mjs +2 -2
  59. package/scripts/hs-companion.mjs +38 -8
  60. package/scripts/lib/companion-install.mjs +768 -0
  61. package/scripts/lib/customer-kit.mjs +5 -3
  62. package/scripts/lib/hs-companion-settings.mjs +1 -0
  63. package/scripts/lib/hs-source-push.mjs +386 -386
  64. package/spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json +29 -29
  65. package/spexharness/src/adapters/copilot-models.mjs +179 -0
  66. package/spexharness/src/adapters/harness-detect.mjs +148 -0
  67. package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -132
  68. package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +153 -125
  69. package/spexharness/src/seat-context.mjs +10 -5
  70. package/spexharness/src/session-bootstrap.mjs +6 -3
  71. package/vendor/heyspex-mcp/cli.mjs +6 -1
  72. package/vendor/heyspex-mcp/config-seed.yaml +15 -15
  73. package/vendor/heyspex-mcp/dev-factory-overlay.mjs +142 -0
  74. package/vendor/heyspex-mcp/memory-tools.mjs +523 -523
  75. package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -1708
  76. package/vendor/heyspex-mcp/procedure-handlers.mjs +327 -327
  77. package/vendor/heyspex-mcp/procedure-tools.mjs +1209 -1181
  78. package/vendor/heyspex-mcp/research-tools.mjs +22 -2
  79. package/vendor/heyspex-mcp/seat-assign-cli.mjs +242 -0
  80. package/vendor/heyspex-mcp/seat-assignment-rules.mjs +96 -0
  81. package/vendor/heyspex-mcp/seat-assignments-client.mjs +265 -0
  82. package/vendor/heyspex-mcp/seat-defaults-cli.mjs +45 -0
  83. package/vendor/heyspex-mcp/seat-defaults.mjs +232 -0
  84. package/vendor/heyspex-mcp/seat-identity.mjs +171 -0
  85. package/vendor/heyspex-mcp/vault-io.mjs +720 -720
@@ -1,1708 +1,1708 @@
1
- // Which spec is on deck: vault/scheduler loaders, milestone scoping, the frozen
2
- // scheduler order, focus overrides, and resolveNextOnDeck itself. The brief
3
- // renderer (on-deck-brief.mjs) sits on top of this. Extracted from cli.mjs under
4
- // [[HS-BLNF-6W61-DIAG-cli-mjs-has-grown-into-a-13-665-line]] (no behavior change).
5
- //
6
- // ─────────────────────────────────────────────────────────────────────────────
7
- // Scheduler cohort + pin resolution ([[HS-TASK-CLIM]])
8
- //
9
- // Mirrors console's `/api/scheduler/inspect` + `/api/roadmap/[id]` cohort
10
- // assembly so vendor `resolveNextOnDeck` picks position-0 from the same global
11
- // order computeOrderCached exposes to the console surfaces. The parity contract
12
- // is: same vault, same focus pin → same #1 spec_id. Console's
13
- // `partitionForScheduler` / active-milestone scope are caller-side concerns
14
- // (the vendor parity test explicitly excludes them from byte-equivalence,
15
- // [[HS-TASK-ZJLX]]), so the filters live here rather than in scheduler.mjs.
16
- // ─────────────────────────────────────────────────────────────────────────────
17
- //
18
- // Reorder a list of `{ id, status, ... }` candidate epics by running
19
- // computeOrder LOCALLY over them, mirroring console's `pickMilestoneOnDeck`
20
- // descent pattern. Epics whose status is non-actionable (closed / in_review /
21
- // hibernated) are excluded from the local cohort but kept in the output at the
22
- // tail in their original relative order so the surface caller still sees them.
23
- // Cycles / cohort load failures degrade to the input order.
24
- // [[HS-EPIC-FRZE]] file-fallback removed (2026-06-10). The milestone snapshot is
25
- // Neon-only; the console never writes `.heyspex/milestone-snapshots/*.json`, so the
26
- // former `readMilestoneSnapshotFile` / `applyFrozenEpicOrder` / `isMilestoneSnapshotEnabled`
27
- // helpers always no-opped. /hs-next returns live scheduler order until a real
28
- // Neon-backed snapshot read lands (deferred follow-up).
29
-
30
- import { findSpecFile, loadRoadmap, loadSpec, loadTask, loadTaskViaGraph, parseFrontmatter, wikilinkTarget } from './vault-io.mjs';
31
- import { CycleError as SchedulerCycleError, applyPin as schedulerApplyPin, buildExplanation as schedulerBuildExplanation, computeOrder as schedulerComputeOrder, frontmatterToSchedulerSpec, normalizeDependsOnId } from './scheduler.mjs';
32
- import { buildBlockerBanner, buildEpicBoundaryPromptMarkdown, computeUnmetBlockers as computeUnmetBlockersImpl, mapStateToHint, resolveEpicChildTask as resolveEpicChildTaskImpl, resolveMilestoneState as resolveMilestoneStateImpl } from './blocker-resolver.mjs';
33
- import { readMvpModeState } from './mvp-mode.mjs';
34
- import { consoleReadErrorFrom, readViaSelector } from './read-path.mjs';
35
- import { consoleFetch } from './console-transport.mjs';
36
- import { join } from 'node:path';
37
- import { existsSync, readFileSync, readdirSync } from 'node:fs';
38
- import { detectUITags, loadDesignMemories } from './memory-tools.mjs';
39
- import { assessBriefReadiness, buildClarificationPrompt, buildExecutionBrief, classifyBriefTier, extractDecompositionContext, readReadinessFloorSettings } from './alignment-gate.mjs';
40
- import { formatBriefTierLog, recordBriefTier } from './brief-observability.mjs';
41
- import { buildWhyNow as schedulerBuildWhyNow } from './why-now.mjs';
42
- import { toCanonicalFamilyFirstId } from './spec-id-lib.mjs';
43
-
44
- export function loadFocusOverride(vaultRoot, options = {}) {
45
- // Neon layer-1 pin (precedence #1) wins when present. It is the console-set
46
- // fuse pin, which on a Neon-up machine is DORMANT in the file (vault.ts writes
47
- // it to Neon `focus_pin`, not focus.yaml — [[heyspex-is-saas-cloud-primary-both-tiers]]).
48
- // The caller fetches it at the async boundary (`injectNeonFocusPin`) and threads
49
- // the spec_id in here so this sync resolver can honor it. We resolve it to a
50
- // focus object that supersedes the file's layer-2 `spec_path` entirely, so a
51
- // stale dormant-file pin is never resurrected. FCPN follow-on (the documented
52
- // paid-tier soak gap). Falls through to the file when the id doesn't resolve.
53
- const neonPinSpecId = options.neonPinSpecId;
54
- if (typeof neonPinSpecId === 'string' && neonPinSpecId) {
55
- const resolved = findSpecFile(vaultRoot, neonPinSpecId);
56
- if (resolved) {
57
- return { spec_path: resolved, pin: { spec_id: neonPinSpecId } };
58
- }
59
- // id doesn't resolve to a vault file → don't steer nowhere; use the file.
60
- }
61
-
62
- // Precedence #2: the Neon `focus_selection` layer-2 spec_path ([[HS-TASK-FSEL]]).
63
- // On a Neon-up machine the console writes layer-2 here, not the (dormant) file —
64
- // so reading it keeps vendor /hs-next in agreement with the console On-Deck.
65
- // The stored value is already a repo-relative `specs/…md` path.
66
- const neonSelectionSpecPath = options.neonSelectionSpecPath;
67
- if (typeof neonSelectionSpecPath === 'string' && neonSelectionSpecPath) {
68
- return { spec_path: neonSelectionSpecPath };
69
- }
70
-
71
- // No `.heyspex/focus.yaml` fallback ([[no-fallbacks-by-default]]; the
72
- // focus.yaml-fallback incident). Focus lives only in Neon, fetched at the
73
- // async boundary (`injectNeonFocusPin`) and threaded in via the options
74
- // above. When neither a Neon pin nor selection is present (console
75
- // unreachable, or simply no focus set), there is no override — /hs-next
76
- // resolves the scheduler default off the local vault. Focus steering is a
77
- // nicety, not a correctness gate, so an absent console degrades to "no
78
- // steer", never to a stale file read.
79
- return null;
80
- }
81
-
82
- export function extractNextStepBlock(body) {
83
- // Look for a "NEXT STEP" or "## NEXT STEP" section
84
- const patterns = [
85
- /```[^\n]*NEXT STEP[^\n]*\n([\s\S]*?)```/i,
86
- /## NEXT STEP[:\s]*Ready-to-paste prompt\s*\n\s*```[^\n]*\n([\s\S]*?)```/i,
87
- /NEXT STEP[:\s]*\n\s*```[^\n]*\n([\s\S]*?)```/i,
88
- ];
89
-
90
- for (const pattern of patterns) {
91
- const match = body.match(pattern);
92
- if (match) return match[1].trim();
93
- }
94
-
95
- return null;
96
- }
97
-
98
- /**
99
- * Build the "Design System Requirements" section for UI epics.
100
- * @param {Array} decisions - Design decisions to include
101
- * @param {string[]} triggerTags - Tags that triggered this
102
- * @returns {string} - Markdown section
103
- */
104
- export function buildDesignContextSection(decisions, triggerTags) {
105
- if (decisions.length === 0) return '';
106
-
107
- const lines = [
108
- '## Design System Requirements',
109
- '',
110
- `> **Triggered by tags:** ${triggerTags.join(', ')}`,
111
- '>',
112
- '> Apply these design decisions before implementation. Violations will be caught by pre-commit hooks.',
113
- '',
114
- ];
115
-
116
- for (const decision of decisions) {
117
- lines.push(`### ${decision.id}`);
118
- lines.push('');
119
- lines.push(decision.summary);
120
- lines.push('');
121
-
122
- if (decision.checklist && decision.checklist.length > 0) {
123
- lines.push('**Checklist:**');
124
- for (const item of decision.checklist) {
125
- lines.push(`- [ ] ${item}`);
126
- }
127
- lines.push('');
128
- }
129
- }
130
-
131
- lines.push('---');
132
- lines.push('');
133
-
134
- return lines.join('\n');
135
- }
136
-
137
- export function isInitiative(taskId) {
138
- return taskId.includes('ROADMAP') || taskId.startsWith('HS-ROADMAP-');
139
- }
140
-
141
- // Status filter mirror of `spex-console/src/lib/db/repositories/helpers.ts:SCHEDULER_EXCLUDED_STATUSES`.
142
- // [[HS-EPIC-FRZE]] — synced to console's full 6-status set (added cancelled /
143
- // superseded / duplicate). Previously the cli excluded only 3, so `/hs-next`
144
- // and the console could disagree on the active set for a terminal-but-not-shipped
145
- // spec (CNVG-AC1 divergence). These are terminal: the work was intentionally
146
- // dropped, so it must never earn an on-deck position on either surface.
147
- export const SCHEDULER_EXCLUDED_STATUSES = new Set([
148
- 'closed',
149
- 'hibernated',
150
- 'in_review',
151
- 'cancelled',
152
- 'superseded',
153
- 'duplicate',
154
- // [[HS-DIAG-YMN3]]: `parked` = indefinite park; scheduler-invisible. Mirrors
155
- // console's `SCHEDULER_EXCLUDED_STATUSES` (helpers.ts) so `/hs-next` never
156
- // ranks a parked spec into the active cohort the way the console no longer does.
157
- 'parked',
158
- ]);
159
-
160
- export function isSchedulerExcluded(status, canonical) {
161
- if (SCHEDULER_EXCLUDED_STATUSES.has(status)) return true;
162
- return canonical === false;
163
- }
164
-
165
- // Mirror of `spex-console/src/lib/scheduler/toposort.ts:isCanonicalSpecId`.
166
- export function isCanonicalSpecId(id) {
167
- // Family-keyed kinds (TASK/EPIC/MIL/RES/CAP/REV/…): accept EITHER ordering by
168
- // recomposing via parseSpecId ([[HS-EPIC-DLE3]]). Non-family-keyed labels
169
- // (ROADMAP/IDEA/FLOW) are not reordered, so keep the type-first bare check.
170
- if (toCanonicalFamilyFirstId(id)) return true;
171
- return /^HS-(?:ROADMAP|IDEA|FLOW)-[A-Z0-9]+$/.test(id);
172
- }
173
-
174
- // Mirror of `spex-console/src/lib/vault-shared.ts:canonicalizeSpecRef` — strips
175
- // wikilink wrappers + slug tails, returning the bare canonical id.
176
- export function canonicalizeSpecRef(value) {
177
- if (value == null) return null;
178
- const inner = wikilinkTarget(String(value));
179
- if (!inner) return null;
180
- const m = inner.match(
181
- /^(HS-(?:TASK|EPIC|MIL|ROADMAP|RES|CAP|REV|IDEA|FLOW)-(?:\d+|[A-Z0-9]+))/,
182
- );
183
- return m ? m[1] : inner;
184
- }
185
-
186
- // Walk specs/{tasks,epics,milestones} and return one entry per .md file.
187
- // Result entries are `{ id, frontmatter, relPath }`. Used to seed the scheduler
188
- // cohort below — we keep the full milestone set so `getActiveMilestones` can
189
- // run, and partition them out before computeOrder.
190
- export function loadVaultSpecsForScheduler(vaultRoot) {
191
- const out = [];
192
- // Diagnoses ([[HS-MIL-BFIX]]) join the scheduler pool so an open bug fix
193
- // competes for on-deck by `severity:` (channel B). It is the SINGLE ranker
194
- // that decides order; the Resolution surface only advises via severity.
195
- // Disk-only readdir — misses Neon-only (cloud-filed) diagnoses; see [[HS-7UJZ-O86I-DIAG]].
196
- for (const dir of ['specs/tasks', 'specs/epics', 'specs/milestones', 'specs/diagnoses']) {
197
- const full = join(vaultRoot, dir);
198
- if (!existsSync(full)) continue;
199
- let files;
200
- try {
201
- files = readdirSync(full, { withFileTypes: true });
202
- } catch {
203
- continue;
204
- }
205
- for (const entry of files) {
206
- if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
207
- const relPath = `${dir}/${entry.name}`;
208
- try {
209
- const content = readFileSync(join(vaultRoot, relPath), 'utf-8');
210
- const { frontmatter } = parseFrontmatter(content);
211
- const fallback = entry.name.replace(/\.md$/i, '');
212
- const id = typeof frontmatter.id === 'string' && frontmatter.id ? frontmatter.id : fallback;
213
- out.push({ id, frontmatter, relPath });
214
- } catch {
215
- // ignore unreadable / malformed files
216
- }
217
- }
218
- }
219
- return out;
220
- }
221
-
222
- // Map a diagnose's `severity:` onto the scheduler's existing `kind` tier
223
- // ([[HS-MIL-BFIX]] channel B — "map severity to existing signals"). The tier is
224
- // the first within-layer ranking key in computeOrder. Moderate mapping (owner
225
- // decision 2026-06-16): an error-severity bug competes at FOUNDATION (just below
226
- // core infra), a warning at feature, an info at polish — so a build-breaking bug
227
- // surfaces high WITHOUT outranking genuine infra. Absent/invalid severity →
228
- // 'feature' (matches detect-bugs.ts's `warning` default). MUST stay byte-identical
229
- // to the console copy in `spex-console/src/lib/vault.ts` (`DIAGNOSE_SEVERITY_TIER`)
230
- // so vendor↔console on-deck order is parity-identical.
231
- export const DIAGNOSE_SEVERITY_TIER = { error: 'foundation', warning: 'feature', info: 'polish' };
232
-
233
- // The four scheduler tiers a pillar's `library_tier:` may name. Mirrors
234
- // console `TIER_ORDER` (spex-console/src/lib/scheduler/types.ts).
235
- export const PILLAR_TIER_VALUES = new Set(['infra', 'foundation', 'feature', 'polish']);
236
-
237
- // Build a `pillar id → scheduler tier` map by reading `specs/pillars/*.md`.
238
- // Mirrors console `buildPillarTierMap` (spex-console/src/lib/db/repositories/
239
- // helpers.ts): an evergreen pillar with `library_tier: foundation` lets epics
240
- // under it inherit the foundation tier when they set no explicit `kind:`.
241
- // Pillars whose `library_tier` is not a tier (e.g. `meta`) or is unset
242
- // contribute no entry. Read from disk separately because the scheduler row
243
- // loader (`loadVaultSpecsForScheduler`) deliberately omits the pillars dir.
244
- export function loadPillarTierMap(vaultRoot) {
245
- const out = new Map();
246
- const full = join(vaultRoot, 'specs/pillars');
247
- if (!existsSync(full)) return out;
248
- let files;
249
- try {
250
- files = readdirSync(full, { withFileTypes: true });
251
- } catch {
252
- return out;
253
- }
254
- for (const entry of files) {
255
- if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
256
- try {
257
- const content = readFileSync(join(full, entry.name), 'utf-8');
258
- const { frontmatter: fm } = parseFrontmatter(content);
259
- if (fm.type !== 'pillar') continue;
260
- const lt = typeof fm.library_tier === 'string' ? fm.library_tier : null;
261
- if (lt && PILLAR_TIER_VALUES.has(lt)) {
262
- const fallback = entry.name.replace(/\.md$/i, '');
263
- const id = typeof fm.id === 'string' && fm.id ? fm.id : fallback;
264
- out.set(canonicalizeSpecRef(id) || id, lt);
265
- }
266
- } catch {
267
- // ignore unreadable / malformed files
268
- }
269
- }
270
- return out;
271
- }
272
-
273
- /**
274
- * A console `GET /api/specs` row (`SpecSummary`) already flattens every
275
- * scheduler-relevant frontmatter field to the top level (id, type, status,
276
- * tags, effort, requires_user_testing, depends_on, canonical, created, kind,
277
- * library_tier, blast_radius, epics, children, milestone, parent, pillar) —
278
- * see `spex-console/src/lib/vault-shared.ts`. `vaultRowToSchedulerSpec` /
279
- * `frontmatterToSchedulerSpec` read the row as if it WERE frontmatter, so this
280
- * just renames the one field that doesn't share its frontmatter name.
281
- *
282
- * Known gap (no console fields= extension exists to close it without editing
283
- * spex-console, forbidden by this task): `SpecSummary` does not carry
284
- * `severity`, so a diagnose's severity-derived tier
285
- * (`vaultRowToSchedulerSpec`'s `DIAGNOSE_SEVERITY_TIER` lookup) always falls
286
- * back to `'feature'` in cloud reliance instead of reflecting the real
287
- * severity — documented here rather than silently accepted.
288
- */
289
- export function summaryRowToFrontmatterLike(row) {
290
- return { ...row, what_matters: row.whatMatters ?? row.what_matters };
291
- }
292
-
293
- /**
294
- * Cloud-reliant sibling of `loadVaultSpecsForScheduler` ([[HS-DJWP-MCPC-TASK]]
295
- * "Scheduler cohort" family). `loadVaultSpecsForScheduler` stays untouched
296
- * (the `diskRead` branch); in `neon` tier this asks `GET /api/specs` once
297
- * (no `type=` — the four wanted types are filtered client-side, mirroring
298
- * the four directories the disk loader walks) instead of `readdirSync`ing
299
- * `specs/{tasks,epics,milestones,diagnoses}`.
300
- */
301
- export async function loadVaultSpecsForSchedulerViaTier(vaultRoot, { tier, consoleUrl }) {
302
- return readViaSelector({
303
- tier,
304
- diskRead: () => loadVaultSpecsForScheduler(vaultRoot),
305
- neonRead: async () => {
306
- const res = await consoleFetch('/api/specs');
307
- if (!res.ok) {
308
- throw consoleReadErrorFrom(res, `GET /api/specs returned ${res.status}: ${res.error || JSON.stringify(res.body ?? {})}`, null);
309
- }
310
- const rows = Array.isArray(res.body?.specs) ? res.body.specs : [];
311
- const wanted = new Set(['task', 'epic', 'milestone', 'diagnose']);
312
- return rows
313
- .filter((r) => wanted.has(r.type))
314
- .map((r) => ({ id: r.id, frontmatter: summaryRowToFrontmatterLike(r), relPath: r.relPath }));
315
- },
316
- routeLabel: 'GET /api/specs',
317
- consoleUrl,
318
- });
319
- }
320
-
321
- /**
322
- * Cloud-reliant sibling of `loadPillarTierMap` ([[HS-DJWP-MCPC-TASK]] "Pillar
323
- * tier map" family). `loadPillarTierMap` stays untouched (the `diskRead`
324
- * branch); in `neon` tier this asks `GET /api/specs?type=pillar` instead of
325
- * `readdirSync`ing `specs/pillars`.
326
- */
327
- export async function loadPillarTierMapViaTier(vaultRoot, { tier, consoleUrl }) {
328
- return readViaSelector({
329
- tier,
330
- diskRead: () => loadPillarTierMap(vaultRoot),
331
- neonRead: async () => {
332
- const res = await consoleFetch('/api/specs?type=pillar');
333
- if (!res.ok) {
334
- throw consoleReadErrorFrom(res, `GET /api/specs?type=pillar returned ${res.status}: ${res.error || JSON.stringify(res.body ?? {})}`, null);
335
- }
336
- const rows = Array.isArray(res.body?.specs) ? res.body.specs : [];
337
- const out = new Map();
338
- for (const r of rows) {
339
- const lt = typeof r.library_tier === 'string' ? r.library_tier : null;
340
- if (lt && PILLAR_TIER_VALUES.has(lt)) {
341
- out.set(canonicalizeSpecRef(r.id) || r.id, lt);
342
- }
343
- }
344
- return out;
345
- },
346
- routeLabel: 'GET /api/specs?type=pillar',
347
- consoleUrl,
348
- });
349
- }
350
-
351
- // Promote a loaded vault row into the SchedulerSpec shape. Mirrors console
352
- // `summaryToSchedulerSpec` — same fields, derived from raw frontmatter.
353
- // The unique scheduler node key for a spec row/task ([[HS-K5GQ-DIAG]]): its
354
- // basename, derived from `relPath` (scheduler rows) or `specPath` (loadTask).
355
- // Sibling tasks under one epic share a family-key `id:`; the basename is what
356
- // disambiguates them so `computeOrder` doesn't throw DuplicateIdError. Falls
357
- // back to `id` when no path is on hand (keeps nodeKey === id, byte-identical).
358
- export function specBasename(rowOrTask) {
359
- const p = rowOrTask?.relPath || rowOrTask?.specPath;
360
- if (typeof p === 'string' && p) {
361
- return p.replace(/^.*[/\\]/, '').replace(/\.md$/i, '');
362
- }
363
- return rowOrTask?.id;
364
- }
365
-
366
- // [[HS-67F4-DIAG]] — build a child-id → declared-order-index map from every
367
- // milestone's `epics:` array and every epic's `children:` array, so the global
368
- // cohort can stamp each spec's position in its parent's authored sequence
369
- // (Stage 6.7 tiebreak). Keyed on the canonical child id; first occurrence wins.
370
- export function buildDeclaredOrderByChild(rows) {
371
- const byChild = new Map();
372
- for (const row of rows) {
373
- const fm = row.frontmatter || {};
374
- const refs = fm.type === 'milestone' ? fm.epics : fm.type === 'epic' ? fm.children : null;
375
- if (!Array.isArray(refs)) continue;
376
- refs.forEach((ref, i) => {
377
- const key = canonicalizeSpecRef(ref);
378
- if (key && !byChild.has(key)) byChild.set(key, i);
379
- });
380
- }
381
- return byChild;
382
- }
383
-
384
- export function vaultRowToSchedulerSpec(row, pillarTierById, declaredOrderByChild) {
385
- const spec = frontmatterToSchedulerSpec(row.id, row.frontmatter || {});
386
- // Unique node key so sibling tasks in the global cohort don't collide.
387
- spec.nodeKey = specBasename(row);
388
- const fm = row.frontmatter || {};
389
- spec.type = typeof fm.type === 'string' ? fm.type : undefined;
390
- if (spec.type === 'milestone') {
391
- const epics = Array.isArray(fm.epics) ? fm.epics : [];
392
- spec.epicsCount = epics.length;
393
- }
394
- // Diagnoses carry `severity:`, not the `kind:` tier vocab — derive the tier so
395
- // computeOrder ranks them by severity instead of sorting them last (no kind).
396
- if (spec.type === 'diagnose' && !spec.kind) {
397
- const sev = typeof fm.severity === 'string' ? fm.severity.trim().toLowerCase() : '';
398
- spec.kind = DIAGNOSE_SEVERITY_TIER[sev] || 'feature';
399
- }
400
- spec.milestone = canonicalizeSpecRef(fm.milestone) || undefined;
401
- spec.parent = canonicalizeSpecRef(fm.parent) || undefined;
402
- // [[HS-67F4-DIAG]] Stage 6.7 — position in the parent's declared child array.
403
- if (declaredOrderByChild) {
404
- const idx = declaredOrderByChild.get(canonicalizeSpecRef(row.id) || row.id);
405
- if (idx != null) spec.declaredOrderIndex = idx;
406
- }
407
- // Stage 2.7 blast-radius band ([[encoding-foundational-and-blast-radius-p]]).
408
- spec.blast_radius = typeof fm.blast_radius === 'string' ? fm.blast_radius : undefined;
409
- // Pillar-tier inheritance ([[encoding-foundational-and-blast-radius-p]]): an
410
- // epic with no explicit `kind:` inherits its pillar's `library_tier` tier, so
411
- // foundation-pillar work (e.g. HS-PILR-architecture) auto-sorts foundation.
412
- // Explicit `kind:` always wins; inert when no map is supplied.
413
- if (!spec.kind && pillarTierById) {
414
- const pillar = canonicalizeSpecRef(fm.pillar);
415
- const inherited = pillar ? pillarTierById.get(pillar) : undefined;
416
- if (inherited) spec.kind = inherited;
417
- }
418
- return spec;
419
- }
420
-
421
- // Mirror of `spex-console/src/lib/scheduler/pipeline.ts:partitionForScheduler`.
422
- // Drops epicless milestones + non-canonical-id duplicates. Pure — same input,
423
- // same output, same byte-equivalence with console.
424
- export function partitionSchedulerInput(specs) {
425
- const afterMilestones = [];
426
- for (const spec of specs) {
427
- if (spec.type === 'milestone' && (spec.epicsCount ?? 0) === 0) continue;
428
- afterMilestones.push(spec);
429
- }
430
- const kept = [];
431
- const seen = new Set();
432
- for (const spec of afterMilestones) {
433
- // Dedup on the unique nodeKey (not the shared family-key `id:`) so legit
434
- // sibling tasks both survive ([[HS-K5GQ-DIAG]]); byte-identical when unset.
435
- const key = spec.nodeKey ?? spec.id;
436
- if (!seen.has(key)) {
437
- seen.add(key);
438
- kept.push(spec);
439
- continue;
440
- }
441
- if (isCanonicalSpecId(spec.id)) kept.push(spec);
442
- // Non-canonical dup → drop (parity with console; vendor doesn't surface
443
- // SkippedDuplicate to the agent, only the picked head).
444
- }
445
- return kept;
446
- }
447
-
448
- // Mirror of `spex-console/src/lib/scheduler/active-milestone.ts:getActiveMilestones`.
449
- export function getActiveMilestoneIds(specs, manifestOrder) {
450
- const milestones = specs.filter((s) => s.type === 'milestone');
451
- if (milestones.length === 0) return [];
452
- const inProgressEpicMilestones = new Set();
453
- for (const s of specs) {
454
- if (s.type !== 'epic' || s.status !== 'in_progress' || !s.milestone) continue;
455
- inProgressEpicMilestones.add(s.milestone);
456
- }
457
- const active = new Set();
458
- for (const m of milestones) {
459
- if (m.status === 'in_progress') active.add(m.id);
460
- else if (m.status === 'open' && inProgressEpicMilestones.has(m.id)) active.add(m.id);
461
- }
462
- if (active.size === 0) return [];
463
- if (manifestOrder && manifestOrder.length > 0) {
464
- const normalized = manifestOrder.map((id) => normalizeDependsOnId(id));
465
- const out = [];
466
- const seen = new Set();
467
- for (const id of normalized) {
468
- if (active.has(id) && !seen.has(id)) {
469
- out.push(id);
470
- seen.add(id);
471
- }
472
- }
473
- const leftover = [...active].filter((id) => !seen.has(id)).sort();
474
- return [...out, ...leftover];
475
- }
476
- return [...active].sort();
477
- }
478
-
479
- // Mirror of `spex-console/src/lib/scheduler/active-milestone.ts:scopeToActiveMilestones`.
480
- // Drops epics whose milestone is not active (unless pinned), and tasks whose
481
- // epic-parent was dropped (unless pinned). Inert when no active milestone exists.
482
- export function scopeToActiveMilestones(specs, opts = {}) {
483
- const activeMs = opts.activeMilestones ?? new Set();
484
- const pinned = opts.pinnedIds ?? new Set();
485
- if (activeMs.size === 0) return [...specs];
486
- const keptEpicIds = new Set();
487
- for (const s of specs) {
488
- if (s.type !== 'epic') continue;
489
- if (pinned.has(s.id)) {
490
- keptEpicIds.add(s.id);
491
- continue;
492
- }
493
- if (s.milestone && activeMs.has(s.milestone)) keptEpicIds.add(s.id);
494
- }
495
- const out = [];
496
- for (const s of specs) {
497
- if (s.type === 'epic') {
498
- if (keptEpicIds.has(s.id)) out.push(s);
499
- continue;
500
- }
501
- if (s.type === 'task') {
502
- if (pinned.has(s.id)) { out.push(s); continue; }
503
- if (s.milestone && activeMs.has(s.milestone)) { out.push(s); continue; }
504
- if (s.parent && keptEpicIds.has(s.parent)) out.push(s);
505
- continue;
506
- }
507
- out.push(s);
508
- }
509
- return out;
510
- }
511
-
512
- // Mirror of `spex-console/src/lib/vault.ts:getPinnedSpecIdForScheduler`. The pin
513
- // id is the input to `applyPin` so vendor and console float the same head.
514
- export function getPinnedSpecIdFromFocus(focus) {
515
- if (!focus) return null;
516
- if (focus.pin && typeof focus.pin.spec_id === 'string' && focus.pin.spec_id) {
517
- return focus.pin.spec_id;
518
- }
519
- if (typeof focus.spec_path === 'string' && focus.spec_path) {
520
- const base = focus.spec_path.replace(/^.*[/\\]/, '').replace(/\.md$/i, '');
521
- const m = base.match(
522
- /^(HS-(?:TASK|EPIC|MIL|ROADMAP|RES|CAP|REV|IDEA|FLOW|PROTO)-(?:\d+|[A-Z0-9]+))/,
523
- );
524
- if (m) return m[1];
525
- }
526
- return null;
527
- }
528
-
529
- // Pick the first scheduler-ordered id that belongs to `candidateIds`. Returns
530
- // null when none match. The candidates may include refs (basenames /
531
- // wikilinks); they are canonicalized before lookup so a milestone's `epics:`
532
- // entry written as `[[HS-EPIC-CNVG-…]]` resolves against the canonical id the
533
- // scheduler emits.
534
- export function pickFromSchedulerOrder(scheduler, candidateRefs) {
535
- if (!scheduler?.order?.length) return null;
536
- const set = new Set();
537
- for (const ref of candidateRefs ?? []) {
538
- const id = canonicalizeSpecRef(ref);
539
- if (id) set.add(id);
540
- }
541
- if (set.size === 0) return null;
542
- for (const entry of scheduler.order) {
543
- if (set.has(entry.id)) return entry.id;
544
- }
545
- return null;
546
- }
547
-
548
- export function sortCandidateEpicsBySchedulerOrder(candidates, vaultRoot) {
549
- if (!Array.isArray(candidates) || candidates.length === 0) return candidates;
550
- const cohort = [];
551
- const skipped = [];
552
- for (const c of candidates) {
553
- if (isSchedulerExcluded(c.status, c.canonical)) {
554
- skipped.push(c);
555
- continue;
556
- }
557
- const id = canonicalizeSpecRef(c.id) || c.id;
558
- const epic = loadTask(vaultRoot, id);
559
- if (!epic) {
560
- skipped.push(c);
561
- continue;
562
- }
563
- const spec = frontmatterToSchedulerSpec(epic.id, epic.frontmatter || {});
564
- // [[HS-67F4-DIAG]] — stamp the epic's position in the milestone's declared
565
- // `epics:` order (candidates arrive in that order) + the shared milestone
566
- // parent, so the Stage 6.7 tiebreak keeps the seed pick in the owner's
567
- // authored sequence when the epics are otherwise tied on every real signal.
568
- spec.milestone = canonicalizeSpecRef(epic.frontmatter?.milestone) || '__milestone_epics__';
569
- spec.declaredOrderIndex = cohort.length;
570
- cohort.push({ candidate: c, spec });
571
- }
572
- if (cohort.length === 0) return candidates;
573
- let order;
574
- try {
575
- order = schedulerComputeOrder(cohort.map((e) => e.spec));
576
- } catch {
577
- return candidates;
578
- }
579
- const byId = new Map(cohort.map((e) => [e.spec.id, e.candidate]));
580
- const sorted = [];
581
- for (const entry of order) {
582
- const c = byId.get(entry.id);
583
- if (c) sorted.push(c);
584
- }
585
- return [...sorted, ...skipped];
586
- }
587
-
588
- // [[HS-TASK-JHIA]] — reorder candidate epics by the FROZEN milestone snapshot
589
- // order (Neon-sourced via `GET /api/scheduler/milestone-snapshot/:id`), restoring
590
- // the freeze parity `/hs-next` lost when the file-read seam was removed
591
- // (2026-06-10). Epics present in the frozen order render in that order; epics
592
- // absent from it (filed after the snapshot was written, or an empty snapshot)
593
- // keep their incoming relative order at the tail so new work appends and never
594
- // reshuffles the frozen rows. Both sides are canonicalized to bare ids so a
595
- // frozen bare id matches a full-slug candidate ([[scheduler-cache-id-form-parity]]).
596
- export function applyFrozenEpicOrder(candidates, frozenEpicOrderIds) {
597
- if (!Array.isArray(candidates) || candidates.length === 0) return candidates;
598
- if (!Array.isArray(frozenEpicOrderIds) || frozenEpicOrderIds.length === 0) return candidates;
599
- const pos = new Map();
600
- frozenEpicOrderIds.forEach((id, idx) => {
601
- const key = canonicalizeSpecRef(id) || id;
602
- if (key && !pos.has(key)) pos.set(key, idx);
603
- });
604
- const known = [];
605
- const unknown = [];
606
- for (const c of candidates) {
607
- const key = canonicalizeSpecRef(c.id) || c.id;
608
- if (pos.has(key)) known.push(c);
609
- else unknown.push(c);
610
- }
611
- known.sort((a, b) => {
612
- const ka = canonicalizeSpecRef(a.id) || a.id;
613
- const kb = canonicalizeSpecRef(b.id) || b.id;
614
- return pos.get(ka) - pos.get(kb);
615
- });
616
- return [...known, ...unknown];
617
- }
618
-
619
- // Build the global scheduler cohort + order for `/hs-next`. Mirrors the path
620
- // `/api/roadmap/[id]` walks: status filter → active-milestone scope → partition
621
- // → computeOrder → applyPin. Returns { order, byId, pinnedSpecId, error? }.
622
- // Errors (cycle, duplicate id) degrade gracefully — caller can fall back to
623
- // the legacy manifest traversal so a single bad-frontmatter day doesn't break
624
- // /hs-next.
625
- // `precomputed.schedulerRows` / `precomputed.pillarTierById` ([[HS-DJWP-MCPC-TASK]]):
626
- // when supplied, used INSTEAD of the disk loaders below — `handleNextOnDeck`
627
- // pre-fetches both over HTTP in cloud reliance and threads them in via
628
- // `resolveNextOnDeck`'s options, so this function never calls
629
- // `loadVaultSpecsForScheduler`/`loadPillarTierMap` in that tier. Omitted (the
630
- // default on every OTHER call site) preserves today's disk-only behavior
631
- // byte-for-byte.
632
- export function computeGlobalSchedulerOrder(vaultRoot, focus, precomputed = {}) {
633
- try {
634
- const rows = precomputed.schedulerRows ?? loadVaultSpecsForScheduler(vaultRoot);
635
- const pillarTierById = precomputed.pillarTierById ?? loadPillarTierMap(vaultRoot);
636
- const declaredOrderByChild = buildDeclaredOrderByChild(rows);
637
- const allSchedulerSpecs = rows.map((r) =>
638
- vaultRowToSchedulerSpec(r, pillarTierById, declaredOrderByChild),
639
- );
640
- const activeMilestones = new Set(getActiveMilestoneIds(allSchedulerSpecs));
641
- // Tasks + epics + diagnoses with a scheduler-relevant status feed computeOrder
642
- // (mirrors the `active` filter in console's roadmap + inspect routes).
643
- // Diagnoses ([[HS-MIL-BFIX]]) are milestone-less leaves; `scopeToActiveMilestones`
644
- // passes non-epic/task types through its catch-all, so a standalone bug bypasses
645
- // milestone scoping and competes in the global on-deck queue by `severity:`.
646
- const active = allSchedulerSpecs.filter(
647
- (s) => (s.type === 'task' || s.type === 'epic' || s.type === 'diagnose')
648
- && !isSchedulerExcluded(s.status, s.canonical),
649
- );
650
- const pinnedSpecId = getPinnedSpecIdFromFocus(focus);
651
- const pinnedIds = new Set();
652
- if (pinnedSpecId) pinnedIds.add(pinnedSpecId);
653
- const scoped = scopeToActiveMilestones(active, { activeMilestones, pinnedIds });
654
- const partitioned = partitionSchedulerInput(scoped);
655
- const base = schedulerComputeOrder(partitioned);
656
- const order = pinnedSpecId
657
- ? schedulerApplyPin(base, pinnedSpecId, partitioned)
658
- : base;
659
- // Dual-keyed ([[HS-K5GQ-DIAG]]): every row is reachable by its family-key
660
- // `id:` (back-compat for family-grain consumers) AND by its unique basename
661
- // (`order[].nodeKey`), so a sibling-task head resolves to the RIGHT row via
662
- // `byId.get(entry.nodeKey)` instead of an ambiguous family-key lookup.
663
- const byId = new Map();
664
- for (const r of rows) {
665
- byId.set(r.id, r);
666
- byId.set(specBasename(r), r);
667
- }
668
- return { order, byId, pinnedSpecId };
669
- } catch (err) {
670
- return { error: err, order: [], byId: new Map(), pinnedSpecId: null };
671
- }
672
- }
673
-
674
- // Bind the pure resolver helpers to this server's vault-aware `loadTask`.
675
- // The resolver lives in `./blocker-resolver.mjs` — it's unit-testable with a
676
- // synthetic loader and surfaces a discriminated state per
677
- // [[depends-on-canonical-blocker-field]] for the focus-override caller below.
678
- // `mvp_mode.review_backlog_never_blocks` — opt-OUT only, and the default holds
679
- // with MVP mode OFF too: `in_review` / `review_pending` mean build-complete at
680
- // every grain, so a review backlog never gates the next claim (owner ruling
681
- // 2026-08-14; mirrors the console's `PREREQ_SATISFIED_STATUSES`).
682
- export function blockerOpts(vaultRoot) {
683
- const mvp = vaultRoot ? readMvpModeState(vaultRoot) : null;
684
- return { reviewBacklogNeverBlocks: mvp?.review_backlog_never_blocks !== false };
685
- }
686
-
687
- export function computeUnmetBlockers(vaultRoot, task) {
688
- return computeUnmetBlockersImpl((id) => loadTask(vaultRoot, id), task, blockerOpts(vaultRoot));
689
- }
690
-
691
- export function resolveEpicChildTask(vaultRoot, epicFrontmatter) {
692
- return resolveEpicChildTaskImpl(
693
- (id) => loadTask(vaultRoot, id),
694
- epicFrontmatter,
695
- blockerOpts(vaultRoot),
696
- );
697
- }
698
-
699
- // [[HS-TASK-CLIM]] — scheduler-order epic-drill. Picks the seed child by
700
- // running computeOrder LOCALLY over the epic's actionable children, mirroring
701
- // console's `pickMilestoneOnDeck` descent pattern. Falls back to the legacy
702
- // manifest-order resolveEpicChildTask when no children resolve, when no child
703
- // is actionable (boundary states epic_implementation_complete / epic_done /
704
- // epic_empty still go through the legacy resolver), or when the local
705
- // computeOrder throws on a cycle. This deliberately supersedes the
706
- // manifest-order rule from
707
- // [[HS-DEC-epic-drill-resolver-manifest-order-and-blocker-filter]] — the
708
- // "blocker filter" half of that decision still holds (blockers surfaced in
709
- // the banner via computeUnmetBlockers), but the ordering authority moves to
710
- // the scheduler so vendor and console cannot disagree on the seed.
711
- export function resolveEpicChildTaskViaScheduler(vaultRoot, epicFrontmatter) {
712
- const legacy = () => resolveEpicChildTask(vaultRoot, epicFrontmatter);
713
- const children = epicFrontmatter?.children;
714
- if (!Array.isArray(children) || children.length === 0) return { state: 'epic_empty' };
715
-
716
- const actionable = [];
717
- for (const raw of children) {
718
- const target = wikilinkTarget(raw);
719
- if (!target) continue;
720
- const task = loadTask(vaultRoot, target);
721
- if (!task) continue;
722
- const status = task.status || task.frontmatter?.status;
723
- if (status === 'open' || status === 'in_progress') {
724
- actionable.push(task);
725
- }
726
- }
727
- if (actionable.length === 0) return legacy(); // boundary states stay with legacy
728
-
729
- // Key the LOCAL cohort on each child's unique basename ([[HS-K5GQ-DIAG]]).
730
- // Sibling tasks under one epic legitimately share a family-key `id:` (e.g. all
731
- // `id: HS-W6AD-TASK`); without a unique nodeKey `schedulerComputeOrder` throws
732
- // DuplicateIdError, which — rethrown below — blanked What's Next
733
- // (SUBPROCESS_EMPTY). The pick is resolved back by that same basename.
734
- let pickedNodeKey;
735
- try {
736
- // [[HS-67F4-DIAG]] — stamp each child's position in the epic's declared
737
- // `children:` order (`actionable` preserves it) + the shared epic parent, so
738
- // the Stage 6.7 tiebreak keeps the seed task in the owner's authored sequence
739
- // when the children are otherwise tied on every real signal.
740
- const childParentKey =
741
- canonicalizeSpecRef(epicFrontmatter?.id) || epicFrontmatter?.id || '__epic_children__';
742
- const cohort = actionable.map((t, i) => ({
743
- ...frontmatterToSchedulerSpec(t.id, t.frontmatter || {}),
744
- nodeKey: specBasename(t),
745
- parent: childParentKey,
746
- declaredOrderIndex: i,
747
- }));
748
- const order = schedulerComputeOrder(cohort);
749
- pickedNodeKey = order[0]?.nodeKey;
750
- } catch (err) {
751
- if (err instanceof SchedulerCycleError) return legacy();
752
- throw err;
753
- }
754
- if (!pickedNodeKey) return legacy();
755
- const picked = actionable.find((t) => specBasename(t) === pickedNodeKey);
756
- if (!picked) return legacy();
757
-
758
- const { blockers, severity } = computeUnmetBlockers(vaultRoot, picked);
759
- const status = picked.status || picked.frontmatter?.status;
760
- let state;
761
- if (status === 'in_progress') state = 'resume';
762
- else if (severity === 'none') state = 'ready';
763
- else if (severity === 'soft_review') state = 'soft_review';
764
- else state = 'hard_blocked';
765
- return { state, task: picked, blockers, severity };
766
- }
767
-
768
- export function resolveMilestoneState(vaultRoot, milestoneFrontmatter) {
769
- return resolveMilestoneStateImpl(
770
- (id) => loadTask(vaultRoot, id),
771
- milestoneFrontmatter,
772
- );
773
- }
774
-
775
- /**
776
- * Build the structured `/hs-next` response when focus pins a milestone whose
777
- * `epics:` array is empty (or all entries fail to resolve). Shape matches
778
- * [[HS-TASK-SKEM-scheduler-skip-epicless-milestones]] §"Structured /hs-next
779
- * response": `status: "needs_breakdown"`, `suggested_actions[]`, and
780
- * `next_eligible_spec: null`. The console UI consumer is
781
- * [[HS-EPIC-MNBD-milestone-needs-breakdown-awareness]].
782
- */
783
- export function buildMilestoneNeedsBreakdownResponse({
784
- milestoneId,
785
- milestoneName,
786
- focusSpecPath,
787
- roadmapId,
788
- reason,
789
- }) {
790
- const suggestedActions = [
791
- {
792
- action: 'file_epic',
793
- cli: `hs epic new --milestone ${milestoneId}`,
794
- mcp_tool: 'heyspex_file_epic',
795
- },
796
- { action: 'unpin', cli: 'hs focus clear' },
797
- ];
798
-
799
- const promptLines = [
800
- `## needs_breakdown — ${milestoneId}`,
801
- '',
802
- `**Milestone:** ${milestoneName}`,
803
- `**Roadmap:** ${roadmapId}`,
804
- '',
805
- `${reason}`,
806
- '',
807
- '### Suggested next moves',
808
- '',
809
- `1. **File a child epic** to break this milestone down: \`${suggestedActions[0].cli}\` (MCP: \`${suggestedActions[0].mcp_tool}\`).`,
810
- `2. **Unpin** if this milestone was pinned by mistake: \`${suggestedActions[1].cli}\`.`,
811
- '',
812
- `_Pinned via \`.heyspex/focus.yaml\` (\`${focusSpecPath}\`). Milestone has no schedulable work yet — surfacing breakdown choices instead of a body excerpt._`,
813
- ];
814
-
815
- return {
816
- status: 'needs_breakdown',
817
- kind: 'milestone',
818
- spec_id: milestoneId,
819
- spec_title: milestoneName,
820
- reason,
821
- suggested_actions: suggestedActions,
822
- next_eligible_spec: null,
823
- source_kind: 'milestone_needs_breakdown',
824
- roadmap_id: roadmapId,
825
- spec_path: focusSpecPath,
826
- prompt_markdown: promptLines.join('\n'),
827
- next_action_hint: 'file_epic',
828
- };
829
- }
830
-
831
- /**
832
- * Build the `/hs-next` response when focus pins a milestone with a populated
833
- * `epics:` array ([[HS-TASK-K7L9]] of [[HS-EPIC-M2J3]] §5). The milestone is
834
- * the *scope* of focus; the deferral picks the next actionable child epic
835
- * (in_progress first, else first non-closed, else first listed) so the agent
836
- * has a concrete claim target. Multiple equally-actionable epics surface in
837
- * `candidate_epics` so the agent can ask the human which to start.
838
- *
839
- * Mirrors `buildMilestoneNeedsBreakdownResponse` shape: the surface
840
- * structurally distinguishes "pick a child to start" (this) from "file a child
841
- * to start" (needs_breakdown) so the console can render either intent without
842
- * grain-aware fork code.
843
- */
844
- export function buildMilestoneDeferToSeedEpicResponse({
845
- milestoneId,
846
- milestoneName,
847
- milestoneBody,
848
- focusSpecPath,
849
- roadmapId,
850
- candidateEpics,
851
- }) {
852
- // Pick the scheduler's position-0 over the actionable subset. `candidateEpics`
853
- // has already been sorted by `sortCandidateEpicsBySchedulerOrder`, which
854
- // appends SCHEDULER_EXCLUDED_STATUSES items to the tail; falling through to
855
- // the first non-terminal preserves the scheduler's pick when the head is
856
- // actionable, and only reaches the tail if every actionable epic was skipped
857
- // during cohort load. This is the parity contract with console's On Deck —
858
- // do NOT re-apply a manifest-style "first in_progress" filter here ([[HS-TASK-CLIM]]).
859
- const SEED_TERMINAL = new Set(['closed', 'shipped', 'archived', 'hibernated', 'in_review']);
860
- const seed =
861
- candidateEpics.find((e) => !SEED_TERMINAL.has(e.status)) || candidateEpics[0];
862
- const multiple = candidateEpics.length > 1;
863
- const promptLines = [
864
- `## focus_override_milestone_drill — ${milestoneId}`,
865
- '',
866
- `**Milestone:** ${milestoneName}`,
867
- `**Roadmap:** ${roadmapId}`,
868
- '',
869
- `_Pinned via \`.heyspex/focus.yaml\`. Strategic focus is at the milestone grain — pick a child epic to start work below._`,
870
- '',
871
- '### Defer to seed epic',
872
- '',
873
- multiple
874
- ? `**Seed epic candidates** (${candidateEpics.length}):`
875
- : `**Seed epic:** \`${seed.id}\` (\`status: ${seed.status}\`).`,
876
- '',
877
- ...candidateEpics.map(
878
- (e) =>
879
- `- \`${e.id}\` · status: \`${e.status}\`${e.id === seed.id ? ' ← suggested next' : ''}`,
880
- ),
881
- '',
882
- multiple
883
- ? `Ask the human which to claim, or default to \`${seed.id}\` (first by scheduler order).`
884
- : `Run \`/hs-next ${seed.id}\` to drill into the seed epic and claim its next task.`,
885
- '',
886
- '---',
887
- '',
888
- '### Milestone body excerpt',
889
- '',
890
- milestoneBody.length > 2000
891
- ? `${milestoneBody.slice(0, 2000)}\n\n[... truncated ...]`
892
- : milestoneBody,
893
- '',
894
- '---',
895
- '',
896
- `_Spec path: \`${focusSpecPath}\`_`,
897
- ];
898
-
899
- return {
900
- spec_id: milestoneId,
901
- title: milestoneName,
902
- status: 'in_progress',
903
- source_kind: 'focus_override_milestone_drill',
904
- kind: 'milestone',
905
- roadmap_id: roadmapId,
906
- spec_path: focusSpecPath,
907
- prompt_markdown: promptLines.join('\n'),
908
- pinned_milestone: { id: milestoneId, name: milestoneName, spec_path: focusSpecPath },
909
- candidate_epics: candidateEpics,
910
- seed_epic: seed,
911
- next_action_hint: multiple ? 'pick_epic' : 'drill_epic',
912
- };
913
- }
914
-
915
- export const STALE_FOCUS_STATUSES = new Set(['in_review', 'closed', 'hibernated']);
916
-
917
- /**
918
- * Compute the scheduler explanation trace for `spec` relative to `cohort`
919
- * — i.e. the same set the spec was picked from. For epic-drill that's the
920
- * epic's children; for manifest paths that's the phase's open tasks; for
921
- * direct lookup the cohort is just the spec itself (layer 0, no tiebreaker
922
- * pressure). Returns `null` if computeOrder fails (cycle) — the caller still
923
- * surfaces the spec; explanation absence is non-fatal so /hs-next doesn't
924
- * regress on a bad-frontmatter day.
925
- *
926
- * `cohortSpecs` items must be `{ id, frontmatter }` shaped (raw vault loads).
927
- * The function looks up `pickedSpecId` after computing order and returns the
928
- * full ExplanationTrace, which always cites tier (stage 2), topological
929
- * layer (stage 1), and at least one tiebreaker (stage 7 or 8) per AC #1 of
930
- * [[HS-TASK-C2ZQ]].
931
- */
932
- /**
933
- * Load an epic's child tasks into the `{ id, frontmatter }` shape
934
- * `buildOnDeckExplanation` expects. Skips children that don't resolve to a
935
- * vault file — same tolerance as `resolveEpicChildTask`.
936
- */
937
- export function loadEpicChildrenCohort(vaultRoot, epicFrontmatter) {
938
- const children = epicFrontmatter?.children;
939
- if (!Array.isArray(children)) return [];
940
- const out = [];
941
- for (const raw of children) {
942
- const target = wikilinkTarget(raw);
943
- if (!target) continue;
944
- const task = loadTask(vaultRoot, target);
945
- if (!task) continue;
946
- out.push({ id: task.id, frontmatter: task.frontmatter || {} });
947
- }
948
- return out;
949
- }
950
-
951
- /**
952
- * Load all open tasks declared in a roadmap phase's `tasks:` list into the
953
- * `{ id, frontmatter }` shape `buildOnDeckExplanation` expects. Initiative
954
- * refs (nested roadmaps) are not flattened — they're rare in scheduled
955
- * phases and the picked spec would always be the first open of its phase.
956
- */
957
- export function loadPhaseOpenCohort(vaultRoot, phase) {
958
- const tasks = phase?.tasks;
959
- if (!Array.isArray(tasks)) return [];
960
- const out = [];
961
- for (const raw of tasks) {
962
- if (isInitiative(raw)) continue;
963
- const task = loadTask(vaultRoot, raw);
964
- if (!task) continue;
965
- if (task.status !== 'open') continue;
966
- out.push({ id: task.id, frontmatter: task.frontmatter || {} });
967
- }
968
- return out;
969
- }
970
-
971
- /**
972
- * Hysteresis anchor for the stateless MCP ([[HS-TASK-MI65]]). Reads the
973
- * cohort's prior relative order out of the console-written scheduler cache
974
- * (`<vaultRoot>/.heyspex/scheduler-cache.json`) so `/hs-next` stabilizes the
975
- * same way the console Scheduler surface does. Best-effort: any missing file,
976
- * parse error, or schema mismatch returns undefined → hysteresis inert, order
977
- * byte-identical to a priorless run. Returns cohort ids in cached order.
978
- */
979
- export function readSchedulerCachePriorOrder(vaultRoot, cohortIds) {
980
- if (!vaultRoot) return undefined;
981
- try {
982
- const cachePath = join(vaultRoot, '.heyspex', 'scheduler-cache.json');
983
- if (!existsSync(cachePath)) return undefined;
984
- const parsed = JSON.parse(readFileSync(cachePath, 'utf-8'));
985
- const order = Array.isArray(parsed?.order) ? parsed.order : null;
986
- if (!order || order.length === 0) return undefined;
987
- const inCohort = new Set(cohortIds);
988
- const priorOrder = order
989
- .map((o) => o?.id)
990
- .filter((id) => typeof id === 'string' && inCohort.has(id));
991
- return priorOrder.length > 0 ? priorOrder : undefined;
992
- } catch {
993
- return undefined;
994
- }
995
- }
996
-
997
- /**
998
- * Full global scheduler order (bare ids, global rank) from the console-written
999
- * cache. Used to converge the claim target onto scheduler output so /hs-next's
1000
- * `claim_target` agrees with its own `seed_epic` and the console On Deck
1001
- * ([[HS-EPIC-CNVG]]). Best-effort: any miss returns `[]` →
1002
- * `computeClaimTarget` falls back to declared (`epics:`/`children:`) order.
1003
- */
1004
- export function readSchedulerCacheOrderIds(vaultRoot) {
1005
- if (!vaultRoot) return [];
1006
- try {
1007
- const cachePath = join(vaultRoot, '.heyspex', 'scheduler-cache.json');
1008
- if (!existsSync(cachePath)) return [];
1009
- const parsed = JSON.parse(readFileSync(cachePath, 'utf-8'));
1010
- const order = Array.isArray(parsed?.order) ? parsed.order : null;
1011
- if (!order) return [];
1012
- return order.map((o) => o?.id).filter((id) => typeof id === 'string');
1013
- } catch {
1014
- return [];
1015
- }
1016
- }
1017
-
1018
- export function buildOnDeckExplanation(pickedSpecId, cohortSpecs, vaultRoot) {
1019
- if (!pickedSpecId) return null;
1020
- const cohort = Array.isArray(cohortSpecs) && cohortSpecs.length > 0
1021
- ? cohortSpecs
1022
- : [{ id: pickedSpecId, frontmatter: {} }];
1023
- const hasPicked = cohort.some((c) => c.id === pickedSpecId);
1024
- const effective = hasPicked
1025
- ? cohort
1026
- : [...cohort, { id: pickedSpecId, frontmatter: {} }];
1027
- const schedulerSpecs = effective.map((c) =>
1028
- frontmatterToSchedulerSpec(c.id, c.frontmatter || {}),
1029
- );
1030
- const priorOrder = readSchedulerCachePriorOrder(
1031
- vaultRoot,
1032
- schedulerSpecs.map((s) => s.id),
1033
- );
1034
- try {
1035
- const order = schedulerComputeOrder(schedulerSpecs, { priorOrder });
1036
- const hit = order.find((o) => o.id === pickedSpecId);
1037
- return hit?.explanation ?? null;
1038
- } catch (err) {
1039
- // Degenerate fallback so a cycle in unrelated cohort entries doesn't
1040
- // erase the picked spec's row-level signal.
1041
- if (err instanceof SchedulerCycleError) {
1042
- const picked = schedulerSpecs.find((s) => s.id === pickedSpecId);
1043
- if (picked) return schedulerBuildExplanation(picked, 0);
1044
- }
1045
- return null;
1046
- }
1047
- }
1048
-
1049
- /** Attach the one-line why-now legibility field alongside the raw explanation trace. */
1050
- export function attachWhyNowFields(response, explanation, pinnedSpecId) {
1051
- if (!response || !explanation) return;
1052
- response.explanation = explanation;
1053
- const pinned =
1054
- typeof pinnedSpecId === 'string' &&
1055
- typeof response.spec_id === 'string' &&
1056
- response.spec_id === pinnedSpecId;
1057
- response.why_now = schedulerBuildWhyNow(explanation, { pinned });
1058
- }
1059
-
1060
- export function resolveNextOnDeck(vaultRoot, roadmapId, options = {}) {
1061
- const {
1062
- ignoreFocus = false,
1063
- includeHorizon = false,
1064
- neonPinSpecId,
1065
- neonSelectionSpecPath,
1066
- frozenEpicOrder,
1067
- // [[HS-DJWP-MCPC-TASK]]: pre-fetched over HTTP by `handleNextOnDeck` when
1068
- // cloud-reliant; undefined on every other (disk-reliant) call site, which
1069
- // falls through to computeGlobalSchedulerOrder's own disk loaders.
1070
- schedulerRows,
1071
- pillarTierById,
1072
- } = options;
1073
-
1074
- // [[HS-TASK-CLIM]] — global scheduler order, computed once per call. The pin
1075
- // (Neon `focus_pin` precedence #1 via `neonPinSpecId`, else the file's layer-2
1076
- // `spec_path`) is fed into applyPin so vendor /hs-next and the console surfaces
1077
- // (`/api/scheduler/inspect`, `/api/roadmap/[id]`, `/api/focus`) agree on
1078
- // position-0. Loaded here even when `ignoreFocus: true` is set so the no-pin
1079
- // path can still consult the scheduler for the head pick.
1080
- const focus = !ignoreFocus
1081
- ? loadFocusOverride(vaultRoot, { neonPinSpecId, neonSelectionSpecPath })
1082
- : null;
1083
- const scheduler = computeGlobalSchedulerOrder(vaultRoot, focus, { schedulerRows, pillarTierById });
1084
-
1085
- let staleFocusPin = null;
1086
-
1087
- const decorate = (response) => {
1088
- if (!staleFocusPin || !response) return response;
1089
- response.stale_focus_pin = staleFocusPin;
1090
- const banner = `> ⚠️ **Stale focus pin ignored:** \`${staleFocusPin.spec_id}\` is \`status: ${staleFocusPin.status}\` (non-actionable). Clear or repoint \`.heyspex/focus.yaml\` — falling through to manifest traversal.\n\n`;
1091
- if (typeof response.prompt_markdown === 'string') {
1092
- response.prompt_markdown = banner + response.prompt_markdown;
1093
- } else if (typeof response.message === 'string') {
1094
- response.message = `${response.message}. Also: stale focus pin ${staleFocusPin.spec_id} (${staleFocusPin.status}) was ignored — clear or repoint .heyspex/focus.yaml.`;
1095
- }
1096
- return response;
1097
- };
1098
-
1099
- // Check focus override first. The focus.yaml pin is the canonical
1100
- // "on deck" signal — it must always win when present (matches the console
1101
- // On Deck surface). If the focus spec has a NEXT STEP block we use it
1102
- // verbatim (source_kind: self_perpetuating); otherwise we return a body
1103
- // excerpt so /hs-next still surfaces the pinned spec instead of drifting
1104
- // back to manifest order.
1105
- //
1106
- // Exception: if the pinned spec's status is non-actionable
1107
- // (in_review / closed / hibernated), the pin is stale — capture a warning
1108
- // and fall through to manifest traversal so /hs-next never returns a spec
1109
- // the agent cannot work on.
1110
- if (!ignoreFocus) {
1111
- if (focus && focus.spec_path) {
1112
- const focusSpec = loadSpec(vaultRoot, focus.spec_path);
1113
- if (focusSpec && STALE_FOCUS_STATUSES.has(focusSpec.frontmatter.status)) {
1114
- staleFocusPin = {
1115
- spec_id: focusSpec.frontmatter.id || focus.spec_path,
1116
- spec_path: focus.spec_path,
1117
- status: focusSpec.frontmatter.status,
1118
- reason: focus.reason || null,
1119
- };
1120
- } else if (focusSpec) {
1121
- // If the pin is a milestone (kind: milestone or type: milestone), the
1122
- // milestone is the *scope* of focus — the actual work is a child
1123
- // epic, which itself walks to a child task. Today this resolver only
1124
- // handles the `needs_breakdown` case (empty/unresolved epics: array);
1125
- // drilling into a populated milestone is out of scope for SKEM and
1126
- // falls through to the body-excerpt branch. See
1127
- // [[HS-TASK-SKEM-scheduler-skip-epicless-milestones]].
1128
- const isMilestonePin =
1129
- focus.kind === 'milestone' ||
1130
- focusSpec.frontmatter.type === 'milestone';
1131
- if (isMilestonePin) {
1132
- const milestoneId = focusSpec.frontmatter.id || focus.spec_path;
1133
- const milestoneName = focusSpec.frontmatter.name || 'Pinned milestone';
1134
- const milestoneResult = resolveMilestoneState(
1135
- vaultRoot,
1136
- focusSpec.frontmatter,
1137
- );
1138
- if (milestoneResult.state === 'milestone_needs_breakdown') {
1139
- return buildMilestoneNeedsBreakdownResponse({
1140
- milestoneId,
1141
- milestoneName,
1142
- focusSpecPath: focus.spec_path,
1143
- roadmapId,
1144
- reason: 'Milestone has no child epics yet.',
1145
- });
1146
- }
1147
- if (milestoneResult.state === 'milestone_empty_after_filter') {
1148
- return buildMilestoneNeedsBreakdownResponse({
1149
- milestoneId,
1150
- milestoneName,
1151
- focusSpecPath: focus.spec_path,
1152
- roadmapId,
1153
- reason:
1154
- 'Milestone declares child epics, but none of them resolve to a spec in the vault — breakdown still pending.',
1155
- });
1156
- }
1157
- if (milestoneResult.state === 'milestone_has_epics') {
1158
- // [[HS-TASK-K7L9]] of [[HS-EPIC-M2J3]] §5 — milestone is the
1159
- // *scope* of focus; surface the milestone body and defer the
1160
- // claim path to the seed epic. [[HS-TASK-CLIM]] resorts the
1161
- // candidate epics by scheduler position so the seed pick agrees
1162
- // with console's What's Next column. Scheduler errors / cohort
1163
- // misses degrade gracefully: epics absent from the order keep
1164
- // their manifest position (sorted after scheduler-ranked ones).
1165
- // [[HS-EPIC-FRZE]] / [[HS-TASK-JHIA]]: the frozen milestone snapshot
1166
- // lives in Neon and this cli has no Neon client, so the frozen epic
1167
- // order is fetched over HTTP at the async handler boundary
1168
- // (`injectFrozenMilestoneOrder` → `frozenEpicOrder`). When present we
1169
- // honor the freeze — the seed epic agrees with the console What's Next
1170
- // / On Deck surfaces. When absent (flag off, no snapshot, or console
1171
- // unreachable) we degrade to LIVE scheduler order, exactly as before.
1172
- const candidateEpics =
1173
- Array.isArray(frozenEpicOrder) && frozenEpicOrder.length > 0
1174
- ? applyFrozenEpicOrder(milestoneResult.epics, frozenEpicOrder)
1175
- : sortCandidateEpicsBySchedulerOrder(milestoneResult.epics, vaultRoot);
1176
- return buildMilestoneDeferToSeedEpicResponse({
1177
- milestoneId,
1178
- milestoneName,
1179
- milestoneBody: focusSpec.body || '',
1180
- focusSpecPath: focus.spec_path,
1181
- roadmapId,
1182
- candidateEpics,
1183
- });
1184
- }
1185
- }
1186
-
1187
- // If the pin is an epic (kind: epic or type: epic), the epic is the
1188
- // *scope* of focus — the actual work is a child task. Drill to the
1189
- // next actionable child (in_progress first, else first open by
1190
- // priority) and return that task. Falls through to body-excerpt
1191
- // behavior only if the epic has no actionable child.
1192
- const isEpicPin =
1193
- focus.kind === 'epic' || focusSpec.frontmatter.type === 'epic';
1194
- if (isEpicPin) {
1195
- // [[HS-TASK-CLIM]] — drill into children in scheduler position order
1196
- // (local computeOrder over epic.children, mirroring console's
1197
- // milestone-descent pattern) so the seed task agrees with console's
1198
- // On Deck pick. Boundary states (epic_implementation_complete /
1199
- // epic_done / epic_empty) and scheduler errors fall back to the
1200
- // legacy manifest-order resolver.
1201
- const childResult = resolveEpicChildTaskViaScheduler(
1202
- vaultRoot,
1203
- focusSpec.frontmatter,
1204
- );
1205
- const epicId = focusSpec.frontmatter.id || focus.spec_path;
1206
- const epicName = focusSpec.frontmatter.name || 'Pinned epic';
1207
-
1208
- if (childResult && childResult.task) {
1209
- // resume / ready / soft_review / hard_blocked — picked an actionable
1210
- // child. Build the standard task response, then decorate with the
1211
- // state-specific banner + machine-readable hint per
1212
- // [[depends-on-canonical-blocker-field]].
1213
- const { task: childTask, blockers = [], severity = 'none', state } = childResult;
1214
- const phase = { id: epicId, name: `Pinned epic — ${epicName}` };
1215
- const cohort = loadEpicChildrenCohort(vaultRoot, focusSpec.frontmatter);
1216
- const response = buildTaskResponse(
1217
- vaultRoot,
1218
- childTask,
1219
- phase,
1220
- roadmapId,
1221
- 'focus_override_epic_drill',
1222
- cohort,
1223
- );
1224
- response.pinned_epic = { id: epicId, name: epicName, spec_path: focus.spec_path };
1225
- response.child_state = state;
1226
- response.next_action_hint = mapStateToHint(state);
1227
- response.blocker_severity = severity;
1228
- if (blockers.length > 0) {
1229
- response.unmet_blockers = blockers;
1230
- const banner = buildBlockerBanner(state, blockers);
1231
- if (banner) {
1232
- response.prompt_markdown = response.prompt_markdown.replace(
1233
- '\n\n---\n\n',
1234
- `${banner}\n\n---\n\n`,
1235
- );
1236
- }
1237
- }
1238
- return response;
1239
- }
1240
-
1241
- if (
1242
- childResult &&
1243
- (childResult.state === 'epic_implementation_complete' ||
1244
- childResult.state === 'epic_done')
1245
- ) {
1246
- // Epic boundary — no actionable child. Surface a real prompt with
1247
- // the three next-move choices (advance focus, /hs-review, ship)
1248
- // instead of falling through to a confusing body excerpt.
1249
- const reviewCandidates = childResult.review_candidates || [];
1250
- const milestoneNextEpics = childResult.milestone_next_epics || [];
1251
- const promptMarkdown = buildEpicBoundaryPromptMarkdown({
1252
- state: childResult.state,
1253
- epicId,
1254
- epicName,
1255
- reviewCandidates,
1256
- milestoneNextEpics,
1257
- roadmapId,
1258
- focusSpecPath: focus.spec_path,
1259
- });
1260
- return {
1261
- spec_id: epicId,
1262
- title: epicName,
1263
- status: focusSpec.frontmatter.status,
1264
- source_kind: childResult.state,
1265
- roadmap_id: roadmapId,
1266
- prompt_markdown: promptMarkdown,
1267
- spec_path: focus.spec_path,
1268
- focus_reason: focus.reason,
1269
- pinned_epic: { id: epicId, name: epicName, spec_path: focus.spec_path },
1270
- child_state: childResult.state,
1271
- next_action_hint: mapStateToHint(childResult.state),
1272
- review_candidates: reviewCandidates,
1273
- milestone_next_epics: milestoneNextEpics,
1274
- };
1275
- }
1276
- // epic_empty (or null from legacy code path) — fall through to
1277
- // body-excerpt behavior below so the agent at least sees the epic
1278
- // body and can decide what to do.
1279
- }
1280
-
1281
- // [[HS-TASK-CLIM]] — task-grain pin parity. When the focused TASK has
1282
- // unmet open prerequisites, the global scheduler seats those prereqs
1283
- // ahead of the pin (applyPin is prereq-aware per [[HS-TASK-PINQ]]). The
1284
- // CLI's #1 must equal computeOrderCached's position-0 — i.e. the
1285
- // prereq, not the pin — so we re-route to the prereq with a banner.
1286
- // Epic-empty / milestone fall-throughs and self_perpetuating NEXT STEP
1287
- // blocks bypass this so a structured pin display isn't replaced with
1288
- // an unexpected sibling task.
1289
- const focusType = focus.kind || focusSpec.frontmatter.type;
1290
- const pinnedId = focusSpec.frontmatter.id
1291
- || (focus.spec_path
1292
- ? focus.spec_path.replace(/^.*[/\\]/, '').replace(/\.md$/i, '')
1293
- : null);
1294
- if (
1295
- focusType === 'task'
1296
- && scheduler
1297
- && !scheduler.error
1298
- && scheduler.order?.length
1299
- && scheduler.order[0].id !== pinnedId
1300
- ) {
1301
- const prereqEntry = scheduler.order[0];
1302
- const prereqId = prereqEntry.id;
1303
- // Load by the unique nodeKey (basename) so a sibling-task prereq
1304
- // resolves to the right file, not an ambiguous family-key match
1305
- // ([[HS-K5GQ-DIAG]]); the banner still shows the family-key id.
1306
- const prereqTask = loadTask(vaultRoot, prereqEntry.nodeKey ?? prereqId);
1307
- if (prereqTask) {
1308
- const phase = {
1309
- id: pinnedId || 'focus_pin',
1310
- name: `Pinned task — ${focusSpec.frontmatter.name || pinnedId}`,
1311
- };
1312
- const cohort = scheduler.order
1313
- .slice(0, 25)
1314
- .map((o) => scheduler.byId.get(o.nodeKey ?? o.id))
1315
- .filter(Boolean)
1316
- .map((row) => ({ id: row.id, frontmatter: row.frontmatter }));
1317
- const response = buildTaskResponse(
1318
- vaultRoot,
1319
- prereqTask,
1320
- phase,
1321
- roadmapId,
1322
- 'focus_override_prereq',
1323
- cohort,
1324
- );
1325
- const banner =
1326
- `\n\n> ⚠️ **Pinned \`${pinnedId}\` has unmet prerequisites.** ` +
1327
- `Scheduler seated \`${prereqId}\` ahead so the pin can land. ` +
1328
- `Clear this prereq first, or pin a different spec.`;
1329
- response.prompt_markdown = response.prompt_markdown.replace(
1330
- '\n\n---\n\n',
1331
- `${banner}\n\n---\n\n`,
1332
- );
1333
- response.pinned_task = {
1334
- id: pinnedId,
1335
- name: focusSpec.frontmatter.name || pinnedId,
1336
- spec_path: focus.spec_path,
1337
- };
1338
- response.next_action_hint = 'clear_prereq';
1339
- return response;
1340
- }
1341
- }
1342
-
1343
- const uiTags = detectUITags(focusSpec.frontmatter);
1344
- let designContext = null;
1345
- let designSection = '';
1346
- if (uiTags.length > 0) {
1347
- const designDecisions = loadDesignMemories(vaultRoot, uiTags);
1348
- if (designDecisions.length > 0) {
1349
- designContext = {
1350
- triggered_by_tags: uiTags,
1351
- decisions: designDecisions,
1352
- };
1353
- designSection = buildDesignContextSection(designDecisions, uiTags);
1354
- }
1355
- }
1356
-
1357
- const specId = focusSpec.frontmatter.id || focus.spec_path;
1358
- const title = focusSpec.frontmatter.name || 'Focus Override';
1359
- const status = focusSpec.frontmatter.status || 'in_progress';
1360
-
1361
- // [[HS-TASK-G0HF]] the pinned spec always gets a derived execution brief
1362
- // (the `## NEXT STEP` block, if any, rides in as the Tier-0 override).
1363
- // The old "no NEXT STEP → body excerpt" disclaimer path is retired.
1364
- const hasOverride = !!extractNextStepBlock(focusSpec.body);
1365
- const promptMarkdown = designSection + deriveTaskBrief(vaultRoot, {
1366
- id: specId,
1367
- name: title,
1368
- status,
1369
- roadmapId,
1370
- frontmatter: focusSpec.frontmatter,
1371
- body: focusSpec.body,
1372
- specPath: focus.spec_path,
1373
- });
1374
- const sourceKind = hasOverride ? 'self_perpetuating' : 'focus_override';
1375
-
1376
- const response = {
1377
- spec_id: specId,
1378
- title,
1379
- status,
1380
- source_kind: sourceKind,
1381
- roadmap_id: roadmapId,
1382
- prompt_markdown: promptMarkdown,
1383
- spec_path: focus.spec_path,
1384
- focus_reason: focus.reason,
1385
- };
1386
-
1387
- if (designContext) {
1388
- response.design_context = designContext;
1389
- }
1390
-
1391
- const focusExplanation = buildOnDeckExplanation(specId, [
1392
- { id: specId, frontmatter: focusSpec.frontmatter || {} },
1393
- ], vaultRoot);
1394
- attachWhyNowFields(response, focusExplanation, specId);
1395
-
1396
- return response;
1397
- }
1398
- }
1399
- }
1400
-
1401
- // Load roadmap
1402
- const roadmap = loadRoadmap(vaultRoot, roadmapId);
1403
- if (!roadmap) {
1404
- return { error: 'ROADMAP_NOT_FOUND', message: `Roadmap ${roadmapId} not found` };
1405
- }
1406
-
1407
- const manifest = roadmap.frontmatter.roadmap_manifest;
1408
- if (!manifest || (!manifest.milestones && !manifest.phases)) {
1409
- return { error: 'NO_MANIFEST', message: `Roadmap ${roadmapId} has no manifest` };
1410
- }
1411
-
1412
- // [[HS-TASK-CLIM]] — when no focus pin steered the resolver and the global
1413
- // scheduler returned a non-empty order, the head pick comes straight from
1414
- // position-0 (source_kind: `scheduler_default`). This is the no-pin parity
1415
- // contract — console's `/api/focus` and `/api/roadmap/[id]` use the same
1416
- // computeOrderCached call, so the agent and the surfaces stay in sync. The
1417
- // v3 manifest traversal below is kept as a graceful fallback for scheduler
1418
- // errors (cycle, duplicate id) and for cohort emptiness (every spec is
1419
- // closed / in_review / hibernated).
1420
- if (scheduler && !scheduler.error && scheduler.order?.length > 0) {
1421
- const head = scheduler.order[0];
1422
- // Resolve the head by its unique nodeKey (basename) so a sibling-task head
1423
- // loads the right file, not an ambiguous family-key match ([[HS-K5GQ-DIAG]]).
1424
- const headKey = head.nodeKey ?? head.id;
1425
- const row = scheduler.byId.get(headKey);
1426
- if (row) {
1427
- const task = loadTask(vaultRoot, headKey);
1428
- if (task) {
1429
- const phase = {
1430
- id: roadmapId,
1431
- name: `Scheduler default — ${roadmapId}`,
1432
- };
1433
- const cohort = scheduler.order
1434
- .slice(0, 25)
1435
- .map((o) => scheduler.byId.get(o.nodeKey ?? o.id))
1436
- .filter(Boolean)
1437
- .map((r) => ({ id: r.id, frontmatter: r.frontmatter }));
1438
- return decorate(
1439
- buildTaskResponse(vaultRoot, task, phase, roadmapId, 'scheduler_default', cohort),
1440
- );
1441
- }
1442
- }
1443
- }
1444
-
1445
- // Manifest v3 ([[HS-TASK-JN9H]]): top-level `milestones:` is the
1446
- // authoritative priority order. Active-milestone scoping ([[HS-TASK-YDUH]])
1447
- // skips milestones whose status is not in_progress (and that have no
1448
- // in_progress child epic). Pinned-focus override already happened above —
1449
- // this branch is only reached when no focus.yaml drill applied and the
1450
- // scheduler-default pick above could not produce a head (cycle, empty cohort,
1451
- // or row not loadable).
1452
- if (Array.isArray(manifest.milestones)) {
1453
- const picked = pickFromV3Milestones(vaultRoot, manifest.milestones, roadmapId);
1454
- if (picked && !picked.error) return decorate(picked);
1455
- } else if (Array.isArray(manifest.phases)) {
1456
- // v2 backcompat — kept for one release window per [[HS-TASK-JN9H]] §2.
1457
- for (const phase of manifest.phases) {
1458
- for (const taskId of phase.tasks || []) {
1459
- if (isInitiative(taskId)) {
1460
- const nested = resolveNextOnDeckOpen(vaultRoot, taskId);
1461
- if (nested && !nested.error) {
1462
- return decorate({ ...nested, phase_id: phase.id, phase_name: phase.name });
1463
- }
1464
- } else {
1465
- const task = loadTask(vaultRoot, taskId);
1466
- if (task && task.status === 'open') {
1467
- const cohort = loadPhaseOpenCohort(vaultRoot, phase);
1468
- return decorate(buildTaskResponse(vaultRoot, task, phase, roadmapId, 'on_deck', cohort));
1469
- }
1470
- }
1471
- }
1472
- }
1473
- }
1474
-
1475
- // Horizon (if enabled and no scheduled open tasks). Retired in v3 per
1476
- // [[HS-TASK-JN9H]] §1; kept here only as a v2 backcompat fall-through.
1477
- if (includeHorizon && manifest.horizon) {
1478
- const horizonPhase = { id: 'horizon', name: 'Horizon Backlog', tasks: manifest.horizon };
1479
- const horizonCohort = loadPhaseOpenCohort(vaultRoot, horizonPhase);
1480
- for (const taskId of manifest.horizon) {
1481
- const task = loadTask(vaultRoot, taskId);
1482
- if (task && task.status === 'open') {
1483
- return decorate(buildTaskResponse(vaultRoot, task, horizonPhase, roadmapId, 'horizon', horizonCohort));
1484
- }
1485
- }
1486
- }
1487
-
1488
- return decorate({ error: 'NO_ACTIONABLE_TASK', message: 'All tasks closed or in progress — no open tasks on deck' });
1489
- }
1490
-
1491
- /**
1492
- * v3 manifest traversal with active-milestone scoping ([[HS-TASK-YDUH]]).
1493
- *
1494
- * Walks `manifest.milestones` in priority order, skips milestones that are
1495
- * not active (status !== in_progress AND no in_progress epic child), and
1496
- * drills into the first active milestone with an actionable child task.
1497
- *
1498
- * Returns the standard task-response payload (with `phase_id` set to the
1499
- * milestone id so existing callers' shape stays intact) or null when no
1500
- * active milestone has an open child.
1501
- */
1502
- export function pickFromV3Milestones(vaultRoot, milestoneRefs, roadmapId) {
1503
- for (const ref of milestoneRefs) {
1504
- const milestoneSpec = loadRoadmap(vaultRoot, ref);
1505
- if (!milestoneSpec) continue;
1506
- const fm = milestoneSpec.frontmatter || {};
1507
- if (fm.type !== 'milestone') continue;
1508
- const status = fm.status;
1509
- const milestoneState = resolveMilestoneState(vaultRoot, fm);
1510
- if (milestoneState.state !== 'milestone_has_epics') continue;
1511
- const epics = milestoneState.epics;
1512
- const isActive =
1513
- status === 'in_progress' ||
1514
- (status === 'open' && epics.some((e) => e.status === 'in_progress'));
1515
- if (!isActive) continue;
1516
-
1517
- // Active milestone — drill into its epics looking for an actionable child.
1518
- // Prefer in_progress epics first (continue the WIP), then open epics.
1519
- const sortedEpics = [
1520
- ...epics.filter((e) => e.status === 'in_progress'),
1521
- ...epics.filter((e) => e.status === 'open'),
1522
- ];
1523
- for (const epicSummary of sortedEpics) {
1524
- const epic = loadTask(vaultRoot, epicSummary.id);
1525
- if (!epic) continue;
1526
- const childResult = resolveEpicChildTask(vaultRoot, epic.frontmatter);
1527
- if (!childResult || !childResult.task) continue;
1528
- const { task: childTask, blockers = [], severity = 'none', state } = childResult;
1529
- const phase = { id: epic.id, name: `Active milestone — ${fm.name || ref}` };
1530
- const cohort = loadEpicChildrenCohort(vaultRoot, epic.frontmatter);
1531
- const response = buildTaskResponse(
1532
- vaultRoot,
1533
- childTask,
1534
- phase,
1535
- roadmapId,
1536
- 'manifest_active_milestone_drill',
1537
- cohort,
1538
- );
1539
- response.active_milestone = {
1540
- id: fm.id || ref,
1541
- name: fm.name || ref,
1542
- spec_path: milestoneSpec.specPath,
1543
- };
1544
- response.pinned_epic = {
1545
- id: epic.id,
1546
- name: epic.frontmatter?.name || epic.id,
1547
- spec_path: epic.specPath,
1548
- };
1549
- response.child_state = state;
1550
- response.next_action_hint = mapStateToHint(state);
1551
- response.blocker_severity = severity;
1552
- if (blockers.length > 0) {
1553
- response.unmet_blockers = blockers;
1554
- const banner = buildBlockerBanner(state, blockers);
1555
- if (banner) {
1556
- response.prompt_markdown = response.prompt_markdown.replace(
1557
- '\n\n---\n\n',
1558
- `${banner}\n\n---\n\n`,
1559
- );
1560
- }
1561
- }
1562
- return response;
1563
- }
1564
- }
1565
- return null;
1566
- }
1567
-
1568
- export function resolveNextOnDeckOpen(vaultRoot, roadmapId) {
1569
- const roadmap = loadRoadmap(vaultRoot, roadmapId);
1570
- if (!roadmap) return null;
1571
-
1572
- const manifest = roadmap.frontmatter.roadmap_manifest;
1573
- if (!manifest || !manifest.phases) return null;
1574
-
1575
- for (const phase of manifest.phases) {
1576
- for (const taskId of phase.tasks || []) {
1577
- if (isInitiative(taskId)) {
1578
- const nested = resolveNextOnDeckOpen(vaultRoot, taskId);
1579
- if (nested) return nested;
1580
- } else {
1581
- const task = loadTask(vaultRoot, taskId);
1582
- if (task && task.status === 'open') {
1583
- const cohort = loadPhaseOpenCohort(vaultRoot, phase);
1584
- return buildTaskResponse(vaultRoot, task, phase, roadmapId, 'manifest_open', cohort);
1585
- }
1586
- }
1587
- }
1588
- }
1589
-
1590
- return null;
1591
- }
1592
-
1593
- /**
1594
- * Build the derived execution brief for a task-shaped spec node (the Tier 0–2
1595
- * composer lives in alignment-gate.mjs). The NEXT STEP block, if present, is the
1596
- * Tier-0 override (prepended). Pure aside from the injected vault loader.
1597
- */
1598
- export function deriveTaskBrief(vaultRoot, { id, name, status, roadmapId, frontmatter, body, specPath, cloudTaskGraph }) {
1599
- // [[HS-H227-MCPC-TASK]]: `cloudTaskGraph`, when supplied by the direct
1600
- // spec_id branch of `resolveNextOnDeckPayload` (cloud tier only), routes
1601
- // this decomposition-context walk through the HTTP pre-fetch instead of
1602
- // disk. Every other caller (buildTaskResponse's disk-only scheduler path)
1603
- // passes nothing here and keeps the original `loadTask` behavior.
1604
- const loadRef = (ref) => loadTaskViaGraph(vaultRoot, ref, cloudTaskGraph);
1605
- const node = { id, name, frontmatter: frontmatter || {}, body: body || '' };
1606
- const context = extractDecompositionContext(node, loadRef, 'epic');
1607
- const override = extractNextStepBlock(body || '');
1608
- const hasOverride = typeof override === 'string' && override.trim().length > 0;
1609
-
1610
- // Tier-3 readiness floor ([[HS-TASK-RW3B]]) — runs BEFORE synthesis. A spec
1611
- // below the minimum-viable-input threshold routes to a recoverable
1612
- // clarification surface naming the missing fields instead of a thin brief.
1613
- // An authored `## NEXT STEP` override is itself sufficient input (a human
1614
- // pinned the prompt), so it bypasses the floor.
1615
- let ready = true;
1616
- let markdown;
1617
- if (hasOverride) {
1618
- markdown = buildExecutionBrief({ id, name, status, roadmapId, context, override });
1619
- } else {
1620
- const floor = readReadinessFloorSettings(vaultRoot);
1621
- const readiness = assessBriefReadiness({ name, context, floor });
1622
- ready = readiness.ready;
1623
- markdown = ready
1624
- ? buildExecutionBrief({ id, name, status, roadmapId, context, override })
1625
- : buildClarificationPrompt({
1626
- id,
1627
- name,
1628
- status,
1629
- roadmapId,
1630
- missing: readiness.missing,
1631
- focusSpecPath: specPath,
1632
- });
1633
- }
1634
-
1635
- // Observability ([[HS-TASK-68NO]]) — record the served tier + emit the running
1636
- // degraded-mode rate so a degrading system is visible, never silent.
1637
- const tier = classifyBriefTier({ override, context, ready });
1638
- const stats = recordBriefTier(tier);
1639
- // One-shot CLI paths (`hs next`, `hs mcp call`, unit tests) set
1640
- // HEYSPEX_DISABLE_STDIO_BOOTSTRAP — skip stderr tier logs there. PowerShell
1641
- // paints stderr red and shows NativeCommandError even on success; humans
1642
- // running `--json` only want stdout JSON ([[HS-TASK-ISK7]]).
1643
- if (process.env.HEYSPEX_DISABLE_STDIO_BOOTSTRAP !== '1') {
1644
- process.stderr.write(`${formatBriefTierLog({ tier, id, stats })}\n`);
1645
- }
1646
-
1647
- return markdown;
1648
- }
1649
-
1650
- export function buildTaskResponse(vaultRoot, task, phase, roadmapId, sourceKind, cohortSpecs = null) {
1651
- // Detect UI tags and load design context if applicable
1652
- const uiTags = detectUITags(task.frontmatter);
1653
- let designContext = null;
1654
- let designSection = '';
1655
-
1656
- if (uiTags.length > 0) {
1657
- const designDecisions = loadDesignMemories(vaultRoot, uiTags);
1658
- if (designDecisions.length > 0) {
1659
- designContext = {
1660
- triggered_by_tags: uiTags,
1661
- decisions: designDecisions,
1662
- };
1663
- designSection = buildDesignContextSection(designDecisions, uiTags);
1664
- }
1665
- }
1666
-
1667
- // [[HS-TASK-G0HF]] derived execution brief — the only path. An acceptance-
1668
- // criteria contract composed deterministically; the `## NEXT STEP` block, if
1669
- // present, rides in as the Tier-0 override (no raw body-excerpt fallback).
1670
- const brief = deriveTaskBrief(vaultRoot, {
1671
- id: task.id,
1672
- name: task.name,
1673
- status: task.status,
1674
- roadmapId,
1675
- frontmatter: task.frontmatter,
1676
- body: task.body,
1677
- specPath: task.specPath,
1678
- });
1679
- const promptMarkdown = `${designSection}${brief}\n\n---\n\n_Spec path: \`${task.specPath}\`_`;
1680
-
1681
- const response = {
1682
- spec_id: task.id,
1683
- title: task.name,
1684
- status: task.status,
1685
- source_kind: sourceKind,
1686
- phase_id: phase.id,
1687
- phase_name: phase.name,
1688
- roadmap_id: roadmapId,
1689
- prompt_markdown: promptMarkdown,
1690
- spec_path: task.specPath,
1691
- };
1692
-
1693
- // Include design_context if UI tags detected
1694
- if (designContext) {
1695
- response.design_context = designContext;
1696
- }
1697
-
1698
- // Scheduler explanation trace ([[HS-TASK-C2ZQ]] AC #1). Cohort defaults to
1699
- // the picked spec when callers don't pass a richer set — better to emit a
1700
- // single-spec trace than to drop the field entirely.
1701
- const cohort = Array.isArray(cohortSpecs) && cohortSpecs.length > 0
1702
- ? cohortSpecs
1703
- : [{ id: task.id, frontmatter: task.frontmatter || {} }];
1704
- const explanation = buildOnDeckExplanation(task.id, cohort, vaultRoot);
1705
- attachWhyNowFields(response, explanation, null);
1706
-
1707
- return response;
1708
- }
1
+ // Which spec is on deck: vault/scheduler loaders, milestone scoping, the frozen
2
+ // scheduler order, focus overrides, and resolveNextOnDeck itself. The brief
3
+ // renderer (on-deck-brief.mjs) sits on top of this. Extracted from cli.mjs under
4
+ // [[HS-BLNF-6W61-DIAG-cli-mjs-has-grown-into-a-13-665-line]] (no behavior change).
5
+ //
6
+ // ─────────────────────────────────────────────────────────────────────────────
7
+ // Scheduler cohort + pin resolution ([[HS-TASK-CLIM]])
8
+ //
9
+ // Mirrors console's `/api/scheduler/inspect` + `/api/roadmap/[id]` cohort
10
+ // assembly so vendor `resolveNextOnDeck` picks position-0 from the same global
11
+ // order computeOrderCached exposes to the console surfaces. The parity contract
12
+ // is: same vault, same focus pin → same #1 spec_id. Console's
13
+ // `partitionForScheduler` / active-milestone scope are caller-side concerns
14
+ // (the vendor parity test explicitly excludes them from byte-equivalence,
15
+ // [[HS-TASK-ZJLX]]), so the filters live here rather than in scheduler.mjs.
16
+ // ─────────────────────────────────────────────────────────────────────────────
17
+ //
18
+ // Reorder a list of `{ id, status, ... }` candidate epics by running
19
+ // computeOrder LOCALLY over them, mirroring console's `pickMilestoneOnDeck`
20
+ // descent pattern. Epics whose status is non-actionable (closed / in_review /
21
+ // hibernated) are excluded from the local cohort but kept in the output at the
22
+ // tail in their original relative order so the surface caller still sees them.
23
+ // Cycles / cohort load failures degrade to the input order.
24
+ // [[HS-EPIC-FRZE]] file-fallback removed (2026-06-10). The milestone snapshot is
25
+ // Neon-only; the console never writes `.heyspex/milestone-snapshots/*.json`, so the
26
+ // former `readMilestoneSnapshotFile` / `applyFrozenEpicOrder` / `isMilestoneSnapshotEnabled`
27
+ // helpers always no-opped. /hs-next returns live scheduler order until a real
28
+ // Neon-backed snapshot read lands (deferred follow-up).
29
+
30
+ import { findSpecFile, loadRoadmap, loadSpec, loadTask, loadTaskViaGraph, parseFrontmatter, wikilinkTarget } from './vault-io.mjs';
31
+ import { CycleError as SchedulerCycleError, applyPin as schedulerApplyPin, buildExplanation as schedulerBuildExplanation, computeOrder as schedulerComputeOrder, frontmatterToSchedulerSpec, normalizeDependsOnId } from './scheduler.mjs';
32
+ import { buildBlockerBanner, buildEpicBoundaryPromptMarkdown, computeUnmetBlockers as computeUnmetBlockersImpl, mapStateToHint, resolveEpicChildTask as resolveEpicChildTaskImpl, resolveMilestoneState as resolveMilestoneStateImpl } from './blocker-resolver.mjs';
33
+ import { readMvpModeState } from './mvp-mode.mjs';
34
+ import { consoleReadErrorFrom, readViaSelector } from './read-path.mjs';
35
+ import { consoleFetch } from './console-transport.mjs';
36
+ import { join } from 'node:path';
37
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
38
+ import { detectUITags, loadDesignMemories } from './memory-tools.mjs';
39
+ import { assessBriefReadiness, buildClarificationPrompt, buildExecutionBrief, classifyBriefTier, extractDecompositionContext, readReadinessFloorSettings } from './alignment-gate.mjs';
40
+ import { formatBriefTierLog, recordBriefTier } from './brief-observability.mjs';
41
+ import { buildWhyNow as schedulerBuildWhyNow } from './why-now.mjs';
42
+ import { toCanonicalFamilyFirstId } from './spec-id-lib.mjs';
43
+
44
+ export function loadFocusOverride(vaultRoot, options = {}) {
45
+ // Neon layer-1 pin (precedence #1) wins when present. It is the console-set
46
+ // fuse pin, which on a Neon-up machine is DORMANT in the file (vault.ts writes
47
+ // it to Neon `focus_pin`, not focus.yaml — [[heyspex-is-saas-cloud-primary-both-tiers]]).
48
+ // The caller fetches it at the async boundary (`injectNeonFocusPin`) and threads
49
+ // the spec_id in here so this sync resolver can honor it. We resolve it to a
50
+ // focus object that supersedes the file's layer-2 `spec_path` entirely, so a
51
+ // stale dormant-file pin is never resurrected. FCPN follow-on (the documented
52
+ // paid-tier soak gap). Falls through to the file when the id doesn't resolve.
53
+ const neonPinSpecId = options.neonPinSpecId;
54
+ if (typeof neonPinSpecId === 'string' && neonPinSpecId) {
55
+ const resolved = findSpecFile(vaultRoot, neonPinSpecId);
56
+ if (resolved) {
57
+ return { spec_path: resolved, pin: { spec_id: neonPinSpecId } };
58
+ }
59
+ // id doesn't resolve to a vault file → don't steer nowhere; use the file.
60
+ }
61
+
62
+ // Precedence #2: the Neon `focus_selection` layer-2 spec_path ([[HS-TASK-FSEL]]).
63
+ // On a Neon-up machine the console writes layer-2 here, not the (dormant) file —
64
+ // so reading it keeps vendor /hs-next in agreement with the console On-Deck.
65
+ // The stored value is already a repo-relative `specs/…md` path.
66
+ const neonSelectionSpecPath = options.neonSelectionSpecPath;
67
+ if (typeof neonSelectionSpecPath === 'string' && neonSelectionSpecPath) {
68
+ return { spec_path: neonSelectionSpecPath };
69
+ }
70
+
71
+ // No `.heyspex/focus.yaml` fallback ([[no-fallbacks-by-default]]; the
72
+ // focus.yaml-fallback incident). Focus lives only in Neon, fetched at the
73
+ // async boundary (`injectNeonFocusPin`) and threaded in via the options
74
+ // above. When neither a Neon pin nor selection is present (console
75
+ // unreachable, or simply no focus set), there is no override — /hs-next
76
+ // resolves the scheduler default off the local vault. Focus steering is a
77
+ // nicety, not a correctness gate, so an absent console degrades to "no
78
+ // steer", never to a stale file read.
79
+ return null;
80
+ }
81
+
82
+ export function extractNextStepBlock(body) {
83
+ // Look for a "NEXT STEP" or "## NEXT STEP" section
84
+ const patterns = [
85
+ /```[^\n]*NEXT STEP[^\n]*\n([\s\S]*?)```/i,
86
+ /## NEXT STEP[:\s]*Ready-to-paste prompt\s*\n\s*```[^\n]*\n([\s\S]*?)```/i,
87
+ /NEXT STEP[:\s]*\n\s*```[^\n]*\n([\s\S]*?)```/i,
88
+ ];
89
+
90
+ for (const pattern of patterns) {
91
+ const match = body.match(pattern);
92
+ if (match) return match[1].trim();
93
+ }
94
+
95
+ return null;
96
+ }
97
+
98
+ /**
99
+ * Build the "Design System Requirements" section for UI epics.
100
+ * @param {Array} decisions - Design decisions to include
101
+ * @param {string[]} triggerTags - Tags that triggered this
102
+ * @returns {string} - Markdown section
103
+ */
104
+ export function buildDesignContextSection(decisions, triggerTags) {
105
+ if (decisions.length === 0) return '';
106
+
107
+ const lines = [
108
+ '## Design System Requirements',
109
+ '',
110
+ `> **Triggered by tags:** ${triggerTags.join(', ')}`,
111
+ '>',
112
+ '> Apply these design decisions before implementation. Violations will be caught by pre-commit hooks.',
113
+ '',
114
+ ];
115
+
116
+ for (const decision of decisions) {
117
+ lines.push(`### ${decision.id}`);
118
+ lines.push('');
119
+ lines.push(decision.summary);
120
+ lines.push('');
121
+
122
+ if (decision.checklist && decision.checklist.length > 0) {
123
+ lines.push('**Checklist:**');
124
+ for (const item of decision.checklist) {
125
+ lines.push(`- [ ] ${item}`);
126
+ }
127
+ lines.push('');
128
+ }
129
+ }
130
+
131
+ lines.push('---');
132
+ lines.push('');
133
+
134
+ return lines.join('\n');
135
+ }
136
+
137
+ export function isInitiative(taskId) {
138
+ return taskId.includes('ROADMAP') || taskId.startsWith('HS-ROADMAP-');
139
+ }
140
+
141
+ // Status filter mirror of `spex-console/src/lib/db/repositories/helpers.ts:SCHEDULER_EXCLUDED_STATUSES`.
142
+ // [[HS-EPIC-FRZE]] — synced to console's full 6-status set (added cancelled /
143
+ // superseded / duplicate). Previously the cli excluded only 3, so `/hs-next`
144
+ // and the console could disagree on the active set for a terminal-but-not-shipped
145
+ // spec (CNVG-AC1 divergence). These are terminal: the work was intentionally
146
+ // dropped, so it must never earn an on-deck position on either surface.
147
+ export const SCHEDULER_EXCLUDED_STATUSES = new Set([
148
+ 'closed',
149
+ 'hibernated',
150
+ 'in_review',
151
+ 'cancelled',
152
+ 'superseded',
153
+ 'duplicate',
154
+ // [[HS-DIAG-YMN3]]: `parked` = indefinite park; scheduler-invisible. Mirrors
155
+ // console's `SCHEDULER_EXCLUDED_STATUSES` (helpers.ts) so `/hs-next` never
156
+ // ranks a parked spec into the active cohort the way the console no longer does.
157
+ 'parked',
158
+ ]);
159
+
160
+ export function isSchedulerExcluded(status, canonical) {
161
+ if (SCHEDULER_EXCLUDED_STATUSES.has(status)) return true;
162
+ return canonical === false;
163
+ }
164
+
165
+ // Mirror of `spex-console/src/lib/scheduler/toposort.ts:isCanonicalSpecId`.
166
+ export function isCanonicalSpecId(id) {
167
+ // Family-keyed kinds (TASK/EPIC/MIL/RES/CAP/REV/…): accept EITHER ordering by
168
+ // recomposing via parseSpecId ([[HS-EPIC-DLE3]]). Non-family-keyed labels
169
+ // (ROADMAP/IDEA/FLOW) are not reordered, so keep the type-first bare check.
170
+ if (toCanonicalFamilyFirstId(id)) return true;
171
+ return /^HS-(?:ROADMAP|IDEA|FLOW)-[A-Z0-9]+$/.test(id);
172
+ }
173
+
174
+ // Mirror of `spex-console/src/lib/vault-shared.ts:canonicalizeSpecRef` — strips
175
+ // wikilink wrappers + slug tails, returning the bare canonical id.
176
+ export function canonicalizeSpecRef(value) {
177
+ if (value == null) return null;
178
+ const inner = wikilinkTarget(String(value));
179
+ if (!inner) return null;
180
+ const m = inner.match(
181
+ /^(HS-(?:TASK|EPIC|MIL|ROADMAP|RES|CAP|REV|IDEA|FLOW)-(?:\d+|[A-Z0-9]+))/,
182
+ );
183
+ return m ? m[1] : inner;
184
+ }
185
+
186
+ // Walk specs/{tasks,epics,milestones} and return one entry per .md file.
187
+ // Result entries are `{ id, frontmatter, relPath }`. Used to seed the scheduler
188
+ // cohort below — we keep the full milestone set so `getActiveMilestones` can
189
+ // run, and partition them out before computeOrder.
190
+ export function loadVaultSpecsForScheduler(vaultRoot) {
191
+ const out = [];
192
+ // Diagnoses ([[HS-MIL-BFIX]]) join the scheduler pool so an open bug fix
193
+ // competes for on-deck by `severity:` (channel B). It is the SINGLE ranker
194
+ // that decides order; the Resolution surface only advises via severity.
195
+ // Disk-only readdir — misses Neon-only (cloud-filed) diagnoses; see [[HS-7UJZ-O86I-DIAG]].
196
+ for (const dir of ['specs/tasks', 'specs/epics', 'specs/milestones', 'specs/diagnoses']) {
197
+ const full = join(vaultRoot, dir);
198
+ if (!existsSync(full)) continue;
199
+ let files;
200
+ try {
201
+ files = readdirSync(full, { withFileTypes: true });
202
+ } catch {
203
+ continue;
204
+ }
205
+ for (const entry of files) {
206
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
207
+ const relPath = `${dir}/${entry.name}`;
208
+ try {
209
+ const content = readFileSync(join(vaultRoot, relPath), 'utf-8');
210
+ const { frontmatter } = parseFrontmatter(content);
211
+ const fallback = entry.name.replace(/\.md$/i, '');
212
+ const id = typeof frontmatter.id === 'string' && frontmatter.id ? frontmatter.id : fallback;
213
+ out.push({ id, frontmatter, relPath });
214
+ } catch {
215
+ // ignore unreadable / malformed files
216
+ }
217
+ }
218
+ }
219
+ return out;
220
+ }
221
+
222
+ // Map a diagnose's `severity:` onto the scheduler's existing `kind` tier
223
+ // ([[HS-MIL-BFIX]] channel B — "map severity to existing signals"). The tier is
224
+ // the first within-layer ranking key in computeOrder. Moderate mapping (owner
225
+ // decision 2026-06-16): an error-severity bug competes at FOUNDATION (just below
226
+ // core infra), a warning at feature, an info at polish — so a build-breaking bug
227
+ // surfaces high WITHOUT outranking genuine infra. Absent/invalid severity →
228
+ // 'feature' (matches detect-bugs.ts's `warning` default). MUST stay byte-identical
229
+ // to the console copy in `spex-console/src/lib/vault.ts` (`DIAGNOSE_SEVERITY_TIER`)
230
+ // so vendor↔console on-deck order is parity-identical.
231
+ export const DIAGNOSE_SEVERITY_TIER = { error: 'foundation', warning: 'feature', info: 'polish' };
232
+
233
+ // The four scheduler tiers a pillar's `library_tier:` may name. Mirrors
234
+ // console `TIER_ORDER` (spex-console/src/lib/scheduler/types.ts).
235
+ export const PILLAR_TIER_VALUES = new Set(['infra', 'foundation', 'feature', 'polish']);
236
+
237
+ // Build a `pillar id → scheduler tier` map by reading `specs/pillars/*.md`.
238
+ // Mirrors console `buildPillarTierMap` (spex-console/src/lib/db/repositories/
239
+ // helpers.ts): an evergreen pillar with `library_tier: foundation` lets epics
240
+ // under it inherit the foundation tier when they set no explicit `kind:`.
241
+ // Pillars whose `library_tier` is not a tier (e.g. `meta`) or is unset
242
+ // contribute no entry. Read from disk separately because the scheduler row
243
+ // loader (`loadVaultSpecsForScheduler`) deliberately omits the pillars dir.
244
+ export function loadPillarTierMap(vaultRoot) {
245
+ const out = new Map();
246
+ const full = join(vaultRoot, 'specs/pillars');
247
+ if (!existsSync(full)) return out;
248
+ let files;
249
+ try {
250
+ files = readdirSync(full, { withFileTypes: true });
251
+ } catch {
252
+ return out;
253
+ }
254
+ for (const entry of files) {
255
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
256
+ try {
257
+ const content = readFileSync(join(full, entry.name), 'utf-8');
258
+ const { frontmatter: fm } = parseFrontmatter(content);
259
+ if (fm.type !== 'pillar') continue;
260
+ const lt = typeof fm.library_tier === 'string' ? fm.library_tier : null;
261
+ if (lt && PILLAR_TIER_VALUES.has(lt)) {
262
+ const fallback = entry.name.replace(/\.md$/i, '');
263
+ const id = typeof fm.id === 'string' && fm.id ? fm.id : fallback;
264
+ out.set(canonicalizeSpecRef(id) || id, lt);
265
+ }
266
+ } catch {
267
+ // ignore unreadable / malformed files
268
+ }
269
+ }
270
+ return out;
271
+ }
272
+
273
+ /**
274
+ * A console `GET /api/specs` row (`SpecSummary`) already flattens every
275
+ * scheduler-relevant frontmatter field to the top level (id, type, status,
276
+ * tags, effort, requires_user_testing, depends_on, canonical, created, kind,
277
+ * library_tier, blast_radius, epics, children, milestone, parent, pillar) —
278
+ * see `spex-console/src/lib/vault-shared.ts`. `vaultRowToSchedulerSpec` /
279
+ * `frontmatterToSchedulerSpec` read the row as if it WERE frontmatter, so this
280
+ * just renames the one field that doesn't share its frontmatter name.
281
+ *
282
+ * Known gap (no console fields= extension exists to close it without editing
283
+ * spex-console, forbidden by this task): `SpecSummary` does not carry
284
+ * `severity`, so a diagnose's severity-derived tier
285
+ * (`vaultRowToSchedulerSpec`'s `DIAGNOSE_SEVERITY_TIER` lookup) always falls
286
+ * back to `'feature'` in cloud reliance instead of reflecting the real
287
+ * severity — documented here rather than silently accepted.
288
+ */
289
+ export function summaryRowToFrontmatterLike(row) {
290
+ return { ...row, what_matters: row.whatMatters ?? row.what_matters };
291
+ }
292
+
293
+ /**
294
+ * Cloud-reliant sibling of `loadVaultSpecsForScheduler` ([[HS-DJWP-MCPC-TASK]]
295
+ * "Scheduler cohort" family). `loadVaultSpecsForScheduler` stays untouched
296
+ * (the `diskRead` branch); in `neon` tier this asks `GET /api/specs` once
297
+ * (no `type=` — the four wanted types are filtered client-side, mirroring
298
+ * the four directories the disk loader walks) instead of `readdirSync`ing
299
+ * `specs/{tasks,epics,milestones,diagnoses}`.
300
+ */
301
+ export async function loadVaultSpecsForSchedulerViaTier(vaultRoot, { tier, consoleUrl }) {
302
+ return readViaSelector({
303
+ tier,
304
+ diskRead: () => loadVaultSpecsForScheduler(vaultRoot),
305
+ neonRead: async () => {
306
+ const res = await consoleFetch('/api/specs');
307
+ if (!res.ok) {
308
+ throw consoleReadErrorFrom(res, `GET /api/specs returned ${res.status}: ${res.error || JSON.stringify(res.body ?? {})}`, null);
309
+ }
310
+ const rows = Array.isArray(res.body?.specs) ? res.body.specs : [];
311
+ const wanted = new Set(['task', 'epic', 'milestone', 'diagnose']);
312
+ return rows
313
+ .filter((r) => wanted.has(r.type))
314
+ .map((r) => ({ id: r.id, frontmatter: summaryRowToFrontmatterLike(r), relPath: r.relPath }));
315
+ },
316
+ routeLabel: 'GET /api/specs',
317
+ consoleUrl,
318
+ });
319
+ }
320
+
321
+ /**
322
+ * Cloud-reliant sibling of `loadPillarTierMap` ([[HS-DJWP-MCPC-TASK]] "Pillar
323
+ * tier map" family). `loadPillarTierMap` stays untouched (the `diskRead`
324
+ * branch); in `neon` tier this asks `GET /api/specs?type=pillar` instead of
325
+ * `readdirSync`ing `specs/pillars`.
326
+ */
327
+ export async function loadPillarTierMapViaTier(vaultRoot, { tier, consoleUrl }) {
328
+ return readViaSelector({
329
+ tier,
330
+ diskRead: () => loadPillarTierMap(vaultRoot),
331
+ neonRead: async () => {
332
+ const res = await consoleFetch('/api/specs?type=pillar');
333
+ if (!res.ok) {
334
+ throw consoleReadErrorFrom(res, `GET /api/specs?type=pillar returned ${res.status}: ${res.error || JSON.stringify(res.body ?? {})}`, null);
335
+ }
336
+ const rows = Array.isArray(res.body?.specs) ? res.body.specs : [];
337
+ const out = new Map();
338
+ for (const r of rows) {
339
+ const lt = typeof r.library_tier === 'string' ? r.library_tier : null;
340
+ if (lt && PILLAR_TIER_VALUES.has(lt)) {
341
+ out.set(canonicalizeSpecRef(r.id) || r.id, lt);
342
+ }
343
+ }
344
+ return out;
345
+ },
346
+ routeLabel: 'GET /api/specs?type=pillar',
347
+ consoleUrl,
348
+ });
349
+ }
350
+
351
+ // Promote a loaded vault row into the SchedulerSpec shape. Mirrors console
352
+ // `summaryToSchedulerSpec` — same fields, derived from raw frontmatter.
353
+ // The unique scheduler node key for a spec row/task ([[HS-K5GQ-DIAG]]): its
354
+ // basename, derived from `relPath` (scheduler rows) or `specPath` (loadTask).
355
+ // Sibling tasks under one epic share a family-key `id:`; the basename is what
356
+ // disambiguates them so `computeOrder` doesn't throw DuplicateIdError. Falls
357
+ // back to `id` when no path is on hand (keeps nodeKey === id, byte-identical).
358
+ export function specBasename(rowOrTask) {
359
+ const p = rowOrTask?.relPath || rowOrTask?.specPath;
360
+ if (typeof p === 'string' && p) {
361
+ return p.replace(/^.*[/\\]/, '').replace(/\.md$/i, '');
362
+ }
363
+ return rowOrTask?.id;
364
+ }
365
+
366
+ // [[HS-67F4-DIAG]] — build a child-id → declared-order-index map from every
367
+ // milestone's `epics:` array and every epic's `children:` array, so the global
368
+ // cohort can stamp each spec's position in its parent's authored sequence
369
+ // (Stage 6.7 tiebreak). Keyed on the canonical child id; first occurrence wins.
370
+ export function buildDeclaredOrderByChild(rows) {
371
+ const byChild = new Map();
372
+ for (const row of rows) {
373
+ const fm = row.frontmatter || {};
374
+ const refs = fm.type === 'milestone' ? fm.epics : fm.type === 'epic' ? fm.children : null;
375
+ if (!Array.isArray(refs)) continue;
376
+ refs.forEach((ref, i) => {
377
+ const key = canonicalizeSpecRef(ref);
378
+ if (key && !byChild.has(key)) byChild.set(key, i);
379
+ });
380
+ }
381
+ return byChild;
382
+ }
383
+
384
+ export function vaultRowToSchedulerSpec(row, pillarTierById, declaredOrderByChild) {
385
+ const spec = frontmatterToSchedulerSpec(row.id, row.frontmatter || {});
386
+ // Unique node key so sibling tasks in the global cohort don't collide.
387
+ spec.nodeKey = specBasename(row);
388
+ const fm = row.frontmatter || {};
389
+ spec.type = typeof fm.type === 'string' ? fm.type : undefined;
390
+ if (spec.type === 'milestone') {
391
+ const epics = Array.isArray(fm.epics) ? fm.epics : [];
392
+ spec.epicsCount = epics.length;
393
+ }
394
+ // Diagnoses carry `severity:`, not the `kind:` tier vocab — derive the tier so
395
+ // computeOrder ranks them by severity instead of sorting them last (no kind).
396
+ if (spec.type === 'diagnose' && !spec.kind) {
397
+ const sev = typeof fm.severity === 'string' ? fm.severity.trim().toLowerCase() : '';
398
+ spec.kind = DIAGNOSE_SEVERITY_TIER[sev] || 'feature';
399
+ }
400
+ spec.milestone = canonicalizeSpecRef(fm.milestone) || undefined;
401
+ spec.parent = canonicalizeSpecRef(fm.parent) || undefined;
402
+ // [[HS-67F4-DIAG]] Stage 6.7 — position in the parent's declared child array.
403
+ if (declaredOrderByChild) {
404
+ const idx = declaredOrderByChild.get(canonicalizeSpecRef(row.id) || row.id);
405
+ if (idx != null) spec.declaredOrderIndex = idx;
406
+ }
407
+ // Stage 2.7 blast-radius band ([[encoding-foundational-and-blast-radius-p]]).
408
+ spec.blast_radius = typeof fm.blast_radius === 'string' ? fm.blast_radius : undefined;
409
+ // Pillar-tier inheritance ([[encoding-foundational-and-blast-radius-p]]): an
410
+ // epic with no explicit `kind:` inherits its pillar's `library_tier` tier, so
411
+ // foundation-pillar work (e.g. HS-PILR-architecture) auto-sorts foundation.
412
+ // Explicit `kind:` always wins; inert when no map is supplied.
413
+ if (!spec.kind && pillarTierById) {
414
+ const pillar = canonicalizeSpecRef(fm.pillar);
415
+ const inherited = pillar ? pillarTierById.get(pillar) : undefined;
416
+ if (inherited) spec.kind = inherited;
417
+ }
418
+ return spec;
419
+ }
420
+
421
+ // Mirror of `spex-console/src/lib/scheduler/pipeline.ts:partitionForScheduler`.
422
+ // Drops epicless milestones + non-canonical-id duplicates. Pure — same input,
423
+ // same output, same byte-equivalence with console.
424
+ export function partitionSchedulerInput(specs) {
425
+ const afterMilestones = [];
426
+ for (const spec of specs) {
427
+ if (spec.type === 'milestone' && (spec.epicsCount ?? 0) === 0) continue;
428
+ afterMilestones.push(spec);
429
+ }
430
+ const kept = [];
431
+ const seen = new Set();
432
+ for (const spec of afterMilestones) {
433
+ // Dedup on the unique nodeKey (not the shared family-key `id:`) so legit
434
+ // sibling tasks both survive ([[HS-K5GQ-DIAG]]); byte-identical when unset.
435
+ const key = spec.nodeKey ?? spec.id;
436
+ if (!seen.has(key)) {
437
+ seen.add(key);
438
+ kept.push(spec);
439
+ continue;
440
+ }
441
+ if (isCanonicalSpecId(spec.id)) kept.push(spec);
442
+ // Non-canonical dup → drop (parity with console; vendor doesn't surface
443
+ // SkippedDuplicate to the agent, only the picked head).
444
+ }
445
+ return kept;
446
+ }
447
+
448
+ // Mirror of `spex-console/src/lib/scheduler/active-milestone.ts:getActiveMilestones`.
449
+ export function getActiveMilestoneIds(specs, manifestOrder) {
450
+ const milestones = specs.filter((s) => s.type === 'milestone');
451
+ if (milestones.length === 0) return [];
452
+ const inProgressEpicMilestones = new Set();
453
+ for (const s of specs) {
454
+ if (s.type !== 'epic' || s.status !== 'in_progress' || !s.milestone) continue;
455
+ inProgressEpicMilestones.add(s.milestone);
456
+ }
457
+ const active = new Set();
458
+ for (const m of milestones) {
459
+ if (m.status === 'in_progress') active.add(m.id);
460
+ else if (m.status === 'open' && inProgressEpicMilestones.has(m.id)) active.add(m.id);
461
+ }
462
+ if (active.size === 0) return [];
463
+ if (manifestOrder && manifestOrder.length > 0) {
464
+ const normalized = manifestOrder.map((id) => normalizeDependsOnId(id));
465
+ const out = [];
466
+ const seen = new Set();
467
+ for (const id of normalized) {
468
+ if (active.has(id) && !seen.has(id)) {
469
+ out.push(id);
470
+ seen.add(id);
471
+ }
472
+ }
473
+ const leftover = [...active].filter((id) => !seen.has(id)).sort();
474
+ return [...out, ...leftover];
475
+ }
476
+ return [...active].sort();
477
+ }
478
+
479
+ // Mirror of `spex-console/src/lib/scheduler/active-milestone.ts:scopeToActiveMilestones`.
480
+ // Drops epics whose milestone is not active (unless pinned), and tasks whose
481
+ // epic-parent was dropped (unless pinned). Inert when no active milestone exists.
482
+ export function scopeToActiveMilestones(specs, opts = {}) {
483
+ const activeMs = opts.activeMilestones ?? new Set();
484
+ const pinned = opts.pinnedIds ?? new Set();
485
+ if (activeMs.size === 0) return [...specs];
486
+ const keptEpicIds = new Set();
487
+ for (const s of specs) {
488
+ if (s.type !== 'epic') continue;
489
+ if (pinned.has(s.id)) {
490
+ keptEpicIds.add(s.id);
491
+ continue;
492
+ }
493
+ if (s.milestone && activeMs.has(s.milestone)) keptEpicIds.add(s.id);
494
+ }
495
+ const out = [];
496
+ for (const s of specs) {
497
+ if (s.type === 'epic') {
498
+ if (keptEpicIds.has(s.id)) out.push(s);
499
+ continue;
500
+ }
501
+ if (s.type === 'task') {
502
+ if (pinned.has(s.id)) { out.push(s); continue; }
503
+ if (s.milestone && activeMs.has(s.milestone)) { out.push(s); continue; }
504
+ if (s.parent && keptEpicIds.has(s.parent)) out.push(s);
505
+ continue;
506
+ }
507
+ out.push(s);
508
+ }
509
+ return out;
510
+ }
511
+
512
+ // Mirror of `spex-console/src/lib/vault.ts:getPinnedSpecIdForScheduler`. The pin
513
+ // id is the input to `applyPin` so vendor and console float the same head.
514
+ export function getPinnedSpecIdFromFocus(focus) {
515
+ if (!focus) return null;
516
+ if (focus.pin && typeof focus.pin.spec_id === 'string' && focus.pin.spec_id) {
517
+ return focus.pin.spec_id;
518
+ }
519
+ if (typeof focus.spec_path === 'string' && focus.spec_path) {
520
+ const base = focus.spec_path.replace(/^.*[/\\]/, '').replace(/\.md$/i, '');
521
+ const m = base.match(
522
+ /^(HS-(?:TASK|EPIC|MIL|ROADMAP|RES|CAP|REV|IDEA|FLOW|PROTO)-(?:\d+|[A-Z0-9]+))/,
523
+ );
524
+ if (m) return m[1];
525
+ }
526
+ return null;
527
+ }
528
+
529
+ // Pick the first scheduler-ordered id that belongs to `candidateIds`. Returns
530
+ // null when none match. The candidates may include refs (basenames /
531
+ // wikilinks); they are canonicalized before lookup so a milestone's `epics:`
532
+ // entry written as `[[HS-EPIC-CNVG-…]]` resolves against the canonical id the
533
+ // scheduler emits.
534
+ export function pickFromSchedulerOrder(scheduler, candidateRefs) {
535
+ if (!scheduler?.order?.length) return null;
536
+ const set = new Set();
537
+ for (const ref of candidateRefs ?? []) {
538
+ const id = canonicalizeSpecRef(ref);
539
+ if (id) set.add(id);
540
+ }
541
+ if (set.size === 0) return null;
542
+ for (const entry of scheduler.order) {
543
+ if (set.has(entry.id)) return entry.id;
544
+ }
545
+ return null;
546
+ }
547
+
548
+ export function sortCandidateEpicsBySchedulerOrder(candidates, vaultRoot) {
549
+ if (!Array.isArray(candidates) || candidates.length === 0) return candidates;
550
+ const cohort = [];
551
+ const skipped = [];
552
+ for (const c of candidates) {
553
+ if (isSchedulerExcluded(c.status, c.canonical)) {
554
+ skipped.push(c);
555
+ continue;
556
+ }
557
+ const id = canonicalizeSpecRef(c.id) || c.id;
558
+ const epic = loadTask(vaultRoot, id);
559
+ if (!epic) {
560
+ skipped.push(c);
561
+ continue;
562
+ }
563
+ const spec = frontmatterToSchedulerSpec(epic.id, epic.frontmatter || {});
564
+ // [[HS-67F4-DIAG]] — stamp the epic's position in the milestone's declared
565
+ // `epics:` order (candidates arrive in that order) + the shared milestone
566
+ // parent, so the Stage 6.7 tiebreak keeps the seed pick in the owner's
567
+ // authored sequence when the epics are otherwise tied on every real signal.
568
+ spec.milestone = canonicalizeSpecRef(epic.frontmatter?.milestone) || '__milestone_epics__';
569
+ spec.declaredOrderIndex = cohort.length;
570
+ cohort.push({ candidate: c, spec });
571
+ }
572
+ if (cohort.length === 0) return candidates;
573
+ let order;
574
+ try {
575
+ order = schedulerComputeOrder(cohort.map((e) => e.spec));
576
+ } catch {
577
+ return candidates;
578
+ }
579
+ const byId = new Map(cohort.map((e) => [e.spec.id, e.candidate]));
580
+ const sorted = [];
581
+ for (const entry of order) {
582
+ const c = byId.get(entry.id);
583
+ if (c) sorted.push(c);
584
+ }
585
+ return [...sorted, ...skipped];
586
+ }
587
+
588
+ // [[HS-TASK-JHIA]] — reorder candidate epics by the FROZEN milestone snapshot
589
+ // order (Neon-sourced via `GET /api/scheduler/milestone-snapshot/:id`), restoring
590
+ // the freeze parity `/hs-next` lost when the file-read seam was removed
591
+ // (2026-06-10). Epics present in the frozen order render in that order; epics
592
+ // absent from it (filed after the snapshot was written, or an empty snapshot)
593
+ // keep their incoming relative order at the tail so new work appends and never
594
+ // reshuffles the frozen rows. Both sides are canonicalized to bare ids so a
595
+ // frozen bare id matches a full-slug candidate ([[scheduler-cache-id-form-parity]]).
596
+ export function applyFrozenEpicOrder(candidates, frozenEpicOrderIds) {
597
+ if (!Array.isArray(candidates) || candidates.length === 0) return candidates;
598
+ if (!Array.isArray(frozenEpicOrderIds) || frozenEpicOrderIds.length === 0) return candidates;
599
+ const pos = new Map();
600
+ frozenEpicOrderIds.forEach((id, idx) => {
601
+ const key = canonicalizeSpecRef(id) || id;
602
+ if (key && !pos.has(key)) pos.set(key, idx);
603
+ });
604
+ const known = [];
605
+ const unknown = [];
606
+ for (const c of candidates) {
607
+ const key = canonicalizeSpecRef(c.id) || c.id;
608
+ if (pos.has(key)) known.push(c);
609
+ else unknown.push(c);
610
+ }
611
+ known.sort((a, b) => {
612
+ const ka = canonicalizeSpecRef(a.id) || a.id;
613
+ const kb = canonicalizeSpecRef(b.id) || b.id;
614
+ return pos.get(ka) - pos.get(kb);
615
+ });
616
+ return [...known, ...unknown];
617
+ }
618
+
619
+ // Build the global scheduler cohort + order for `/hs-next`. Mirrors the path
620
+ // `/api/roadmap/[id]` walks: status filter → active-milestone scope → partition
621
+ // → computeOrder → applyPin. Returns { order, byId, pinnedSpecId, error? }.
622
+ // Errors (cycle, duplicate id) degrade gracefully — caller can fall back to
623
+ // the legacy manifest traversal so a single bad-frontmatter day doesn't break
624
+ // /hs-next.
625
+ // `precomputed.schedulerRows` / `precomputed.pillarTierById` ([[HS-DJWP-MCPC-TASK]]):
626
+ // when supplied, used INSTEAD of the disk loaders below — `handleNextOnDeck`
627
+ // pre-fetches both over HTTP in cloud reliance and threads them in via
628
+ // `resolveNextOnDeck`'s options, so this function never calls
629
+ // `loadVaultSpecsForScheduler`/`loadPillarTierMap` in that tier. Omitted (the
630
+ // default on every OTHER call site) preserves today's disk-only behavior
631
+ // byte-for-byte.
632
+ export function computeGlobalSchedulerOrder(vaultRoot, focus, precomputed = {}) {
633
+ try {
634
+ const rows = precomputed.schedulerRows ?? loadVaultSpecsForScheduler(vaultRoot);
635
+ const pillarTierById = precomputed.pillarTierById ?? loadPillarTierMap(vaultRoot);
636
+ const declaredOrderByChild = buildDeclaredOrderByChild(rows);
637
+ const allSchedulerSpecs = rows.map((r) =>
638
+ vaultRowToSchedulerSpec(r, pillarTierById, declaredOrderByChild),
639
+ );
640
+ const activeMilestones = new Set(getActiveMilestoneIds(allSchedulerSpecs));
641
+ // Tasks + epics + diagnoses with a scheduler-relevant status feed computeOrder
642
+ // (mirrors the `active` filter in console's roadmap + inspect routes).
643
+ // Diagnoses ([[HS-MIL-BFIX]]) are milestone-less leaves; `scopeToActiveMilestones`
644
+ // passes non-epic/task types through its catch-all, so a standalone bug bypasses
645
+ // milestone scoping and competes in the global on-deck queue by `severity:`.
646
+ const active = allSchedulerSpecs.filter(
647
+ (s) => (s.type === 'task' || s.type === 'epic' || s.type === 'diagnose')
648
+ && !isSchedulerExcluded(s.status, s.canonical),
649
+ );
650
+ const pinnedSpecId = getPinnedSpecIdFromFocus(focus);
651
+ const pinnedIds = new Set();
652
+ if (pinnedSpecId) pinnedIds.add(pinnedSpecId);
653
+ const scoped = scopeToActiveMilestones(active, { activeMilestones, pinnedIds });
654
+ const partitioned = partitionSchedulerInput(scoped);
655
+ const base = schedulerComputeOrder(partitioned);
656
+ const order = pinnedSpecId
657
+ ? schedulerApplyPin(base, pinnedSpecId, partitioned)
658
+ : base;
659
+ // Dual-keyed ([[HS-K5GQ-DIAG]]): every row is reachable by its family-key
660
+ // `id:` (back-compat for family-grain consumers) AND by its unique basename
661
+ // (`order[].nodeKey`), so a sibling-task head resolves to the RIGHT row via
662
+ // `byId.get(entry.nodeKey)` instead of an ambiguous family-key lookup.
663
+ const byId = new Map();
664
+ for (const r of rows) {
665
+ byId.set(r.id, r);
666
+ byId.set(specBasename(r), r);
667
+ }
668
+ return { order, byId, pinnedSpecId };
669
+ } catch (err) {
670
+ return { error: err, order: [], byId: new Map(), pinnedSpecId: null };
671
+ }
672
+ }
673
+
674
+ // Bind the pure resolver helpers to this server's vault-aware `loadTask`.
675
+ // The resolver lives in `./blocker-resolver.mjs` — it's unit-testable with a
676
+ // synthetic loader and surfaces a discriminated state per
677
+ // [[depends-on-canonical-blocker-field]] for the focus-override caller below.
678
+ // `mvp_mode.review_backlog_never_blocks` — opt-OUT only, and the default holds
679
+ // with MVP mode OFF too: `in_review` / `review_pending` mean build-complete at
680
+ // every grain, so a review backlog never gates the next claim (owner ruling
681
+ // 2026-08-14; mirrors the console's `PREREQ_SATISFIED_STATUSES`).
682
+ export function blockerOpts(vaultRoot) {
683
+ const mvp = vaultRoot ? readMvpModeState(vaultRoot) : null;
684
+ return { reviewBacklogNeverBlocks: mvp?.review_backlog_never_blocks !== false };
685
+ }
686
+
687
+ export function computeUnmetBlockers(vaultRoot, task) {
688
+ return computeUnmetBlockersImpl((id) => loadTask(vaultRoot, id), task, blockerOpts(vaultRoot));
689
+ }
690
+
691
+ export function resolveEpicChildTask(vaultRoot, epicFrontmatter) {
692
+ return resolveEpicChildTaskImpl(
693
+ (id) => loadTask(vaultRoot, id),
694
+ epicFrontmatter,
695
+ blockerOpts(vaultRoot),
696
+ );
697
+ }
698
+
699
+ // [[HS-TASK-CLIM]] — scheduler-order epic-drill. Picks the seed child by
700
+ // running computeOrder LOCALLY over the epic's actionable children, mirroring
701
+ // console's `pickMilestoneOnDeck` descent pattern. Falls back to the legacy
702
+ // manifest-order resolveEpicChildTask when no children resolve, when no child
703
+ // is actionable (boundary states epic_implementation_complete / epic_done /
704
+ // epic_empty still go through the legacy resolver), or when the local
705
+ // computeOrder throws on a cycle. This deliberately supersedes the
706
+ // manifest-order rule from
707
+ // [[HS-DEC-epic-drill-resolver-manifest-order-and-blocker-filter]] — the
708
+ // "blocker filter" half of that decision still holds (blockers surfaced in
709
+ // the banner via computeUnmetBlockers), but the ordering authority moves to
710
+ // the scheduler so vendor and console cannot disagree on the seed.
711
+ export function resolveEpicChildTaskViaScheduler(vaultRoot, epicFrontmatter) {
712
+ const legacy = () => resolveEpicChildTask(vaultRoot, epicFrontmatter);
713
+ const children = epicFrontmatter?.children;
714
+ if (!Array.isArray(children) || children.length === 0) return { state: 'epic_empty' };
715
+
716
+ const actionable = [];
717
+ for (const raw of children) {
718
+ const target = wikilinkTarget(raw);
719
+ if (!target) continue;
720
+ const task = loadTask(vaultRoot, target);
721
+ if (!task) continue;
722
+ const status = task.status || task.frontmatter?.status;
723
+ if (status === 'open' || status === 'in_progress') {
724
+ actionable.push(task);
725
+ }
726
+ }
727
+ if (actionable.length === 0) return legacy(); // boundary states stay with legacy
728
+
729
+ // Key the LOCAL cohort on each child's unique basename ([[HS-K5GQ-DIAG]]).
730
+ // Sibling tasks under one epic legitimately share a family-key `id:` (e.g. all
731
+ // `id: HS-W6AD-TASK`); without a unique nodeKey `schedulerComputeOrder` throws
732
+ // DuplicateIdError, which — rethrown below — blanked What's Next
733
+ // (SUBPROCESS_EMPTY). The pick is resolved back by that same basename.
734
+ let pickedNodeKey;
735
+ try {
736
+ // [[HS-67F4-DIAG]] — stamp each child's position in the epic's declared
737
+ // `children:` order (`actionable` preserves it) + the shared epic parent, so
738
+ // the Stage 6.7 tiebreak keeps the seed task in the owner's authored sequence
739
+ // when the children are otherwise tied on every real signal.
740
+ const childParentKey =
741
+ canonicalizeSpecRef(epicFrontmatter?.id) || epicFrontmatter?.id || '__epic_children__';
742
+ const cohort = actionable.map((t, i) => ({
743
+ ...frontmatterToSchedulerSpec(t.id, t.frontmatter || {}),
744
+ nodeKey: specBasename(t),
745
+ parent: childParentKey,
746
+ declaredOrderIndex: i,
747
+ }));
748
+ const order = schedulerComputeOrder(cohort);
749
+ pickedNodeKey = order[0]?.nodeKey;
750
+ } catch (err) {
751
+ if (err instanceof SchedulerCycleError) return legacy();
752
+ throw err;
753
+ }
754
+ if (!pickedNodeKey) return legacy();
755
+ const picked = actionable.find((t) => specBasename(t) === pickedNodeKey);
756
+ if (!picked) return legacy();
757
+
758
+ const { blockers, severity } = computeUnmetBlockers(vaultRoot, picked);
759
+ const status = picked.status || picked.frontmatter?.status;
760
+ let state;
761
+ if (status === 'in_progress') state = 'resume';
762
+ else if (severity === 'none') state = 'ready';
763
+ else if (severity === 'soft_review') state = 'soft_review';
764
+ else state = 'hard_blocked';
765
+ return { state, task: picked, blockers, severity };
766
+ }
767
+
768
+ export function resolveMilestoneState(vaultRoot, milestoneFrontmatter) {
769
+ return resolveMilestoneStateImpl(
770
+ (id) => loadTask(vaultRoot, id),
771
+ milestoneFrontmatter,
772
+ );
773
+ }
774
+
775
+ /**
776
+ * Build the structured `/hs-next` response when focus pins a milestone whose
777
+ * `epics:` array is empty (or all entries fail to resolve). Shape matches
778
+ * [[HS-TASK-SKEM-scheduler-skip-epicless-milestones]] §"Structured /hs-next
779
+ * response": `status: "needs_breakdown"`, `suggested_actions[]`, and
780
+ * `next_eligible_spec: null`. The console UI consumer is
781
+ * [[HS-EPIC-MNBD-milestone-needs-breakdown-awareness]].
782
+ */
783
+ export function buildMilestoneNeedsBreakdownResponse({
784
+ milestoneId,
785
+ milestoneName,
786
+ focusSpecPath,
787
+ roadmapId,
788
+ reason,
789
+ }) {
790
+ const suggestedActions = [
791
+ {
792
+ action: 'file_epic',
793
+ cli: `hs epic new --milestone ${milestoneId}`,
794
+ mcp_tool: 'heyspex_file_epic',
795
+ },
796
+ { action: 'unpin', cli: 'hs focus clear' },
797
+ ];
798
+
799
+ const promptLines = [
800
+ `## needs_breakdown — ${milestoneId}`,
801
+ '',
802
+ `**Milestone:** ${milestoneName}`,
803
+ `**Roadmap:** ${roadmapId}`,
804
+ '',
805
+ `${reason}`,
806
+ '',
807
+ '### Suggested next moves',
808
+ '',
809
+ `1. **File a child epic** to break this milestone down: \`${suggestedActions[0].cli}\` (MCP: \`${suggestedActions[0].mcp_tool}\`).`,
810
+ `2. **Unpin** if this milestone was pinned by mistake: \`${suggestedActions[1].cli}\`.`,
811
+ '',
812
+ `_Pinned via \`.heyspex/focus.yaml\` (\`${focusSpecPath}\`). Milestone has no schedulable work yet — surfacing breakdown choices instead of a body excerpt._`,
813
+ ];
814
+
815
+ return {
816
+ status: 'needs_breakdown',
817
+ kind: 'milestone',
818
+ spec_id: milestoneId,
819
+ spec_title: milestoneName,
820
+ reason,
821
+ suggested_actions: suggestedActions,
822
+ next_eligible_spec: null,
823
+ source_kind: 'milestone_needs_breakdown',
824
+ roadmap_id: roadmapId,
825
+ spec_path: focusSpecPath,
826
+ prompt_markdown: promptLines.join('\n'),
827
+ next_action_hint: 'file_epic',
828
+ };
829
+ }
830
+
831
+ /**
832
+ * Build the `/hs-next` response when focus pins a milestone with a populated
833
+ * `epics:` array ([[HS-TASK-K7L9]] of [[HS-EPIC-M2J3]] §5). The milestone is
834
+ * the *scope* of focus; the deferral picks the next actionable child epic
835
+ * (in_progress first, else first non-closed, else first listed) so the agent
836
+ * has a concrete claim target. Multiple equally-actionable epics surface in
837
+ * `candidate_epics` so the agent can ask the human which to start.
838
+ *
839
+ * Mirrors `buildMilestoneNeedsBreakdownResponse` shape: the surface
840
+ * structurally distinguishes "pick a child to start" (this) from "file a child
841
+ * to start" (needs_breakdown) so the console can render either intent without
842
+ * grain-aware fork code.
843
+ */
844
+ export function buildMilestoneDeferToSeedEpicResponse({
845
+ milestoneId,
846
+ milestoneName,
847
+ milestoneBody,
848
+ focusSpecPath,
849
+ roadmapId,
850
+ candidateEpics,
851
+ }) {
852
+ // Pick the scheduler's position-0 over the actionable subset. `candidateEpics`
853
+ // has already been sorted by `sortCandidateEpicsBySchedulerOrder`, which
854
+ // appends SCHEDULER_EXCLUDED_STATUSES items to the tail; falling through to
855
+ // the first non-terminal preserves the scheduler's pick when the head is
856
+ // actionable, and only reaches the tail if every actionable epic was skipped
857
+ // during cohort load. This is the parity contract with console's On Deck —
858
+ // do NOT re-apply a manifest-style "first in_progress" filter here ([[HS-TASK-CLIM]]).
859
+ const SEED_TERMINAL = new Set(['closed', 'shipped', 'archived', 'hibernated', 'in_review']);
860
+ const seed =
861
+ candidateEpics.find((e) => !SEED_TERMINAL.has(e.status)) || candidateEpics[0];
862
+ const multiple = candidateEpics.length > 1;
863
+ const promptLines = [
864
+ `## focus_override_milestone_drill — ${milestoneId}`,
865
+ '',
866
+ `**Milestone:** ${milestoneName}`,
867
+ `**Roadmap:** ${roadmapId}`,
868
+ '',
869
+ `_Pinned via \`.heyspex/focus.yaml\`. Strategic focus is at the milestone grain — pick a child epic to start work below._`,
870
+ '',
871
+ '### Defer to seed epic',
872
+ '',
873
+ multiple
874
+ ? `**Seed epic candidates** (${candidateEpics.length}):`
875
+ : `**Seed epic:** \`${seed.id}\` (\`status: ${seed.status}\`).`,
876
+ '',
877
+ ...candidateEpics.map(
878
+ (e) =>
879
+ `- \`${e.id}\` · status: \`${e.status}\`${e.id === seed.id ? ' ← suggested next' : ''}`,
880
+ ),
881
+ '',
882
+ multiple
883
+ ? `Ask the human which to claim, or default to \`${seed.id}\` (first by scheduler order).`
884
+ : `Run \`/hs-next ${seed.id}\` to drill into the seed epic and claim its next task.`,
885
+ '',
886
+ '---',
887
+ '',
888
+ '### Milestone body excerpt',
889
+ '',
890
+ milestoneBody.length > 2000
891
+ ? `${milestoneBody.slice(0, 2000)}\n\n[... truncated ...]`
892
+ : milestoneBody,
893
+ '',
894
+ '---',
895
+ '',
896
+ `_Spec path: \`${focusSpecPath}\`_`,
897
+ ];
898
+
899
+ return {
900
+ spec_id: milestoneId,
901
+ title: milestoneName,
902
+ status: 'in_progress',
903
+ source_kind: 'focus_override_milestone_drill',
904
+ kind: 'milestone',
905
+ roadmap_id: roadmapId,
906
+ spec_path: focusSpecPath,
907
+ prompt_markdown: promptLines.join('\n'),
908
+ pinned_milestone: { id: milestoneId, name: milestoneName, spec_path: focusSpecPath },
909
+ candidate_epics: candidateEpics,
910
+ seed_epic: seed,
911
+ next_action_hint: multiple ? 'pick_epic' : 'drill_epic',
912
+ };
913
+ }
914
+
915
+ export const STALE_FOCUS_STATUSES = new Set(['in_review', 'closed', 'hibernated']);
916
+
917
+ /**
918
+ * Compute the scheduler explanation trace for `spec` relative to `cohort`
919
+ * — i.e. the same set the spec was picked from. For epic-drill that's the
920
+ * epic's children; for manifest paths that's the phase's open tasks; for
921
+ * direct lookup the cohort is just the spec itself (layer 0, no tiebreaker
922
+ * pressure). Returns `null` if computeOrder fails (cycle) — the caller still
923
+ * surfaces the spec; explanation absence is non-fatal so /hs-next doesn't
924
+ * regress on a bad-frontmatter day.
925
+ *
926
+ * `cohortSpecs` items must be `{ id, frontmatter }` shaped (raw vault loads).
927
+ * The function looks up `pickedSpecId` after computing order and returns the
928
+ * full ExplanationTrace, which always cites tier (stage 2), topological
929
+ * layer (stage 1), and at least one tiebreaker (stage 7 or 8) per AC #1 of
930
+ * [[HS-TASK-C2ZQ]].
931
+ */
932
+ /**
933
+ * Load an epic's child tasks into the `{ id, frontmatter }` shape
934
+ * `buildOnDeckExplanation` expects. Skips children that don't resolve to a
935
+ * vault file — same tolerance as `resolveEpicChildTask`.
936
+ */
937
+ export function loadEpicChildrenCohort(vaultRoot, epicFrontmatter) {
938
+ const children = epicFrontmatter?.children;
939
+ if (!Array.isArray(children)) return [];
940
+ const out = [];
941
+ for (const raw of children) {
942
+ const target = wikilinkTarget(raw);
943
+ if (!target) continue;
944
+ const task = loadTask(vaultRoot, target);
945
+ if (!task) continue;
946
+ out.push({ id: task.id, frontmatter: task.frontmatter || {} });
947
+ }
948
+ return out;
949
+ }
950
+
951
+ /**
952
+ * Load all open tasks declared in a roadmap phase's `tasks:` list into the
953
+ * `{ id, frontmatter }` shape `buildOnDeckExplanation` expects. Initiative
954
+ * refs (nested roadmaps) are not flattened — they're rare in scheduled
955
+ * phases and the picked spec would always be the first open of its phase.
956
+ */
957
+ export function loadPhaseOpenCohort(vaultRoot, phase) {
958
+ const tasks = phase?.tasks;
959
+ if (!Array.isArray(tasks)) return [];
960
+ const out = [];
961
+ for (const raw of tasks) {
962
+ if (isInitiative(raw)) continue;
963
+ const task = loadTask(vaultRoot, raw);
964
+ if (!task) continue;
965
+ if (task.status !== 'open') continue;
966
+ out.push({ id: task.id, frontmatter: task.frontmatter || {} });
967
+ }
968
+ return out;
969
+ }
970
+
971
+ /**
972
+ * Hysteresis anchor for the stateless MCP ([[HS-TASK-MI65]]). Reads the
973
+ * cohort's prior relative order out of the console-written scheduler cache
974
+ * (`<vaultRoot>/.heyspex/scheduler-cache.json`) so `/hs-next` stabilizes the
975
+ * same way the console Scheduler surface does. Best-effort: any missing file,
976
+ * parse error, or schema mismatch returns undefined → hysteresis inert, order
977
+ * byte-identical to a priorless run. Returns cohort ids in cached order.
978
+ */
979
+ export function readSchedulerCachePriorOrder(vaultRoot, cohortIds) {
980
+ if (!vaultRoot) return undefined;
981
+ try {
982
+ const cachePath = join(vaultRoot, '.heyspex', 'scheduler-cache.json');
983
+ if (!existsSync(cachePath)) return undefined;
984
+ const parsed = JSON.parse(readFileSync(cachePath, 'utf-8'));
985
+ const order = Array.isArray(parsed?.order) ? parsed.order : null;
986
+ if (!order || order.length === 0) return undefined;
987
+ const inCohort = new Set(cohortIds);
988
+ const priorOrder = order
989
+ .map((o) => o?.id)
990
+ .filter((id) => typeof id === 'string' && inCohort.has(id));
991
+ return priorOrder.length > 0 ? priorOrder : undefined;
992
+ } catch {
993
+ return undefined;
994
+ }
995
+ }
996
+
997
+ /**
998
+ * Full global scheduler order (bare ids, global rank) from the console-written
999
+ * cache. Used to converge the claim target onto scheduler output so /hs-next's
1000
+ * `claim_target` agrees with its own `seed_epic` and the console On Deck
1001
+ * ([[HS-EPIC-CNVG]]). Best-effort: any miss returns `[]` →
1002
+ * `computeClaimTarget` falls back to declared (`epics:`/`children:`) order.
1003
+ */
1004
+ export function readSchedulerCacheOrderIds(vaultRoot) {
1005
+ if (!vaultRoot) return [];
1006
+ try {
1007
+ const cachePath = join(vaultRoot, '.heyspex', 'scheduler-cache.json');
1008
+ if (!existsSync(cachePath)) return [];
1009
+ const parsed = JSON.parse(readFileSync(cachePath, 'utf-8'));
1010
+ const order = Array.isArray(parsed?.order) ? parsed.order : null;
1011
+ if (!order) return [];
1012
+ return order.map((o) => o?.id).filter((id) => typeof id === 'string');
1013
+ } catch {
1014
+ return [];
1015
+ }
1016
+ }
1017
+
1018
+ export function buildOnDeckExplanation(pickedSpecId, cohortSpecs, vaultRoot) {
1019
+ if (!pickedSpecId) return null;
1020
+ const cohort = Array.isArray(cohortSpecs) && cohortSpecs.length > 0
1021
+ ? cohortSpecs
1022
+ : [{ id: pickedSpecId, frontmatter: {} }];
1023
+ const hasPicked = cohort.some((c) => c.id === pickedSpecId);
1024
+ const effective = hasPicked
1025
+ ? cohort
1026
+ : [...cohort, { id: pickedSpecId, frontmatter: {} }];
1027
+ const schedulerSpecs = effective.map((c) =>
1028
+ frontmatterToSchedulerSpec(c.id, c.frontmatter || {}),
1029
+ );
1030
+ const priorOrder = readSchedulerCachePriorOrder(
1031
+ vaultRoot,
1032
+ schedulerSpecs.map((s) => s.id),
1033
+ );
1034
+ try {
1035
+ const order = schedulerComputeOrder(schedulerSpecs, { priorOrder });
1036
+ const hit = order.find((o) => o.id === pickedSpecId);
1037
+ return hit?.explanation ?? null;
1038
+ } catch (err) {
1039
+ // Degenerate fallback so a cycle in unrelated cohort entries doesn't
1040
+ // erase the picked spec's row-level signal.
1041
+ if (err instanceof SchedulerCycleError) {
1042
+ const picked = schedulerSpecs.find((s) => s.id === pickedSpecId);
1043
+ if (picked) return schedulerBuildExplanation(picked, 0);
1044
+ }
1045
+ return null;
1046
+ }
1047
+ }
1048
+
1049
+ /** Attach the one-line why-now legibility field alongside the raw explanation trace. */
1050
+ export function attachWhyNowFields(response, explanation, pinnedSpecId) {
1051
+ if (!response || !explanation) return;
1052
+ response.explanation = explanation;
1053
+ const pinned =
1054
+ typeof pinnedSpecId === 'string' &&
1055
+ typeof response.spec_id === 'string' &&
1056
+ response.spec_id === pinnedSpecId;
1057
+ response.why_now = schedulerBuildWhyNow(explanation, { pinned });
1058
+ }
1059
+
1060
+ export function resolveNextOnDeck(vaultRoot, roadmapId, options = {}) {
1061
+ const {
1062
+ ignoreFocus = false,
1063
+ includeHorizon = false,
1064
+ neonPinSpecId,
1065
+ neonSelectionSpecPath,
1066
+ frozenEpicOrder,
1067
+ // [[HS-DJWP-MCPC-TASK]]: pre-fetched over HTTP by `handleNextOnDeck` when
1068
+ // cloud-reliant; undefined on every other (disk-reliant) call site, which
1069
+ // falls through to computeGlobalSchedulerOrder's own disk loaders.
1070
+ schedulerRows,
1071
+ pillarTierById,
1072
+ } = options;
1073
+
1074
+ // [[HS-TASK-CLIM]] — global scheduler order, computed once per call. The pin
1075
+ // (Neon `focus_pin` precedence #1 via `neonPinSpecId`, else the file's layer-2
1076
+ // `spec_path`) is fed into applyPin so vendor /hs-next and the console surfaces
1077
+ // (`/api/scheduler/inspect`, `/api/roadmap/[id]`, `/api/focus`) agree on
1078
+ // position-0. Loaded here even when `ignoreFocus: true` is set so the no-pin
1079
+ // path can still consult the scheduler for the head pick.
1080
+ const focus = !ignoreFocus
1081
+ ? loadFocusOverride(vaultRoot, { neonPinSpecId, neonSelectionSpecPath })
1082
+ : null;
1083
+ const scheduler = computeGlobalSchedulerOrder(vaultRoot, focus, { schedulerRows, pillarTierById });
1084
+
1085
+ let staleFocusPin = null;
1086
+
1087
+ const decorate = (response) => {
1088
+ if (!staleFocusPin || !response) return response;
1089
+ response.stale_focus_pin = staleFocusPin;
1090
+ const banner = `> ⚠️ **Stale focus pin ignored:** \`${staleFocusPin.spec_id}\` is \`status: ${staleFocusPin.status}\` (non-actionable). Clear or repoint \`.heyspex/focus.yaml\` — falling through to manifest traversal.\n\n`;
1091
+ if (typeof response.prompt_markdown === 'string') {
1092
+ response.prompt_markdown = banner + response.prompt_markdown;
1093
+ } else if (typeof response.message === 'string') {
1094
+ response.message = `${response.message}. Also: stale focus pin ${staleFocusPin.spec_id} (${staleFocusPin.status}) was ignored — clear or repoint .heyspex/focus.yaml.`;
1095
+ }
1096
+ return response;
1097
+ };
1098
+
1099
+ // Check focus override first. The focus.yaml pin is the canonical
1100
+ // "on deck" signal — it must always win when present (matches the console
1101
+ // On Deck surface). If the focus spec has a NEXT STEP block we use it
1102
+ // verbatim (source_kind: self_perpetuating); otherwise we return a body
1103
+ // excerpt so /hs-next still surfaces the pinned spec instead of drifting
1104
+ // back to manifest order.
1105
+ //
1106
+ // Exception: if the pinned spec's status is non-actionable
1107
+ // (in_review / closed / hibernated), the pin is stale — capture a warning
1108
+ // and fall through to manifest traversal so /hs-next never returns a spec
1109
+ // the agent cannot work on.
1110
+ if (!ignoreFocus) {
1111
+ if (focus && focus.spec_path) {
1112
+ const focusSpec = loadSpec(vaultRoot, focus.spec_path);
1113
+ if (focusSpec && STALE_FOCUS_STATUSES.has(focusSpec.frontmatter.status)) {
1114
+ staleFocusPin = {
1115
+ spec_id: focusSpec.frontmatter.id || focus.spec_path,
1116
+ spec_path: focus.spec_path,
1117
+ status: focusSpec.frontmatter.status,
1118
+ reason: focus.reason || null,
1119
+ };
1120
+ } else if (focusSpec) {
1121
+ // If the pin is a milestone (kind: milestone or type: milestone), the
1122
+ // milestone is the *scope* of focus — the actual work is a child
1123
+ // epic, which itself walks to a child task. Today this resolver only
1124
+ // handles the `needs_breakdown` case (empty/unresolved epics: array);
1125
+ // drilling into a populated milestone is out of scope for SKEM and
1126
+ // falls through to the body-excerpt branch. See
1127
+ // [[HS-TASK-SKEM-scheduler-skip-epicless-milestones]].
1128
+ const isMilestonePin =
1129
+ focus.kind === 'milestone' ||
1130
+ focusSpec.frontmatter.type === 'milestone';
1131
+ if (isMilestonePin) {
1132
+ const milestoneId = focusSpec.frontmatter.id || focus.spec_path;
1133
+ const milestoneName = focusSpec.frontmatter.name || 'Pinned milestone';
1134
+ const milestoneResult = resolveMilestoneState(
1135
+ vaultRoot,
1136
+ focusSpec.frontmatter,
1137
+ );
1138
+ if (milestoneResult.state === 'milestone_needs_breakdown') {
1139
+ return buildMilestoneNeedsBreakdownResponse({
1140
+ milestoneId,
1141
+ milestoneName,
1142
+ focusSpecPath: focus.spec_path,
1143
+ roadmapId,
1144
+ reason: 'Milestone has no child epics yet.',
1145
+ });
1146
+ }
1147
+ if (milestoneResult.state === 'milestone_empty_after_filter') {
1148
+ return buildMilestoneNeedsBreakdownResponse({
1149
+ milestoneId,
1150
+ milestoneName,
1151
+ focusSpecPath: focus.spec_path,
1152
+ roadmapId,
1153
+ reason:
1154
+ 'Milestone declares child epics, but none of them resolve to a spec in the vault — breakdown still pending.',
1155
+ });
1156
+ }
1157
+ if (milestoneResult.state === 'milestone_has_epics') {
1158
+ // [[HS-TASK-K7L9]] of [[HS-EPIC-M2J3]] §5 — milestone is the
1159
+ // *scope* of focus; surface the milestone body and defer the
1160
+ // claim path to the seed epic. [[HS-TASK-CLIM]] resorts the
1161
+ // candidate epics by scheduler position so the seed pick agrees
1162
+ // with console's What's Next column. Scheduler errors / cohort
1163
+ // misses degrade gracefully: epics absent from the order keep
1164
+ // their manifest position (sorted after scheduler-ranked ones).
1165
+ // [[HS-EPIC-FRZE]] / [[HS-TASK-JHIA]]: the frozen milestone snapshot
1166
+ // lives in Neon and this cli has no Neon client, so the frozen epic
1167
+ // order is fetched over HTTP at the async handler boundary
1168
+ // (`injectFrozenMilestoneOrder` → `frozenEpicOrder`). When present we
1169
+ // honor the freeze — the seed epic agrees with the console What's Next
1170
+ // / On Deck surfaces. When absent (flag off, no snapshot, or console
1171
+ // unreachable) we degrade to LIVE scheduler order, exactly as before.
1172
+ const candidateEpics =
1173
+ Array.isArray(frozenEpicOrder) && frozenEpicOrder.length > 0
1174
+ ? applyFrozenEpicOrder(milestoneResult.epics, frozenEpicOrder)
1175
+ : sortCandidateEpicsBySchedulerOrder(milestoneResult.epics, vaultRoot);
1176
+ return buildMilestoneDeferToSeedEpicResponse({
1177
+ milestoneId,
1178
+ milestoneName,
1179
+ milestoneBody: focusSpec.body || '',
1180
+ focusSpecPath: focus.spec_path,
1181
+ roadmapId,
1182
+ candidateEpics,
1183
+ });
1184
+ }
1185
+ }
1186
+
1187
+ // If the pin is an epic (kind: epic or type: epic), the epic is the
1188
+ // *scope* of focus — the actual work is a child task. Drill to the
1189
+ // next actionable child (in_progress first, else first open by
1190
+ // priority) and return that task. Falls through to body-excerpt
1191
+ // behavior only if the epic has no actionable child.
1192
+ const isEpicPin =
1193
+ focus.kind === 'epic' || focusSpec.frontmatter.type === 'epic';
1194
+ if (isEpicPin) {
1195
+ // [[HS-TASK-CLIM]] — drill into children in scheduler position order
1196
+ // (local computeOrder over epic.children, mirroring console's
1197
+ // milestone-descent pattern) so the seed task agrees with console's
1198
+ // On Deck pick. Boundary states (epic_implementation_complete /
1199
+ // epic_done / epic_empty) and scheduler errors fall back to the
1200
+ // legacy manifest-order resolver.
1201
+ const childResult = resolveEpicChildTaskViaScheduler(
1202
+ vaultRoot,
1203
+ focusSpec.frontmatter,
1204
+ );
1205
+ const epicId = focusSpec.frontmatter.id || focus.spec_path;
1206
+ const epicName = focusSpec.frontmatter.name || 'Pinned epic';
1207
+
1208
+ if (childResult && childResult.task) {
1209
+ // resume / ready / soft_review / hard_blocked — picked an actionable
1210
+ // child. Build the standard task response, then decorate with the
1211
+ // state-specific banner + machine-readable hint per
1212
+ // [[depends-on-canonical-blocker-field]].
1213
+ const { task: childTask, blockers = [], severity = 'none', state } = childResult;
1214
+ const phase = { id: epicId, name: `Pinned epic — ${epicName}` };
1215
+ const cohort = loadEpicChildrenCohort(vaultRoot, focusSpec.frontmatter);
1216
+ const response = buildTaskResponse(
1217
+ vaultRoot,
1218
+ childTask,
1219
+ phase,
1220
+ roadmapId,
1221
+ 'focus_override_epic_drill',
1222
+ cohort,
1223
+ );
1224
+ response.pinned_epic = { id: epicId, name: epicName, spec_path: focus.spec_path };
1225
+ response.child_state = state;
1226
+ response.next_action_hint = mapStateToHint(state);
1227
+ response.blocker_severity = severity;
1228
+ if (blockers.length > 0) {
1229
+ response.unmet_blockers = blockers;
1230
+ const banner = buildBlockerBanner(state, blockers);
1231
+ if (banner) {
1232
+ response.prompt_markdown = response.prompt_markdown.replace(
1233
+ '\n\n---\n\n',
1234
+ `${banner}\n\n---\n\n`,
1235
+ );
1236
+ }
1237
+ }
1238
+ return response;
1239
+ }
1240
+
1241
+ if (
1242
+ childResult &&
1243
+ (childResult.state === 'epic_implementation_complete' ||
1244
+ childResult.state === 'epic_done')
1245
+ ) {
1246
+ // Epic boundary — no actionable child. Surface a real prompt with
1247
+ // the three next-move choices (advance focus, /hs-review, ship)
1248
+ // instead of falling through to a confusing body excerpt.
1249
+ const reviewCandidates = childResult.review_candidates || [];
1250
+ const milestoneNextEpics = childResult.milestone_next_epics || [];
1251
+ const promptMarkdown = buildEpicBoundaryPromptMarkdown({
1252
+ state: childResult.state,
1253
+ epicId,
1254
+ epicName,
1255
+ reviewCandidates,
1256
+ milestoneNextEpics,
1257
+ roadmapId,
1258
+ focusSpecPath: focus.spec_path,
1259
+ });
1260
+ return {
1261
+ spec_id: epicId,
1262
+ title: epicName,
1263
+ status: focusSpec.frontmatter.status,
1264
+ source_kind: childResult.state,
1265
+ roadmap_id: roadmapId,
1266
+ prompt_markdown: promptMarkdown,
1267
+ spec_path: focus.spec_path,
1268
+ focus_reason: focus.reason,
1269
+ pinned_epic: { id: epicId, name: epicName, spec_path: focus.spec_path },
1270
+ child_state: childResult.state,
1271
+ next_action_hint: mapStateToHint(childResult.state),
1272
+ review_candidates: reviewCandidates,
1273
+ milestone_next_epics: milestoneNextEpics,
1274
+ };
1275
+ }
1276
+ // epic_empty (or null from legacy code path) — fall through to
1277
+ // body-excerpt behavior below so the agent at least sees the epic
1278
+ // body and can decide what to do.
1279
+ }
1280
+
1281
+ // [[HS-TASK-CLIM]] — task-grain pin parity. When the focused TASK has
1282
+ // unmet open prerequisites, the global scheduler seats those prereqs
1283
+ // ahead of the pin (applyPin is prereq-aware per [[HS-TASK-PINQ]]). The
1284
+ // CLI's #1 must equal computeOrderCached's position-0 — i.e. the
1285
+ // prereq, not the pin — so we re-route to the prereq with a banner.
1286
+ // Epic-empty / milestone fall-throughs and self_perpetuating NEXT STEP
1287
+ // blocks bypass this so a structured pin display isn't replaced with
1288
+ // an unexpected sibling task.
1289
+ const focusType = focus.kind || focusSpec.frontmatter.type;
1290
+ const pinnedId = focusSpec.frontmatter.id
1291
+ || (focus.spec_path
1292
+ ? focus.spec_path.replace(/^.*[/\\]/, '').replace(/\.md$/i, '')
1293
+ : null);
1294
+ if (
1295
+ focusType === 'task'
1296
+ && scheduler
1297
+ && !scheduler.error
1298
+ && scheduler.order?.length
1299
+ && scheduler.order[0].id !== pinnedId
1300
+ ) {
1301
+ const prereqEntry = scheduler.order[0];
1302
+ const prereqId = prereqEntry.id;
1303
+ // Load by the unique nodeKey (basename) so a sibling-task prereq
1304
+ // resolves to the right file, not an ambiguous family-key match
1305
+ // ([[HS-K5GQ-DIAG]]); the banner still shows the family-key id.
1306
+ const prereqTask = loadTask(vaultRoot, prereqEntry.nodeKey ?? prereqId);
1307
+ if (prereqTask) {
1308
+ const phase = {
1309
+ id: pinnedId || 'focus_pin',
1310
+ name: `Pinned task — ${focusSpec.frontmatter.name || pinnedId}`,
1311
+ };
1312
+ const cohort = scheduler.order
1313
+ .slice(0, 25)
1314
+ .map((o) => scheduler.byId.get(o.nodeKey ?? o.id))
1315
+ .filter(Boolean)
1316
+ .map((row) => ({ id: row.id, frontmatter: row.frontmatter }));
1317
+ const response = buildTaskResponse(
1318
+ vaultRoot,
1319
+ prereqTask,
1320
+ phase,
1321
+ roadmapId,
1322
+ 'focus_override_prereq',
1323
+ cohort,
1324
+ );
1325
+ const banner =
1326
+ `\n\n> ⚠️ **Pinned \`${pinnedId}\` has unmet prerequisites.** ` +
1327
+ `Scheduler seated \`${prereqId}\` ahead so the pin can land. ` +
1328
+ `Clear this prereq first, or pin a different spec.`;
1329
+ response.prompt_markdown = response.prompt_markdown.replace(
1330
+ '\n\n---\n\n',
1331
+ `${banner}\n\n---\n\n`,
1332
+ );
1333
+ response.pinned_task = {
1334
+ id: pinnedId,
1335
+ name: focusSpec.frontmatter.name || pinnedId,
1336
+ spec_path: focus.spec_path,
1337
+ };
1338
+ response.next_action_hint = 'clear_prereq';
1339
+ return response;
1340
+ }
1341
+ }
1342
+
1343
+ const uiTags = detectUITags(focusSpec.frontmatter);
1344
+ let designContext = null;
1345
+ let designSection = '';
1346
+ if (uiTags.length > 0) {
1347
+ const designDecisions = loadDesignMemories(vaultRoot, uiTags);
1348
+ if (designDecisions.length > 0) {
1349
+ designContext = {
1350
+ triggered_by_tags: uiTags,
1351
+ decisions: designDecisions,
1352
+ };
1353
+ designSection = buildDesignContextSection(designDecisions, uiTags);
1354
+ }
1355
+ }
1356
+
1357
+ const specId = focusSpec.frontmatter.id || focus.spec_path;
1358
+ const title = focusSpec.frontmatter.name || 'Focus Override';
1359
+ const status = focusSpec.frontmatter.status || 'in_progress';
1360
+
1361
+ // [[HS-TASK-G0HF]] the pinned spec always gets a derived execution brief
1362
+ // (the `## NEXT STEP` block, if any, rides in as the Tier-0 override).
1363
+ // The old "no NEXT STEP → body excerpt" disclaimer path is retired.
1364
+ const hasOverride = !!extractNextStepBlock(focusSpec.body);
1365
+ const promptMarkdown = designSection + deriveTaskBrief(vaultRoot, {
1366
+ id: specId,
1367
+ name: title,
1368
+ status,
1369
+ roadmapId,
1370
+ frontmatter: focusSpec.frontmatter,
1371
+ body: focusSpec.body,
1372
+ specPath: focus.spec_path,
1373
+ });
1374
+ const sourceKind = hasOverride ? 'self_perpetuating' : 'focus_override';
1375
+
1376
+ const response = {
1377
+ spec_id: specId,
1378
+ title,
1379
+ status,
1380
+ source_kind: sourceKind,
1381
+ roadmap_id: roadmapId,
1382
+ prompt_markdown: promptMarkdown,
1383
+ spec_path: focus.spec_path,
1384
+ focus_reason: focus.reason,
1385
+ };
1386
+
1387
+ if (designContext) {
1388
+ response.design_context = designContext;
1389
+ }
1390
+
1391
+ const focusExplanation = buildOnDeckExplanation(specId, [
1392
+ { id: specId, frontmatter: focusSpec.frontmatter || {} },
1393
+ ], vaultRoot);
1394
+ attachWhyNowFields(response, focusExplanation, specId);
1395
+
1396
+ return response;
1397
+ }
1398
+ }
1399
+ }
1400
+
1401
+ // Load roadmap
1402
+ const roadmap = loadRoadmap(vaultRoot, roadmapId);
1403
+ if (!roadmap) {
1404
+ return { error: 'ROADMAP_NOT_FOUND', message: `Roadmap ${roadmapId} not found` };
1405
+ }
1406
+
1407
+ const manifest = roadmap.frontmatter.roadmap_manifest;
1408
+ if (!manifest || (!manifest.milestones && !manifest.phases)) {
1409
+ return { error: 'NO_MANIFEST', message: `Roadmap ${roadmapId} has no manifest` };
1410
+ }
1411
+
1412
+ // [[HS-TASK-CLIM]] — when no focus pin steered the resolver and the global
1413
+ // scheduler returned a non-empty order, the head pick comes straight from
1414
+ // position-0 (source_kind: `scheduler_default`). This is the no-pin parity
1415
+ // contract — console's `/api/focus` and `/api/roadmap/[id]` use the same
1416
+ // computeOrderCached call, so the agent and the surfaces stay in sync. The
1417
+ // v3 manifest traversal below is kept as a graceful fallback for scheduler
1418
+ // errors (cycle, duplicate id) and for cohort emptiness (every spec is
1419
+ // closed / in_review / hibernated).
1420
+ if (scheduler && !scheduler.error && scheduler.order?.length > 0) {
1421
+ const head = scheduler.order[0];
1422
+ // Resolve the head by its unique nodeKey (basename) so a sibling-task head
1423
+ // loads the right file, not an ambiguous family-key match ([[HS-K5GQ-DIAG]]).
1424
+ const headKey = head.nodeKey ?? head.id;
1425
+ const row = scheduler.byId.get(headKey);
1426
+ if (row) {
1427
+ const task = loadTask(vaultRoot, headKey);
1428
+ if (task) {
1429
+ const phase = {
1430
+ id: roadmapId,
1431
+ name: `Scheduler default — ${roadmapId}`,
1432
+ };
1433
+ const cohort = scheduler.order
1434
+ .slice(0, 25)
1435
+ .map((o) => scheduler.byId.get(o.nodeKey ?? o.id))
1436
+ .filter(Boolean)
1437
+ .map((r) => ({ id: r.id, frontmatter: r.frontmatter }));
1438
+ return decorate(
1439
+ buildTaskResponse(vaultRoot, task, phase, roadmapId, 'scheduler_default', cohort),
1440
+ );
1441
+ }
1442
+ }
1443
+ }
1444
+
1445
+ // Manifest v3 ([[HS-TASK-JN9H]]): top-level `milestones:` is the
1446
+ // authoritative priority order. Active-milestone scoping ([[HS-TASK-YDUH]])
1447
+ // skips milestones whose status is not in_progress (and that have no
1448
+ // in_progress child epic). Pinned-focus override already happened above —
1449
+ // this branch is only reached when no focus.yaml drill applied and the
1450
+ // scheduler-default pick above could not produce a head (cycle, empty cohort,
1451
+ // or row not loadable).
1452
+ if (Array.isArray(manifest.milestones)) {
1453
+ const picked = pickFromV3Milestones(vaultRoot, manifest.milestones, roadmapId);
1454
+ if (picked && !picked.error) return decorate(picked);
1455
+ } else if (Array.isArray(manifest.phases)) {
1456
+ // v2 backcompat — kept for one release window per [[HS-TASK-JN9H]] §2.
1457
+ for (const phase of manifest.phases) {
1458
+ for (const taskId of phase.tasks || []) {
1459
+ if (isInitiative(taskId)) {
1460
+ const nested = resolveNextOnDeckOpen(vaultRoot, taskId);
1461
+ if (nested && !nested.error) {
1462
+ return decorate({ ...nested, phase_id: phase.id, phase_name: phase.name });
1463
+ }
1464
+ } else {
1465
+ const task = loadTask(vaultRoot, taskId);
1466
+ if (task && task.status === 'open') {
1467
+ const cohort = loadPhaseOpenCohort(vaultRoot, phase);
1468
+ return decorate(buildTaskResponse(vaultRoot, task, phase, roadmapId, 'on_deck', cohort));
1469
+ }
1470
+ }
1471
+ }
1472
+ }
1473
+ }
1474
+
1475
+ // Horizon (if enabled and no scheduled open tasks). Retired in v3 per
1476
+ // [[HS-TASK-JN9H]] §1; kept here only as a v2 backcompat fall-through.
1477
+ if (includeHorizon && manifest.horizon) {
1478
+ const horizonPhase = { id: 'horizon', name: 'Horizon Backlog', tasks: manifest.horizon };
1479
+ const horizonCohort = loadPhaseOpenCohort(vaultRoot, horizonPhase);
1480
+ for (const taskId of manifest.horizon) {
1481
+ const task = loadTask(vaultRoot, taskId);
1482
+ if (task && task.status === 'open') {
1483
+ return decorate(buildTaskResponse(vaultRoot, task, horizonPhase, roadmapId, 'horizon', horizonCohort));
1484
+ }
1485
+ }
1486
+ }
1487
+
1488
+ return decorate({ error: 'NO_ACTIONABLE_TASK', message: 'All tasks closed or in progress — no open tasks on deck' });
1489
+ }
1490
+
1491
+ /**
1492
+ * v3 manifest traversal with active-milestone scoping ([[HS-TASK-YDUH]]).
1493
+ *
1494
+ * Walks `manifest.milestones` in priority order, skips milestones that are
1495
+ * not active (status !== in_progress AND no in_progress epic child), and
1496
+ * drills into the first active milestone with an actionable child task.
1497
+ *
1498
+ * Returns the standard task-response payload (with `phase_id` set to the
1499
+ * milestone id so existing callers' shape stays intact) or null when no
1500
+ * active milestone has an open child.
1501
+ */
1502
+ export function pickFromV3Milestones(vaultRoot, milestoneRefs, roadmapId) {
1503
+ for (const ref of milestoneRefs) {
1504
+ const milestoneSpec = loadRoadmap(vaultRoot, ref);
1505
+ if (!milestoneSpec) continue;
1506
+ const fm = milestoneSpec.frontmatter || {};
1507
+ if (fm.type !== 'milestone') continue;
1508
+ const status = fm.status;
1509
+ const milestoneState = resolveMilestoneState(vaultRoot, fm);
1510
+ if (milestoneState.state !== 'milestone_has_epics') continue;
1511
+ const epics = milestoneState.epics;
1512
+ const isActive =
1513
+ status === 'in_progress' ||
1514
+ (status === 'open' && epics.some((e) => e.status === 'in_progress'));
1515
+ if (!isActive) continue;
1516
+
1517
+ // Active milestone — drill into its epics looking for an actionable child.
1518
+ // Prefer in_progress epics first (continue the WIP), then open epics.
1519
+ const sortedEpics = [
1520
+ ...epics.filter((e) => e.status === 'in_progress'),
1521
+ ...epics.filter((e) => e.status === 'open'),
1522
+ ];
1523
+ for (const epicSummary of sortedEpics) {
1524
+ const epic = loadTask(vaultRoot, epicSummary.id);
1525
+ if (!epic) continue;
1526
+ const childResult = resolveEpicChildTask(vaultRoot, epic.frontmatter);
1527
+ if (!childResult || !childResult.task) continue;
1528
+ const { task: childTask, blockers = [], severity = 'none', state } = childResult;
1529
+ const phase = { id: epic.id, name: `Active milestone — ${fm.name || ref}` };
1530
+ const cohort = loadEpicChildrenCohort(vaultRoot, epic.frontmatter);
1531
+ const response = buildTaskResponse(
1532
+ vaultRoot,
1533
+ childTask,
1534
+ phase,
1535
+ roadmapId,
1536
+ 'manifest_active_milestone_drill',
1537
+ cohort,
1538
+ );
1539
+ response.active_milestone = {
1540
+ id: fm.id || ref,
1541
+ name: fm.name || ref,
1542
+ spec_path: milestoneSpec.specPath,
1543
+ };
1544
+ response.pinned_epic = {
1545
+ id: epic.id,
1546
+ name: epic.frontmatter?.name || epic.id,
1547
+ spec_path: epic.specPath,
1548
+ };
1549
+ response.child_state = state;
1550
+ response.next_action_hint = mapStateToHint(state);
1551
+ response.blocker_severity = severity;
1552
+ if (blockers.length > 0) {
1553
+ response.unmet_blockers = blockers;
1554
+ const banner = buildBlockerBanner(state, blockers);
1555
+ if (banner) {
1556
+ response.prompt_markdown = response.prompt_markdown.replace(
1557
+ '\n\n---\n\n',
1558
+ `${banner}\n\n---\n\n`,
1559
+ );
1560
+ }
1561
+ }
1562
+ return response;
1563
+ }
1564
+ }
1565
+ return null;
1566
+ }
1567
+
1568
+ export function resolveNextOnDeckOpen(vaultRoot, roadmapId) {
1569
+ const roadmap = loadRoadmap(vaultRoot, roadmapId);
1570
+ if (!roadmap) return null;
1571
+
1572
+ const manifest = roadmap.frontmatter.roadmap_manifest;
1573
+ if (!manifest || !manifest.phases) return null;
1574
+
1575
+ for (const phase of manifest.phases) {
1576
+ for (const taskId of phase.tasks || []) {
1577
+ if (isInitiative(taskId)) {
1578
+ const nested = resolveNextOnDeckOpen(vaultRoot, taskId);
1579
+ if (nested) return nested;
1580
+ } else {
1581
+ const task = loadTask(vaultRoot, taskId);
1582
+ if (task && task.status === 'open') {
1583
+ const cohort = loadPhaseOpenCohort(vaultRoot, phase);
1584
+ return buildTaskResponse(vaultRoot, task, phase, roadmapId, 'manifest_open', cohort);
1585
+ }
1586
+ }
1587
+ }
1588
+ }
1589
+
1590
+ return null;
1591
+ }
1592
+
1593
+ /**
1594
+ * Build the derived execution brief for a task-shaped spec node (the Tier 0–2
1595
+ * composer lives in alignment-gate.mjs). The NEXT STEP block, if present, is the
1596
+ * Tier-0 override (prepended). Pure aside from the injected vault loader.
1597
+ */
1598
+ export function deriveTaskBrief(vaultRoot, { id, name, status, roadmapId, frontmatter, body, specPath, cloudTaskGraph }) {
1599
+ // [[HS-H227-MCPC-TASK]]: `cloudTaskGraph`, when supplied by the direct
1600
+ // spec_id branch of `resolveNextOnDeckPayload` (cloud tier only), routes
1601
+ // this decomposition-context walk through the HTTP pre-fetch instead of
1602
+ // disk. Every other caller (buildTaskResponse's disk-only scheduler path)
1603
+ // passes nothing here and keeps the original `loadTask` behavior.
1604
+ const loadRef = (ref) => loadTaskViaGraph(vaultRoot, ref, cloudTaskGraph);
1605
+ const node = { id, name, frontmatter: frontmatter || {}, body: body || '' };
1606
+ const context = extractDecompositionContext(node, loadRef, 'epic');
1607
+ const override = extractNextStepBlock(body || '');
1608
+ const hasOverride = typeof override === 'string' && override.trim().length > 0;
1609
+
1610
+ // Tier-3 readiness floor ([[HS-TASK-RW3B]]) — runs BEFORE synthesis. A spec
1611
+ // below the minimum-viable-input threshold routes to a recoverable
1612
+ // clarification surface naming the missing fields instead of a thin brief.
1613
+ // An authored `## NEXT STEP` override is itself sufficient input (a human
1614
+ // pinned the prompt), so it bypasses the floor.
1615
+ let ready = true;
1616
+ let markdown;
1617
+ if (hasOverride) {
1618
+ markdown = buildExecutionBrief({ id, name, status, roadmapId, context, override });
1619
+ } else {
1620
+ const floor = readReadinessFloorSettings(vaultRoot);
1621
+ const readiness = assessBriefReadiness({ name, context, floor });
1622
+ ready = readiness.ready;
1623
+ markdown = ready
1624
+ ? buildExecutionBrief({ id, name, status, roadmapId, context, override })
1625
+ : buildClarificationPrompt({
1626
+ id,
1627
+ name,
1628
+ status,
1629
+ roadmapId,
1630
+ missing: readiness.missing,
1631
+ focusSpecPath: specPath,
1632
+ });
1633
+ }
1634
+
1635
+ // Observability ([[HS-TASK-68NO]]) — record the served tier + emit the running
1636
+ // degraded-mode rate so a degrading system is visible, never silent.
1637
+ const tier = classifyBriefTier({ override, context, ready });
1638
+ const stats = recordBriefTier(tier);
1639
+ // One-shot CLI paths (`hs next`, `hs mcp call`, unit tests) set
1640
+ // HEYSPEX_DISABLE_STDIO_BOOTSTRAP — skip stderr tier logs there. PowerShell
1641
+ // paints stderr red and shows NativeCommandError even on success; humans
1642
+ // running `--json` only want stdout JSON ([[HS-TASK-ISK7]]).
1643
+ if (process.env.HEYSPEX_DISABLE_STDIO_BOOTSTRAP !== '1') {
1644
+ process.stderr.write(`${formatBriefTierLog({ tier, id, stats })}\n`);
1645
+ }
1646
+
1647
+ return markdown;
1648
+ }
1649
+
1650
+ export function buildTaskResponse(vaultRoot, task, phase, roadmapId, sourceKind, cohortSpecs = null) {
1651
+ // Detect UI tags and load design context if applicable
1652
+ const uiTags = detectUITags(task.frontmatter);
1653
+ let designContext = null;
1654
+ let designSection = '';
1655
+
1656
+ if (uiTags.length > 0) {
1657
+ const designDecisions = loadDesignMemories(vaultRoot, uiTags);
1658
+ if (designDecisions.length > 0) {
1659
+ designContext = {
1660
+ triggered_by_tags: uiTags,
1661
+ decisions: designDecisions,
1662
+ };
1663
+ designSection = buildDesignContextSection(designDecisions, uiTags);
1664
+ }
1665
+ }
1666
+
1667
+ // [[HS-TASK-G0HF]] derived execution brief — the only path. An acceptance-
1668
+ // criteria contract composed deterministically; the `## NEXT STEP` block, if
1669
+ // present, rides in as the Tier-0 override (no raw body-excerpt fallback).
1670
+ const brief = deriveTaskBrief(vaultRoot, {
1671
+ id: task.id,
1672
+ name: task.name,
1673
+ status: task.status,
1674
+ roadmapId,
1675
+ frontmatter: task.frontmatter,
1676
+ body: task.body,
1677
+ specPath: task.specPath,
1678
+ });
1679
+ const promptMarkdown = `${designSection}${brief}\n\n---\n\n_Spec path: \`${task.specPath}\`_`;
1680
+
1681
+ const response = {
1682
+ spec_id: task.id,
1683
+ title: task.name,
1684
+ status: task.status,
1685
+ source_kind: sourceKind,
1686
+ phase_id: phase.id,
1687
+ phase_name: phase.name,
1688
+ roadmap_id: roadmapId,
1689
+ prompt_markdown: promptMarkdown,
1690
+ spec_path: task.specPath,
1691
+ };
1692
+
1693
+ // Include design_context if UI tags detected
1694
+ if (designContext) {
1695
+ response.design_context = designContext;
1696
+ }
1697
+
1698
+ // Scheduler explanation trace ([[HS-TASK-C2ZQ]] AC #1). Cohort defaults to
1699
+ // the picked spec when callers don't pass a richer set — better to emit a
1700
+ // single-spec trace than to drop the field entirely.
1701
+ const cohort = Array.isArray(cohortSpecs) && cohortSpecs.length > 0
1702
+ ? cohortSpecs
1703
+ : [{ id: task.id, frontmatter: task.frontmatter || {} }];
1704
+ const explanation = buildOnDeckExplanation(task.id, cohort, vaultRoot);
1705
+ attachWhyNowFields(response, explanation, null);
1706
+
1707
+ return response;
1708
+ }