heyspex 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +14 -0
- package/bin/hs.mjs +163 -11
- package/dist/cli/hs-check.mjs +88 -46
- package/dist/cli/hs-claim.mjs +84 -39
- package/dist/cli/hs-connect.mjs +583 -129
- package/dist/cli/hs-defer.mjs +251 -30
- package/dist/cli/hs-dev.mjs +20 -8
- package/dist/cli/hs-env.mjs +62 -34
- package/dist/cli/hs-index-rebuild.mjs +288 -83
- package/dist/cli/hs-index-reconcile.mjs +397 -111
- package/dist/cli/hs-init.mjs +2143 -91
- package/dist/cli/hs-install.mjs +628 -106
- package/dist/cli/hs-join.mjs +686 -236
- package/dist/cli/hs-vault-pull.mjs +3189 -179
- package/dist/cli/hs-vault-push.mjs +6433 -0
- package/dist/cli/hs-vault.mjs +396 -178
- package/dist/cli/hs-workspace.mjs +289 -151
- package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -28
- package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -62
- package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -29
- package/dist/cli-templates/harness-commands/claude/hs-ground.md +30 -28
- package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -29
- package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -70
- package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -35
- package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -27
- package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -23
- package/dist/cli-templates/harness-commands/claude/hs-review.md +23 -23
- package/dist/cli-templates/harness-commands/claude/hs-view.md +28 -26
- package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -32
- package/dist/cli-templates/harness-commands/claude/prototype.md +91 -91
- package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +22 -22
- package/dist/cli-templates/harness-commands/cursor/hs-capture.md +56 -56
- package/dist/cli-templates/harness-commands/cursor/hs-defer.md +23 -23
- package/dist/cli-templates/harness-commands/cursor/hs-ground.md +24 -22
- package/dist/cli-templates/harness-commands/cursor/hs-next.md +23 -23
- package/dist/cli-templates/harness-commands/cursor/hs-promote.md +64 -64
- package/dist/cli-templates/harness-commands/cursor/hs-push.md +29 -29
- package/dist/cli-templates/harness-commands/cursor/hs-qc.md +21 -21
- package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +17 -17
- package/dist/cli-templates/harness-commands/cursor/hs-review.md +17 -17
- package/dist/cli-templates/harness-commands/cursor/hs-view.md +24 -22
- package/dist/cli-templates/harness-commands/cursor/kill-ports.md +26 -26
- package/dist/cli-templates/harness-commands/cursor/prototype.md +86 -86
- package/docs/copilot-plugin-install.md +162 -0
- package/docs/driving-heyspex-from-copilot.md +165 -0
- package/package.json +20 -2
- package/plugins/heyspex/.claude-plugin/plugin.json +12 -12
- package/plugins/heyspex/.cursor-plugin/plugin.json +15 -15
- package/plugins/heyspex/README.md +309 -0
- package/plugins/heyspex/bin/run-hook.mjs +20 -2
- package/plugins/heyspex/hooks/cursor-hooks.json +81 -0
- package/plugins/heyspex/hooks/hooks.json +94 -37
- package/scripts/claude-hooks/_intent-classifier-router.mjs +456 -410
- package/scripts/claude-hooks/_intent-classifier-semantic.mjs +5 -0
- package/scripts/claude-hooks/_run.mjs +316 -23
- package/scripts/claude-hooks/capture-execution-gate.mjs +51 -0
- package/scripts/claude-hooks/claim-liveness-shared.mjs +2 -0
- package/scripts/claude-hooks/diagnose-capture-reminder.mjs +11 -0
- package/scripts/claude-hooks/directive-obligation-gate.mjs +11 -0
- package/scripts/claude-hooks/inject-communication-style.mjs +20 -0
- package/scripts/claude-hooks/intent-classifier-trigger.mjs +279 -247
- package/scripts/claude-hooks/session-end.mjs +2 -0
- package/scripts/claude-hooks/vault-mirror-catchup.mjs +11 -0
- package/scripts/claude-hooks/vault-write-tier-guard.mjs +11 -0
- package/scripts/claude-hooks/wip-checkpoint.mjs +5 -1
- package/scripts/cursor-hooks/manifest.mjs +46 -0
- package/scripts/heyspex-mode.mjs +20 -0
- package/scripts/hs-mode.mjs +1 -1
- package/scripts/hs-view.mjs +180 -17
- package/scripts/install-claude-code-hooks.mjs +3 -1
- package/scripts/lib/console-url-resolver.mjs +3 -2
- package/scripts/lib/customer-kit.mjs +327 -10
- package/scripts/lib/dev-mode.mjs +7 -6
- package/scripts/lib/directive-obligations.mjs +349 -0
- package/scripts/lib/hook-dispositions.mjs +210 -0
- package/scripts/lib/hook-vault-header.mjs +30 -0
- package/scripts/lib/hs-source-branch.mjs +196 -0
- package/scripts/lib/hs-source-log.mjs +2 -1
- package/scripts/lib/hs-source-preflight.mjs +18 -4
- package/scripts/lib/hs-source-push.mjs +386 -356
- package/scripts/lib/window-placement.mjs +109 -0
- package/spexharness/src/adapters/claude-code.mjs +11 -3
- package/spexharness/src/adapters/copilot.mjs +236 -0
- package/spexharness/src/adapters/detect-harness.mjs +34 -0
- package/spexharness/src/gates/_shell-write-targets.mjs +77 -0
- package/spexharness/src/gates/deep-research-cost-gate.mjs +4 -3
- package/spexharness/src/gates/diagnose-capture-reminder.mjs +112 -0
- package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -0
- package/spexharness/src/gates/directive-obligation-gate.mjs +183 -0
- package/spexharness/src/gates/vault-mirror-catchup.mjs +124 -0
- package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +125 -0
- package/spexharness/src/gates/vault-write-tier-guard.mjs +42 -0
- package/spexharness/src/intent-classifier.mjs +4 -2
- package/spexharness/src/intent-directive.mjs +626 -541
- package/spexharness/src/rule-table.mjs +31 -1
- package/vendor/heyspex-mcp/account-switch.mjs +6 -5
- package/vendor/heyspex-mcp/capture-buffer-patches.mjs +31 -3
- package/vendor/heyspex-mcp/capture-console-client.mjs +37 -2
- package/vendor/heyspex-mcp/capture-focus.mjs +26 -2
- package/vendor/heyspex-mcp/capture-outbox.mjs +10 -4
- package/vendor/heyspex-mcp/capture-sources.mjs +195 -0
- package/vendor/heyspex-mcp/capture-tools.mjs +56 -11
- package/vendor/heyspex-mcp/claim-tools.mjs +25 -2
- package/vendor/heyspex-mcp/cli-verbs.mjs +19 -21
- package/vendor/heyspex-mcp/cli.mjs +7 -1
- package/vendor/heyspex-mcp/close-branch-report.mjs +102 -0
- package/vendor/heyspex-mcp/cloud-vault-writes.mjs +197 -7
- package/vendor/heyspex-mcp/console-fetch.mjs +192 -28
- package/vendor/heyspex-mcp/console-transport.mjs +7 -1
- package/vendor/heyspex-mcp/credentials.mjs +9 -8
- package/vendor/heyspex-mcp/dev-tools.mjs +7 -0
- package/vendor/heyspex-mcp/filing-tools.mjs +35 -0
- package/vendor/heyspex-mcp/fleet-queue-tools.mjs +2 -1
- package/vendor/heyspex-mcp/folder-trust-store.mjs +2 -1
- package/vendor/heyspex-mcp/http-transport.mjs +2 -0
- package/vendor/heyspex-mcp/list-tools.mjs +1 -1
- package/vendor/heyspex-mcp/mcp-config.mjs +14 -1
- package/vendor/heyspex-mcp/memory-tools.mjs +523 -523
- package/vendor/heyspex-mcp/obligation-backstop.mjs +103 -0
- package/vendor/heyspex-mcp/occupied-keys.mjs +1 -0
- package/vendor/heyspex-mcp/on-deck-brief.mjs +19 -2
- package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -1708
- package/vendor/heyspex-mcp/plan-push.mjs +3 -2
- package/vendor/heyspex-mcp/preflight-gate.mjs +1 -0
- package/vendor/heyspex-mcp/procedure-handlers.mjs +327 -325
- package/vendor/heyspex-mcp/procedure-tools.mjs +1181 -1159
- package/vendor/heyspex-mcp/promote-capture.mjs +2 -0
- package/vendor/heyspex-mcp/promote-research.mjs +6 -0
- package/vendor/heyspex-mcp/read-path.mjs +162 -6
- package/vendor/heyspex-mcp/request-context.mjs +25 -1
- package/vendor/heyspex-mcp/research-handlers.mjs +6 -4
- package/vendor/heyspex-mcp/roadmap-default.mjs +70 -0
- package/vendor/heyspex-mcp/session-owner.mjs +3 -2
- package/vendor/heyspex-mcp/session-tools.mjs +10 -0
- package/vendor/heyspex-mcp/spec-edit-tools.mjs +8 -1
- package/vendor/heyspex-mcp/split-research.mjs +3 -0
- package/vendor/heyspex-mcp/submit-for-review.mjs +18 -1
- package/vendor/heyspex-mcp/summary-write-tools.mjs +5 -1
- package/vendor/heyspex-mcp/test-helpers.mjs +18 -3
- package/vendor/heyspex-mcp/tool-catalog.mjs +7 -1
- package/vendor/heyspex-mcp/tool-dispatch.mjs +18 -0
- package/vendor/heyspex-mcp/vault-io.mjs +720 -724
- package/vendor/heyspex-mcp/vault-resolve.mjs +197 -9
- package/vendor/heyspex-mcp/whoami.mjs +2 -1
- package/vendor/heyspex-mcp/write-outbox.mjs +512 -0
|
@@ -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 { 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
|
|
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
|
|
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
|
+
}
|