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