heyspex 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +157 -0
  2. package/bin/hs.mjs +1028 -0
  3. package/bin/postinstall.mjs +88 -0
  4. package/dev-factory/lib/fleet-readiness.mjs +274 -0
  5. package/dev-factory/lib/fleet-state.mjs +250 -0
  6. package/dev-factory/lib/sufficiency-score.mjs +441 -0
  7. package/dist/cli/hs-check.mjs +747 -0
  8. package/dist/cli/hs-claim.mjs +541 -0
  9. package/dist/cli/hs-connect.mjs +3436 -0
  10. package/dist/cli/hs-defer.mjs +8455 -0
  11. package/dist/cli/hs-env.mjs +36845 -0
  12. package/dist/cli/hs-init.mjs +1145 -0
  13. package/dist/cli/hs-install.mjs +1390 -0
  14. package/dist/cli/hs-join.mjs +38553 -0
  15. package/dist/cli/hs-vault-pull.mjs +7782 -0
  16. package/dist/cli/hs-vault.mjs +4166 -0
  17. package/dist/cli/hs-workspace.mjs +2898 -0
  18. package/dist/cli-templates/customer-vault/specs/entity-model.md +1735 -0
  19. package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +531 -0
  20. package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +48 -0
  21. package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +548 -0
  22. package/docs/mcp-client-config.md +200 -0
  23. package/package.json +124 -0
  24. package/scripts/check-mcp-freshness.mjs +379 -0
  25. package/scripts/dev-server-status.mjs +722 -0
  26. package/scripts/gateway-skills-lib.mjs +421 -0
  27. package/scripts/lib/console-url-resolver.mjs +152 -0
  28. package/scripts/lib/dev-mode.mjs +369 -0
  29. package/scripts/lib/hs-link.mjs +154 -0
  30. package/scripts/lib/hs-source-commit.mjs +207 -0
  31. package/scripts/lib/hs-source-log.mjs +366 -0
  32. package/scripts/lib/hs-source-preflight.mjs +402 -0
  33. package/scripts/lib/hs-source-push.mjs +307 -0
  34. package/scripts/lib/hs-source-secret-scan.mjs +263 -0
  35. package/scripts/lib/standalone-doctor.mjs +89 -0
  36. package/scripts/vault-id-lib.mjs +173 -0
  37. package/vendor/heyspex-mcp/alignment-gate.mjs +1347 -0
  38. package/vendor/heyspex-mcp/blocker-resolver.mjs +891 -0
  39. package/vendor/heyspex-mcp/breakdown-ladder.mjs +309 -0
  40. package/vendor/heyspex-mcp/breakdown-source.mjs +236 -0
  41. package/vendor/heyspex-mcp/breakdown-writer.mjs +524 -0
  42. package/vendor/heyspex-mcp/brief-observability.mjs +67 -0
  43. package/vendor/heyspex-mcp/canvas-inbox.mjs +130 -0
  44. package/vendor/heyspex-mcp/capture-active-rehydrate.mjs +76 -0
  45. package/vendor/heyspex-mcp/capture-buffer-patches.mjs +147 -0
  46. package/vendor/heyspex-mcp/capture-channel.mjs +577 -0
  47. package/vendor/heyspex-mcp/capture-console-client.mjs +290 -0
  48. package/vendor/heyspex-mcp/capture-focus.mjs +1366 -0
  49. package/vendor/heyspex-mcp/capture-kind-suggest.mjs +121 -0
  50. package/vendor/heyspex-mcp/capture-outbox.mjs +239 -0
  51. package/vendor/heyspex-mcp/capture-stage-gate.mjs +160 -0
  52. package/vendor/heyspex-mcp/cli.mjs +15754 -0
  53. package/vendor/heyspex-mcp/config-settings.mjs +419 -0
  54. package/vendor/heyspex-mcp/console-fetch.mjs +342 -0
  55. package/vendor/heyspex-mcp/defer-spec.mjs +527 -0
  56. package/vendor/heyspex-mcp/deferred-reclaim.mjs +284 -0
  57. package/vendor/heyspex-mcp/design-tools.mjs +449 -0
  58. package/vendor/heyspex-mcp/dev-factory-bridge.mjs +66 -0
  59. package/vendor/heyspex-mcp/diagnose-skeleton.mjs +114 -0
  60. package/vendor/heyspex-mcp/fleet-queue-tools.mjs +241 -0
  61. package/vendor/heyspex-mcp/hooks-engine.mjs +399 -0
  62. package/vendor/heyspex-mcp/login-handoff.mjs +438 -0
  63. package/vendor/heyspex-mcp/mcp-subprocess.mjs +136 -0
  64. package/vendor/heyspex-mcp/mvp-mode.mjs +101 -0
  65. package/vendor/heyspex-mcp/next-resolution.mjs +240 -0
  66. package/vendor/heyspex-mcp/next-review.mjs +548 -0
  67. package/vendor/heyspex-mcp/peer-presence.mjs +430 -0
  68. package/vendor/heyspex-mcp/plan-ground-conflicts.mjs +321 -0
  69. package/vendor/heyspex-mcp/plan-ground.mjs +2001 -0
  70. package/vendor/heyspex-mcp/plan-ingest.mjs +476 -0
  71. package/vendor/heyspex-mcp/plan-push.mjs +1826 -0
  72. package/vendor/heyspex-mcp/plan-review.mjs +1419 -0
  73. package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
  74. package/vendor/heyspex-mcp/procedure-tools.mjs +970 -0
  75. package/vendor/heyspex-mcp/promote-capture.mjs +1280 -0
  76. package/vendor/heyspex-mcp/promote-diagnose.mjs +187 -0
  77. package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +338 -0
  78. package/vendor/heyspex-mcp/promote-research.mjs +1553 -0
  79. package/vendor/heyspex-mcp/promotion-helpers.mjs +280 -0
  80. package/vendor/heyspex-mcp/read-path.mjs +138 -0
  81. package/vendor/heyspex-mcp/recipe-tools.mjs +258 -0
  82. package/vendor/heyspex-mcp/regen-tasks.mjs +326 -0
  83. package/vendor/heyspex-mcp/register-spec.mjs +417 -0
  84. package/vendor/heyspex-mcp/research-focus.mjs +476 -0
  85. package/vendor/heyspex-mcp/research-tools.mjs +109 -0
  86. package/vendor/heyspex-mcp/research-type-suggest.mjs +112 -0
  87. package/vendor/heyspex-mcp/resolution-focus.mjs +45 -0
  88. package/vendor/heyspex-mcp/review-focus.mjs +55 -0
  89. package/vendor/heyspex-mcp/review-rollup.mjs +47 -0
  90. package/vendor/heyspex-mcp/run-front.mjs +60 -0
  91. package/vendor/heyspex-mcp/scheduler.mjs +813 -0
  92. package/vendor/heyspex-mcp/seat-identity.mjs +281 -0
  93. package/vendor/heyspex-mcp/settings-tools.mjs +119 -0
  94. package/vendor/heyspex-mcp/spec-id-lib.mjs +790 -0
  95. package/vendor/heyspex-mcp/split-research.mjs +832 -0
  96. package/vendor/heyspex-mcp/submit-for-review.mjs +190 -0
  97. package/vendor/heyspex-mcp/summary-contract.mjs +162 -0
  98. package/vendor/heyspex-mcp/test-helpers.mjs +43 -0
  99. package/vendor/heyspex-mcp/validate-linkage.mjs +574 -0
  100. package/vendor/heyspex-mcp/vault-resolve.mjs +297 -0
  101. package/vendor/heyspex-mcp/vault-settings.mjs +87 -0
  102. package/vendor/heyspex-mcp/verify-reproduction.mjs +314 -0
  103. package/vendor/heyspex-mcp/why-now.mjs +64 -0
  104. package/vendor/heyspex-mcp/yaml-scalar.mjs +34 -0
@@ -0,0 +1,891 @@
1
+ /**
2
+ * Blocker resolver — pure logic backing heyspex_next_on_deck epic-drill.
3
+ *
4
+ * Reads `depends_on` (canonical) and `blocked_by` (back-compat) from spec
5
+ * frontmatter, classifies blocker severity by target status, and returns a
6
+ * discriminated state for the resolver caller in cli.mjs.
7
+ *
8
+ * Pure-ish: every IO is injected via `loadTask(idOrBasename) => task|null`,
9
+ * so this module is hermetic against the vault and unit-testable with a
10
+ * synthetic loader.
11
+ *
12
+ * Spec: see `C:\Users\pwher\.claude\plans\mighty-baking-biscuit.md`.
13
+ * Workflow context: the user implements an entire epic (sometimes an entire
14
+ * milestone) before reviewing — so sibling `in_review` / `review_pending`
15
+ * predecessors are EXPECTED intra-epic state, not a stop-and-review signal
16
+ * (and never a reason to ask the owner for permission). Intra-epic
17
+ * blockers surface as `soft_review` with a brief "proceed" note; only at the
18
+ * epic boundary (no `open` children left) does the resolver surface review
19
+ * candidates and next-epic options.
20
+ */
21
+
22
+ import { readMvpModeState } from './mvp-mode.mjs';
23
+ import { classifyParentRollup, rollupOptsFromMvpState } from './review-rollup.mjs';
24
+ import { toCanonicalFamilyFirstId } from './spec-id-lib.mjs';
25
+
26
+ /**
27
+ * Statuses at which a dependency is fully MET — dropped from the blocker list
28
+ * entirely. Mirror of `PREREQ_SATISFIED_STATUSES`
29
+ * (`spex-console/src/lib/scheduler/blocker-category.ts`) **minus** the two
30
+ * review states, which stay listed so the agent still SEES the batched-review
31
+ * context in the banner (they are advisory, never gating — see below).
32
+ */
33
+ const PREREQ_MET_STATUSES = new Set(['closed', 'approved', 'shipped']);
34
+
35
+ /**
36
+ * Review backlog is NEVER a blocker. `in_review` and `review_pending` both mean
37
+ * "build-complete, awaiting review at an ancestor grain" — the dependent code is
38
+ * already in the tree, so they classify as `soft_review` (proceed, with a note),
39
+ * never `hard`. `review_pending` grouping with `in_review` is the canonical rule
40
+ * ([[review-pending-must-group-with-in-review]]); omitting it here let a
41
+ * build-complete dependency fall through the `|| 'hard'` default and hard-block
42
+ * its dependent — the MCP-side instance of the same trap the console fixed in
43
+ * `PREREQ_SATISFIED_STATUSES` (2026-06-22).
44
+ */
45
+ const STATUS_SEVERITY = {
46
+ closed: 'none',
47
+ approved: 'none',
48
+ shipped: 'none',
49
+ in_review: 'soft_review',
50
+ review_pending: 'soft_review',
51
+ hibernated: 'hard',
52
+ in_progress: 'hard',
53
+ open: 'hard',
54
+ draft: 'hard',
55
+ missing: 'integrity',
56
+ unknown: 'integrity',
57
+ };
58
+
59
+ const SEVERITY_RANK = { none: 0, soft_review: 1, hard: 2, integrity: 3 };
60
+
61
+ function combineSeverity(a, b) {
62
+ return SEVERITY_RANK[a] >= SEVERITY_RANK[b] ? a : b;
63
+ }
64
+
65
+ function wikilinkTarget(raw) {
66
+ if (typeof raw !== 'string') return null;
67
+ const m = raw.match(/\[\[([^\]]+)\]\]/);
68
+ return m ? m[1].trim() : raw.trim();
69
+ }
70
+
71
+ // Strip wikilink wrapper + slug tail → bare canonical id. Mirror of
72
+ // `cli.mjs:canonicalizeSpecRef` / `vault-shared.ts:canonicalizeSpecRef`.
73
+ // Needed so scheduler-cache ids (bare, e.g. `HS-EPIC-PLRV`) compare equal to
74
+ // the full-slug refs carried in `epics:` / `children:` frontmatter
75
+ // ([[scheduler-cache-id-form-parity]]).
76
+ function canonicalizeRef(value) {
77
+ if (value == null) return null;
78
+ const inner = wikilinkTarget(String(value));
79
+ if (!inner) return null;
80
+ // Family-keyed kinds: normalize EITHER ordering (type-first HS-EPIC-PLRV or
81
+ // family-first HS-PLRV-EPIC) to the canonical family-first bare id so the two
82
+ // orderings compare equal ([[HS-EPIC-DLE3]]).
83
+ const canonical = toCanonicalFamilyFirstId(inner);
84
+ if (canonical) return canonical;
85
+ // Non-family-keyed kinds (ROADMAP/IDEA/FLOW) are not reordered — just strip any
86
+ // slug tail to the bare id.
87
+ const m = inner.match(/^(HS-(?:ROADMAP|IDEA|FLOW)-(?:\d+|[A-Z0-9]+))/);
88
+ return m ? m[1] : inner;
89
+ }
90
+
91
+ /**
92
+ * Stable-sort child refs by their position in the deterministic scheduler
93
+ * order (`schedulerOrderIds`, bare ids in global rank). Refs absent from the
94
+ * order keep their declared array position at the tail. With no order supplied
95
+ * this is the identity — preserving the prior declared-order behavior + tests.
96
+ * This is what converges the claim target onto scheduler output so it agrees
97
+ * with every display surface ([[HS-EPIC-CNVG]]); without it the milestone /
98
+ * epic walk picks the first *written* child, not the first *scheduled* one.
99
+ */
100
+ function rankRefsBySchedulerOrder(refs, schedulerOrderIds) {
101
+ if (!Array.isArray(schedulerOrderIds) || schedulerOrderIds.length === 0) return refs;
102
+ const rank = new Map();
103
+ schedulerOrderIds.forEach((id, i) => {
104
+ const c = canonicalizeRef(id);
105
+ if (c && !rank.has(c)) rank.set(c, i);
106
+ });
107
+ return refs
108
+ .map((ref, i) => ({
109
+ ref,
110
+ i,
111
+ r: rank.get(canonicalizeRef(ref)) ?? Number.MAX_SAFE_INTEGER,
112
+ }))
113
+ .sort((a, b) => (a.r !== b.r ? a.r - b.r : a.i - b.i))
114
+ .map((x) => x.ref);
115
+ }
116
+
117
+ /**
118
+ * Inspect `task.frontmatter.depends_on` + `task.frontmatter.blocked_by`,
119
+ * resolve each wikilink via `loadTask`, classify by status, and return:
120
+ * { blockers: [{ id, status, source }], severity }
121
+ * - `depends_on` is the canonical field; `blocked_by` is unioned for back-compat.
122
+ * - Targets whose status is in `PREREQ_MET_STATUSES` are omitted (blocker met).
123
+ * - Final `severity` is the worst (max-rank) across all unmet blockers.
124
+ * - `'none'` means the task is unblocked.
125
+ */
126
+ function computeUnmetBlockers(loadTask, task, opts = {}) {
127
+ // `mvp_mode.review_backlog_never_blocks` — opt-OUT only, so anything but an
128
+ // explicit `false` keeps review states advisory.
129
+ const reviewBacklogNeverBlocks = opts.reviewBacklogNeverBlocks !== false;
130
+ const fm = task?.frontmatter || {};
131
+ const dependsOn = Array.isArray(fm.depends_on) ? fm.depends_on : [];
132
+ const blockedBy = Array.isArray(fm.blocked_by) ? fm.blocked_by : [];
133
+
134
+ const seen = new Set();
135
+ const targets = [];
136
+ for (const raw of dependsOn) {
137
+ const t = wikilinkTarget(raw);
138
+ if (!t || seen.has(t)) continue;
139
+ seen.add(t);
140
+ targets.push({ target: t, source: 'depends_on' });
141
+ }
142
+ for (const raw of blockedBy) {
143
+ const t = wikilinkTarget(raw);
144
+ if (!t || seen.has(t)) continue;
145
+ seen.add(t);
146
+ targets.push({ target: t, source: 'blocked_by' });
147
+ }
148
+
149
+ const blockers = [];
150
+ let severity = 'none';
151
+ for (const { target, source } of targets) {
152
+ const blocker = loadTask(target);
153
+ if (!blocker) {
154
+ blockers.push({ id: target, status: 'missing', source });
155
+ severity = combineSeverity(severity, 'integrity');
156
+ continue;
157
+ }
158
+ const status = blocker.status || blocker.frontmatter?.status || 'unknown';
159
+ if (PREREQ_MET_STATUSES.has(status)) continue;
160
+ blockers.push({ id: blocker.id || target, status, source });
161
+ let classified = STATUS_SEVERITY[status] || 'hard';
162
+ if (classified === 'soft_review' && !reviewBacklogNeverBlocks) classified = 'hard';
163
+ severity = combineSeverity(severity, classified);
164
+ }
165
+ return { blockers, severity };
166
+ }
167
+
168
+ /**
169
+ * When focus.yaml pins an epic, classify the next move by walking the epic's
170
+ * declared `children:` in manifest order. Returns a discriminated state:
171
+ *
172
+ * { state: 'resume', task, blockers, severity }
173
+ * { state: 'ready', task, blockers: [], severity: 'none' }
174
+ * { state: 'soft_review', task, blockers, severity: 'soft_review' }
175
+ * { state: 'hard_blocked', task, blockers, severity }
176
+ * { state: 'epic_implementation_complete', review_candidates, milestone_next_epics }
177
+ * { state: 'epic_done', review_candidates: [], milestone_next_epics }
178
+ * { state: 'epic_empty' }
179
+ *
180
+ * Selection order:
181
+ * 1) Resume the first `in_progress` child (blockers shown but don't gate).
182
+ * 2) Walk `open` children in manifest order and bucket by severity:
183
+ * 'none' wins; else 'soft_review'; else 'hard'/'integrity'.
184
+ * 3) If no `open` children remain, summarize the epic boundary state from
185
+ * the children's statuses + parent-milestone next-epic lookup.
186
+ */
187
+ function resolveEpicChildTask(loadTask, epicFrontmatter, opts = {}) {
188
+ const children = epicFrontmatter?.children;
189
+ if (!Array.isArray(children) || children.length === 0) {
190
+ return { state: 'epic_empty' };
191
+ }
192
+
193
+ const loaded = [];
194
+ for (const raw of children) {
195
+ const target = wikilinkTarget(raw);
196
+ if (!target) continue;
197
+ const task = loadTask(target);
198
+ if (!task) continue;
199
+ loaded.push(task);
200
+ }
201
+
202
+ if (loaded.length === 0) {
203
+ return { state: 'epic_empty' };
204
+ }
205
+
206
+ const resume = loaded.find((t) => (t.status || t.frontmatter?.status) === 'in_progress');
207
+ if (resume) {
208
+ const { blockers, severity } = computeUnmetBlockers(loadTask, resume, opts);
209
+ return { state: 'resume', task: resume, blockers, severity };
210
+ }
211
+
212
+ let firstReady = null;
213
+ let firstSoft = null;
214
+ let firstHard = null;
215
+ for (const task of loaded) {
216
+ const status = task.status || task.frontmatter?.status;
217
+ if (status !== 'open') continue;
218
+ const { blockers, severity } = computeUnmetBlockers(loadTask, task, opts);
219
+ if (severity === 'none' && firstReady === null) {
220
+ firstReady = { task, blockers, severity };
221
+ } else if (severity === 'soft_review' && firstSoft === null) {
222
+ firstSoft = { task, blockers, severity };
223
+ } else if ((severity === 'hard' || severity === 'integrity') && firstHard === null) {
224
+ firstHard = { task, blockers, severity };
225
+ }
226
+ }
227
+ if (firstReady) return { state: 'ready', ...firstReady };
228
+ if (firstSoft) return { state: 'soft_review', ...firstSoft };
229
+ if (firstHard) return { state: 'hard_blocked', ...firstHard };
230
+
231
+ // No open children — classify epic boundary. `review_pending` groups with
232
+ // `in_review` here too: below the active review grain a build-complete child
233
+ // carries `review_pending`, and it is just as much a review candidate
234
+ // ([[review-pending-must-group-with-in-review]]).
235
+ const reviewCandidates = loaded
236
+ .filter((t) => {
237
+ const s = t.status || t.frontmatter?.status;
238
+ return s === 'in_review' || s === 'review_pending';
239
+ })
240
+ .map((t) => t.id || t.frontmatter?.id)
241
+ .filter(Boolean);
242
+ const closedCount = loaded.filter(
243
+ (t) => (t.status || t.frontmatter?.status) === 'closed',
244
+ ).length;
245
+ const milestoneNext = resolveMilestoneNextEpics(loadTask, epicFrontmatter);
246
+
247
+ if (reviewCandidates.length > 0) {
248
+ return {
249
+ state: 'epic_implementation_complete',
250
+ review_candidates: reviewCandidates,
251
+ milestone_next_epics: milestoneNext,
252
+ };
253
+ }
254
+ if (closedCount === loaded.length) {
255
+ return {
256
+ state: 'epic_done',
257
+ review_candidates: [],
258
+ milestone_next_epics: milestoneNext,
259
+ };
260
+ }
261
+
262
+ // Mixed weird state — no open, no in_review, not all closed (drafts /
263
+ // hibernated only?). Treat as empty so the caller falls through to the
264
+ // existing body-excerpt behavior rather than fabricating a misleading hint.
265
+ return { state: 'epic_empty' };
266
+ }
267
+
268
+ /**
269
+ * Walk the parent milestone's `children:` list and return up to 3 next-epic
270
+ * ids whose status is `open` or `in_progress` after the current epic.
271
+ * Returns [] when no parent milestone is set, can't be loaded, has no
272
+ * children, or has no actionable successors.
273
+ */
274
+ function resolveMilestoneNextEpics(loadTask, epicFrontmatter) {
275
+ const milestoneRaw = epicFrontmatter?.milestone;
276
+ if (!milestoneRaw) return [];
277
+ const milestoneTarget = wikilinkTarget(milestoneRaw);
278
+ if (!milestoneTarget) return [];
279
+ const milestone = loadTask(milestoneTarget);
280
+ if (!milestone) return [];
281
+
282
+ const mfm = milestone.frontmatter || {};
283
+ if (!Array.isArray(mfm.children)) return [];
284
+
285
+ const currentId = epicFrontmatter.id || '';
286
+ const childIds = mfm.children.map(wikilinkTarget).filter(Boolean);
287
+ const currentIdx = childIds.findIndex(
288
+ (id) => id === currentId || id.startsWith(currentId + '-') || id.startsWith(currentId),
289
+ );
290
+ const after = currentIdx >= 0 ? childIds.slice(currentIdx + 1) : childIds;
291
+
292
+ const next = [];
293
+ for (const id of after) {
294
+ const child = loadTask(id);
295
+ if (!child) continue;
296
+ const ctype = child.type || child.frontmatter?.type;
297
+ if (ctype && ctype !== 'epic') continue;
298
+ const cstatus = child.status || child.frontmatter?.status;
299
+ if (cstatus === 'open' || cstatus === 'in_progress') {
300
+ next.push(child.id || child.frontmatter?.id || id);
301
+ if (next.length >= 3) break;
302
+ }
303
+ }
304
+ return next;
305
+ }
306
+
307
+ /**
308
+ * Map a child-state to the short `next_action_hint` exposed on the response
309
+ * payload. Hint is the machine-readable shape; the human-facing markdown
310
+ * comes from `buildBlockerBanner` / `buildEpicBoundaryPromptMarkdown`.
311
+ */
312
+ function mapStateToHint(state) {
313
+ switch (state) {
314
+ case 'resume': return 'resume';
315
+ case 'ready': return 'start';
316
+ case 'soft_review': return 'start_batched';
317
+ case 'hard_blocked': return 'hard_blocked';
318
+ case 'epic_implementation_complete': return 'advance_or_review';
319
+ case 'epic_done': return 'ship_epic';
320
+ case 'epic_empty': return 'inspect_epic';
321
+ case 'milestone_needs_breakdown': return 'file_epic';
322
+ case 'milestone_empty_after_filter': return 'file_epic';
323
+ case 'milestone_has_epics': return 'drill_milestone';
324
+ default: return 'start';
325
+ }
326
+ }
327
+
328
+ /**
329
+ * Milestone-pin analogue of `resolveEpicChildTask`. Inspects a milestone's
330
+ * declared `epics:` field (canonical per [[HS-PROTO-spec-hierarchy]] —
331
+ * milestones use `epics:`, never `children:`) and returns a discriminated
332
+ * state. See [[HS-TASK-SKEM-scheduler-skip-epicless-milestones]] for the
333
+ * `/hs-next` `needs_breakdown` consumer.
334
+ *
335
+ * { state: 'milestone_needs_breakdown' }
336
+ * → `epics:` is missing or empty. No actionable work below this pin.
337
+ * { state: 'milestone_empty_after_filter', unresolved }
338
+ * → `epics:` listed entries but none resolved via `loadTask`. Treated the
339
+ * same as needs_breakdown at the cli layer — still no actionable epic.
340
+ * { state: 'milestone_has_epics', epics: [{ id, status }] }
341
+ * → at least one entry resolved. Out-of-band for this resolver; the
342
+ * caller decides whether to drill (out of scope for SKEM).
343
+ */
344
+ function resolveMilestoneState(loadTask, milestoneFrontmatter) {
345
+ const raw = milestoneFrontmatter?.epics;
346
+ const entries = Array.isArray(raw) ? raw : [];
347
+ if (entries.length === 0) {
348
+ return { state: 'milestone_needs_breakdown' };
349
+ }
350
+
351
+ const resolved = [];
352
+ const unresolved = [];
353
+ const seen = new Set();
354
+ for (const ref of entries) {
355
+ const target = wikilinkTarget(ref);
356
+ if (!target || seen.has(target)) continue;
357
+ seen.add(target);
358
+ const epic = loadTask(target);
359
+ if (!epic) {
360
+ unresolved.push(target);
361
+ continue;
362
+ }
363
+ resolved.push({
364
+ id: epic.id || target,
365
+ status: epic.status || epic.frontmatter?.status || 'unknown',
366
+ });
367
+ }
368
+
369
+ if (resolved.length === 0) {
370
+ return { state: 'milestone_empty_after_filter', unresolved };
371
+ }
372
+ return { state: 'milestone_has_epics', epics: resolved };
373
+ }
374
+
375
+ /**
376
+ * Banner inserted into prompt_markdown when blockers are present. Tone
377
+ * differs by state: `soft_review` reassures the agent to proceed (epic-batch
378
+ * is the intended workflow); `hard_blocked` warns and names the predecessor;
379
+ * `integrity` flags a schema error.
380
+ */
381
+ function buildBlockerBanner(state, blockers) {
382
+ if (!Array.isArray(blockers) || blockers.length === 0) return '';
383
+ const list = blockers
384
+ .map(
385
+ (b) =>
386
+ `\`${b.id}\` (${b.status}${b.source === 'blocked_by' ? ', via blocked_by' : ''})`,
387
+ )
388
+ .join(', ');
389
+ if (state === 'soft_review') {
390
+ return (
391
+ `\n\n> **Sibling awaiting review (batched, expected):** ${list}. ` +
392
+ `Proceed — this is the intended workflow when implementing a whole epic before reviewing. ` +
393
+ `A review backlog is never a blocker and needs no owner permission to work past: ` +
394
+ `do NOT offer to pause, redirect the claim, or ask the owner to review/commit first. ` +
395
+ `If review later rejects a sibling and forces redesign, revisit this task then.`
396
+ );
397
+ }
398
+ if (state === 'hard_blocked') {
399
+ return (
400
+ `\n\n> ⚠️ **Hard blockers:** ${list}. ` +
401
+ `The predecessor(s) above must reach \`in_progress\` or \`closed\` before this task can land cleanly. ` +
402
+ `Claim a blocker first, or re-pin focus to a different epic.`
403
+ );
404
+ }
405
+ // resume or integrity fall-through
406
+ const integrity = blockers.find((b) => b.status === 'missing');
407
+ if (integrity) {
408
+ return (
409
+ `\n\n> ⚠️ **Integrity error:** blocker \`${integrity.id}\` does not resolve to any spec in the vault. ` +
410
+ `Fix the wikilink in this task's \`depends_on\` / \`blocked_by\` frontmatter.`
411
+ );
412
+ }
413
+ return `\n\n> Blockers: ${list}.`;
414
+ }
415
+
416
+ /**
417
+ * Build prompt_markdown for the epic-boundary states (no actionable child).
418
+ * Surfaces three options: re-pin focus to next epic in milestone, run
419
+ * `/hs-review` on the queue, or `ship_epic`. The order matches the user's
420
+ * stated preference for epic-batch workflow — advance-then-review-then-ship.
421
+ */
422
+ function buildEpicBoundaryPromptMarkdown({
423
+ state,
424
+ epicId,
425
+ epicName,
426
+ reviewCandidates = [],
427
+ milestoneNextEpics = [],
428
+ roadmapId,
429
+ focusSpecPath,
430
+ }) {
431
+ const lines = [];
432
+ const heading =
433
+ state === 'epic_implementation_complete'
434
+ ? 'epic_implementation_complete'
435
+ : state === 'epic_done'
436
+ ? 'epic_done'
437
+ : state;
438
+ lines.push(`## ${heading} — ${epicId}`);
439
+ lines.push('');
440
+ lines.push(`**Epic:** ${epicName}`);
441
+ lines.push(`**Roadmap:** ${roadmapId}`);
442
+ lines.push('');
443
+ if (state === 'epic_implementation_complete') {
444
+ lines.push(
445
+ `All ${reviewCandidates.length} child task${reviewCandidates.length === 1 ? '' : 's'} of this epic are build-complete ` +
446
+ `(\`in_review\` / \`review_pending\`). The epic's implementation work is done; no actionable child remains. ` +
447
+ `The pending reviews do **not** gate anything below — advancing is always available without owner permission. ` +
448
+ `What to do next:`,
449
+ );
450
+ } else if (state === 'epic_done') {
451
+ lines.push(`All children of this epic are \`closed\`. The epic itself can be shipped.`);
452
+ }
453
+ lines.push('');
454
+ lines.push('### Suggested next moves');
455
+ lines.push('');
456
+
457
+ let stepNum = 1;
458
+ if (milestoneNextEpics.length > 0) {
459
+ lines.push(
460
+ `${stepNum}. **Advance focus to the next epic in this milestone** ` +
461
+ `(preferred if you're batching review for milestone close-out). Re-pin \`.heyspex/focus.yaml\`:`,
462
+ );
463
+ for (const eid of milestoneNextEpics) {
464
+ lines.push(` - \`${eid}\``);
465
+ }
466
+ stepNum += 1;
467
+ } else {
468
+ lines.push(
469
+ `${stepNum}. **Re-pin \`.heyspex/focus.yaml\`** to the next epic you want to implement ` +
470
+ `(no next epic auto-detected on the parent milestone — pick one manually).`,
471
+ );
472
+ stepNum += 1;
473
+ }
474
+ lines.push('');
475
+
476
+ if (reviewCandidates.length > 0) {
477
+ lines.push(
478
+ `${stepNum}. **Start clearing the review queue** with \`/hs-review\` — candidates:`,
479
+ );
480
+ for (const id of reviewCandidates) {
481
+ lines.push(` - \`${id}\``);
482
+ }
483
+ stepNum += 1;
484
+ lines.push('');
485
+ lines.push(
486
+ `${stepNum}. **Backfill the epic** once all children close: \`heyspex_backfill_epic({ id: "${epicId}" })\`.`,
487
+ );
488
+ } else {
489
+ lines.push(
490
+ `${stepNum}. **Backfill the epic**: \`heyspex_backfill_epic({ id: "${epicId}" })\`.`,
491
+ );
492
+ }
493
+ lines.push('');
494
+ lines.push(
495
+ `_Pinned via \`.heyspex/focus.yaml\` (\`${focusSpecPath}\`). No actionable child task — ` +
496
+ `surfacing epic-boundary choices instead of a body excerpt._`,
497
+ );
498
+ return lines.join('\n');
499
+ }
500
+
501
+ // ─────────────────────────────────────────────────────────────────────────────
502
+ // computeClaimTarget — the vault invariant "a spec is claimable iff it is its
503
+ // own current ClaimTarget" (T2 of [[HS-EPIC-VQZ8]]). Both the /hs-next brief
504
+ // and the console claim endpoint consult this primitive; the endpoint blocks
505
+ // any POST whose target.spec_id !== params.id.
506
+ //
507
+ // Reads ONLY from disk via the injected `loadRef` (SSOT — never an index).
508
+ // Recursion ends at the first claimable descendant or the original node when
509
+ // it has no open descendants.
510
+ // ─────────────────────────────────────────────────────────────────────────────
511
+
512
+ const CLAIM_TERMINAL_STATUSES = new Set([
513
+ 'closed',
514
+ 'shipped',
515
+ 'in_review',
516
+ // review_pending = build-complete, awaiting batched review ([[HS-EPIC-SSTM]]).
517
+ 'review_pending',
518
+ 'archived',
519
+ 'cancelled',
520
+ 'duplicate',
521
+ 'hibernated',
522
+ 'superseded',
523
+ // [[HS-DIAG-YMN3]]: `parked` = indefinite park; not claimable. Mirror of the
524
+ // TS CLAIM_TERMINAL_STATUSES in lib/scheduler/claim-eligibility.ts.
525
+ 'parked',
526
+ ]);
527
+
528
+ const REVIEW_AWAITING_STATUSES = new Set(['in_review', 'review_pending']);
529
+
530
+ const ROLLUP_INTENT_VERB = {
531
+ reviewer_blocked: 'wait for reviewer',
532
+ ready_for_close: 'flip to in_review',
533
+ };
534
+
535
+ const ROLLUP_NEXT_ACTION_HINT = {
536
+ reviewer_blocked: 'wait_for_reviewer',
537
+ ready_for_close: 'flip_to_in_review',
538
+ };
539
+
540
+ const TERMINAL_LEAF_INTENT = {
541
+ awaiting_review: 'wait for reviewer',
542
+ parked: 'parked — not claimable',
543
+ done: 'already closed',
544
+ };
545
+
546
+ const TERMINAL_LEAF_NEXT_ACTION = {
547
+ awaiting_review: 'wait_for_reviewer',
548
+ parked: 'not_claimable',
549
+ done: 'not_claimable',
550
+ };
551
+
552
+ // [[HS-DIAG-YMN3]] mirror of TS terminalLeafKind / levelForType.
553
+ function terminalLeafKind(status) {
554
+ if (REVIEW_AWAITING_STATUSES.has(status)) return 'awaiting_review';
555
+ if (status === 'parked') return 'parked';
556
+ return 'done';
557
+ }
558
+
559
+ function levelForType(type) {
560
+ return type === 'epic' || type === 'milestone' ? type : 'task';
561
+ }
562
+
563
+ const INTENT_VERB_BY_LEVEL = {
564
+ task: 'implement it',
565
+ epic: 'break it into Tasks',
566
+ milestone: 'break it into Epics',
567
+ };
568
+
569
+ const TONE_TOKEN_BY_LEVEL = {
570
+ task: '--status-neutral',
571
+ epic: '--status-ready',
572
+ milestone: '--status-pending',
573
+ };
574
+
575
+ function specStatus(spec) {
576
+ return String(spec?.status || spec?.frontmatter?.status || '').toLowerCase();
577
+ }
578
+
579
+ function specType(spec) {
580
+ return String(spec?.type || spec?.frontmatter?.type || '').toLowerCase();
581
+ }
582
+
583
+ function specId(spec) {
584
+ return spec?.id || spec?.frontmatter?.id || null;
585
+ }
586
+
587
+ function specTitle(spec) {
588
+ const fm = spec?.frontmatter || {};
589
+ return spec?.name || fm.name || specId(spec);
590
+ }
591
+
592
+ /**
593
+ * Walk parent pointers root-ward, reading each link from disk via `loadRef`.
594
+ * Returns the chain root → leaf as `[{id, type, title}]`. For a task: includes
595
+ * the epic and milestone above it. For an epic: includes its milestone. For a
596
+ * milestone: just the milestone itself. The leaf is always `spec`.
597
+ */
598
+ export function resolveHierarchy(spec, loadRef) {
599
+ if (!spec) return [];
600
+ const chain = [];
601
+ const visited = new Set();
602
+ let current = spec;
603
+ while (current) {
604
+ const id = specId(current);
605
+ if (!id || visited.has(id)) break;
606
+ visited.add(id);
607
+ chain.unshift({ id, type: specType(current) || 'spec', title: specTitle(current) });
608
+ const fm = current.frontmatter || {};
609
+ const parentRef =
610
+ wikilinkTarget(fm.parent) ||
611
+ wikilinkTarget(fm.parent_epic) ||
612
+ wikilinkTarget(fm.milestone);
613
+ if (!parentRef || typeof loadRef !== 'function') break;
614
+ current = loadRef(parentRef);
615
+ }
616
+ return chain;
617
+ }
618
+
619
+ function buildClaimTarget(node, level, alignmentState, breadcrumb) {
620
+ return {
621
+ level,
622
+ spec_id: specId(node),
623
+ title: specTitle(node),
624
+ intent_verb: INTENT_VERB_BY_LEVEL[level] || 'work on it',
625
+ tone_token: TONE_TOKEN_BY_LEVEL[level] || '--status-neutral',
626
+ hierarchy_breadcrumb: breadcrumb || [],
627
+ alignment_state: alignmentState || 'unknown',
628
+ // The on-deck claim brief surfaces the leaf's authored summary, and falls
629
+ // back to an author-then-claim prompt (which needs the file path) when it is
630
+ // missing. Both come straight from the resolved leaf node's frontmatter/path.
631
+ summary: node?.frontmatter?.summary || null,
632
+ relPath: node?.specPath || node?.spec_path || node?.relPath || null,
633
+ };
634
+ }
635
+
636
+ function buildRollupClaimTarget(node, level, alignmentState, breadcrumb, rollup) {
637
+ const kind = rollup.kind;
638
+ return {
639
+ ...buildClaimTarget(node, level, alignmentState, breadcrumb),
640
+ intent_verb: ROLLUP_INTENT_VERB[kind] || 'work on it',
641
+ tone_token: '--status-paused',
642
+ rollup_state: kind,
643
+ next_action_hint: ROLLUP_NEXT_ACTION_HINT[kind] || null,
644
+ rollup_detail:
645
+ kind === 'reviewer_blocked'
646
+ ? { in_review: rollup.in_review ?? 0 }
647
+ : kind === 'ready_for_close'
648
+ ? { closed: rollup.closed ?? 0 }
649
+ : null,
650
+ };
651
+ }
652
+
653
+ /** Terminal leaf task/diagnose — not open for implementation ([[HS-DIAG-BVKC]]). */
654
+ function buildTerminalLeafClaimTarget(node, alignmentState, breadcrumb, level = 'task') {
655
+ const status = specStatus(node);
656
+ const kind = terminalLeafKind(status);
657
+ return {
658
+ ...buildClaimTarget(node, level, alignmentState, breadcrumb),
659
+ intent_verb: TERMINAL_LEAF_INTENT[kind],
660
+ tone_token: '--status-paused',
661
+ rollup_state: 'terminal_leaf',
662
+ next_action_hint: TERMINAL_LEAF_NEXT_ACTION[kind],
663
+ terminal_status: status,
664
+ };
665
+ }
666
+
667
+ /**
668
+ * Classify an epic's children into rollup kinds ([[HS-TASK-CRTR]]). Returns
669
+ * `{ kind: 'has_claimable', ref, child }` when a non-terminal child exists so
670
+ * the caller can recurse without re-walking.
671
+ */
672
+ export function classifyEpicChildRollup(epic, loadRef, schedulerOrderIds = [], reviewLevel = 'task') {
673
+ const fm = epic?.frontmatter || {};
674
+ const childRefs = rankRefsBySchedulerOrder(
675
+ Array.isArray(fm.children) ? fm.children.map(wikilinkTarget).filter(Boolean) : [],
676
+ schedulerOrderIds,
677
+ );
678
+ if (childRefs.length === 0) return { kind: 'no_children' };
679
+
680
+ let loadedCount = 0;
681
+ let inReviewCount = 0;
682
+ const statuses = [];
683
+ for (const ref of childRefs) {
684
+ if (typeof loadRef !== 'function') break;
685
+ const child = loadRef(ref);
686
+ if (!child) continue;
687
+ loadedCount += 1;
688
+ const status = specStatus(child);
689
+ statuses.push(status);
690
+ if (!CLAIM_TERMINAL_STATUSES.has(status)) {
691
+ return { kind: 'has_claimable', ref, child };
692
+ }
693
+ if (REVIEW_AWAITING_STATUSES.has(status)) inReviewCount += 1;
694
+ }
695
+
696
+ if (loadedCount === 0) return { kind: 'no_children' };
697
+ const rollupKind = classifyParentRollup(statuses, 'task', reviewLevel);
698
+ if (rollupKind === 'reviewer_blocked') {
699
+ return { kind: 'reviewer_blocked', in_review: inReviewCount };
700
+ }
701
+ return { kind: 'ready_for_close', closed: loadedCount };
702
+ }
703
+
704
+ /** Milestone-level rollup when every child epic is terminal ([[HS-TASK-CRTR]]). */
705
+ export function classifyMilestoneChildRollup(milestone, loadRef, schedulerOrderIds = [], reviewLevel = 'task') {
706
+ const fm = milestone?.frontmatter || {};
707
+ const epicRefs = rankRefsBySchedulerOrder(
708
+ Array.isArray(fm.epics) ? fm.epics.map(wikilinkTarget).filter(Boolean) : [],
709
+ schedulerOrderIds,
710
+ );
711
+ if (epicRefs.length === 0) return { kind: 'no_children' };
712
+
713
+ let loadedCount = 0;
714
+ let inReviewCount = 0;
715
+ const statuses = [];
716
+ for (const ref of epicRefs) {
717
+ if (typeof loadRef !== 'function') break;
718
+ const epic = loadRef(ref);
719
+ if (!epic) continue;
720
+ loadedCount += 1;
721
+ const status = specStatus(epic);
722
+ statuses.push(status);
723
+ if (REVIEW_AWAITING_STATUSES.has(status)) inReviewCount += 1;
724
+ }
725
+
726
+ if (loadedCount === 0) return { kind: 'no_children' };
727
+ const rollupKind = classifyParentRollup(statuses, 'epic', reviewLevel);
728
+ if (rollupKind === 'reviewer_blocked') {
729
+ return { kind: 'reviewer_blocked', in_review: inReviewCount };
730
+ }
731
+ return { kind: 'ready_for_close', closed: loadedCount };
732
+ }
733
+
734
+ /**
735
+ * Resolve the current claimable node under `spec`. Returns a ClaimTarget with
736
+ * the 7-field shape T2 AC #7 pins.
737
+ *
738
+ * - Task → returns itself at level: 'task'.
739
+ * - Epic with no open children → itself at level: 'epic' (break it into Tasks).
740
+ * - Epic with open children → recurses into the first non-terminal child.
741
+ * - Milestone with no open epics → itself at level: 'milestone' (break it
742
+ * into Epics).
743
+ * - Milestone with open epics → recurses into the first non-terminal epic.
744
+ *
745
+ * `breadcrumb` is the carried hierarchy chain (computed once at the entry
746
+ * point, threaded so deep recursion doesn't re-walk from each child).
747
+ */
748
+ export function computeClaimTarget(
749
+ spec,
750
+ vaultRoot,
751
+ alignmentState,
752
+ loadRef,
753
+ schedulerOrderIds = [],
754
+ rollupOpts,
755
+ ) {
756
+ if (!spec) return null;
757
+ void vaultRoot;
758
+ const resolvedRollup =
759
+ rollupOpts ?? rollupOptsFromMvpState(vaultRoot ? readMvpModeState(vaultRoot) : null);
760
+ const reviewLevel = resolvedRollup.reviewLevel ?? 'task';
761
+
762
+ const breadcrumb = resolveHierarchy(spec, loadRef).map((h) => h.id);
763
+ return resolveClaimTargetRecursive(
764
+ spec,
765
+ alignmentState,
766
+ loadRef,
767
+ breadcrumb,
768
+ new Set(),
769
+ schedulerOrderIds,
770
+ reviewLevel,
771
+ );
772
+ }
773
+
774
+ function resolveClaimTargetRecursive(
775
+ spec,
776
+ alignmentState,
777
+ loadRef,
778
+ breadcrumb,
779
+ visited,
780
+ schedulerOrderIds = [],
781
+ reviewLevel = 'task',
782
+ ) {
783
+ if (!spec) return null;
784
+ const id = specId(spec);
785
+ if (!id || visited.has(id)) return null;
786
+ visited.add(id);
787
+ const type = specType(spec) || 'task';
788
+
789
+ // Own-status terminal gate ([[HS-DIAG-YMN3]]) — mirror of the TS resolver. Runs
790
+ // before the type switch so a directly-claimed terminal node (incl. a `parked`
791
+ // epic/milestone) reports not-claimable instead of falling through. Only fires
792
+ // on the root spec — descent recurses into non-terminal children only.
793
+ if (CLAIM_TERMINAL_STATUSES.has(specStatus(spec))) {
794
+ return buildTerminalLeafClaimTarget(spec, alignmentState, breadcrumb, levelForType(type));
795
+ }
796
+
797
+ // A diagnose ([[HS-MIL-BFIX]]) is a claimable leaf like a task when open.
798
+ if (type === 'task' || type === 'diagnose') {
799
+ return buildClaimTarget(spec, 'task', alignmentState, breadcrumb);
800
+ }
801
+
802
+ if (type === 'epic') {
803
+ const rollup = classifyEpicChildRollup(spec, loadRef, schedulerOrderIds, reviewLevel);
804
+ if (rollup.kind === 'has_claimable') {
805
+ const child = rollup.child;
806
+ const childBreadcrumb = [...breadcrumb, specId(child)].filter(Boolean);
807
+ const childTarget = resolveClaimTargetRecursive(
808
+ child,
809
+ alignmentState,
810
+ loadRef,
811
+ childBreadcrumb,
812
+ visited,
813
+ schedulerOrderIds,
814
+ reviewLevel,
815
+ );
816
+ if (childTarget) return childTarget;
817
+ }
818
+ if (rollup.kind === 'no_children') {
819
+ return buildClaimTarget(spec, 'epic', alignmentState, breadcrumb);
820
+ }
821
+ if (rollup.kind === 'reviewer_blocked' || rollup.kind === 'ready_for_close') {
822
+ return buildRollupClaimTarget(spec, 'epic', alignmentState, breadcrumb, rollup);
823
+ }
824
+ return buildClaimTarget(spec, 'epic', alignmentState, breadcrumb);
825
+ }
826
+
827
+ if (type === 'milestone') {
828
+ const fm = spec.frontmatter || {};
829
+ const epicRefs = rankRefsBySchedulerOrder(
830
+ Array.isArray(fm.epics) ? fm.epics.map(wikilinkTarget).filter(Boolean) : [],
831
+ schedulerOrderIds,
832
+ );
833
+ for (const ref of epicRefs) {
834
+ if (typeof loadRef !== 'function') break;
835
+ const epic = loadRef(ref);
836
+ if (!epic) continue;
837
+ if (CLAIM_TERMINAL_STATUSES.has(specStatus(epic))) continue;
838
+ const childBreadcrumb = [...breadcrumb, specId(epic)].filter(Boolean);
839
+ const childTarget = resolveClaimTargetRecursive(
840
+ epic,
841
+ alignmentState,
842
+ loadRef,
843
+ childBreadcrumb,
844
+ visited,
845
+ schedulerOrderIds,
846
+ reviewLevel,
847
+ );
848
+ if (childTarget) return childTarget;
849
+ }
850
+ const rollup = classifyMilestoneChildRollup(spec, loadRef, schedulerOrderIds, reviewLevel);
851
+ if (rollup.kind === 'no_children') {
852
+ return buildClaimTarget(spec, 'milestone', alignmentState, breadcrumb);
853
+ }
854
+ if (rollup.kind === 'reviewer_blocked' || rollup.kind === 'ready_for_close') {
855
+ return buildRollupClaimTarget(spec, 'milestone', alignmentState, breadcrumb, rollup);
856
+ }
857
+ return buildClaimTarget(spec, 'milestone', alignmentState, breadcrumb);
858
+ }
859
+
860
+ // Unknown type → return at the spec's own level with a neutral verb.
861
+ return {
862
+ level: type || 'spec',
863
+ spec_id: id,
864
+ title: specTitle(spec),
865
+ intent_verb: 'work on it',
866
+ tone_token: '--status-neutral',
867
+ hierarchy_breadcrumb: breadcrumb,
868
+ alignment_state: alignmentState || 'unknown',
869
+ summary: spec?.frontmatter?.summary || null,
870
+ relPath: spec?.specPath || spec?.spec_path || spec?.relPath || null,
871
+ };
872
+ }
873
+
874
+ export {
875
+ STATUS_SEVERITY,
876
+ SEVERITY_RANK,
877
+ combineSeverity,
878
+ wikilinkTarget,
879
+ computeUnmetBlockers,
880
+ resolveEpicChildTask,
881
+ resolveMilestoneNextEpics,
882
+ resolveMilestoneState,
883
+ mapStateToHint,
884
+ buildBlockerBanner,
885
+ buildEpicBoundaryPromptMarkdown,
886
+ CLAIM_TERMINAL_STATUSES,
887
+ INTENT_VERB_BY_LEVEL,
888
+ TONE_TOKEN_BY_LEVEL,
889
+ ROLLUP_INTENT_VERB,
890
+ ROLLUP_NEXT_ACTION_HINT,
891
+ };