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,813 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic scheduler — JS port of spex-console/src/lib/scheduler for the
|
|
3
|
+
* vendor stdio MCP server, which cannot import TypeScript at runtime.
|
|
4
|
+
*
|
|
5
|
+
* Mirrors stages 1 / 2 / 3 / 4 / 5 / 6 / 7 / 8 / 9 of
|
|
6
|
+
* [[HS-EPIC-Y9OJ-deterministic-priority-scheduler]] (stage 9 = the opt-in
|
|
7
|
+
* hysteresis band, [[HS-TASK-MI65]]) so `heyspex_next_on_deck` can surface an
|
|
8
|
+
* `explanation` trace alongside the picked spec. Keep behavior byte-equivalent
|
|
9
|
+
* to the TypeScript module — if you change one, change the other (and the
|
|
10
|
+
* matching test suite).
|
|
11
|
+
*
|
|
12
|
+
* Phase 3 ([[HS-TASK-XA40]]) adds Stage 6 (testing-batch grouping) plus
|
|
13
|
+
* `applyPin` for the pin-with-fuse override.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const TIER_RANK = Object.freeze({
|
|
17
|
+
infra: 0,
|
|
18
|
+
foundation: 1,
|
|
19
|
+
feature: 2,
|
|
20
|
+
polish: 3,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const DEFAULT_TIER = 'feature';
|
|
24
|
+
const DEFAULT_EFFORT = 3;
|
|
25
|
+
|
|
26
|
+
// Stage 2.7 — blast-radius band ([[encoding-foundational-and-blast-radius-p]]).
|
|
27
|
+
// Discrete breakage-surface severity; lower rank sorts first. Keep byte-equivalent
|
|
28
|
+
// to spex-console/src/lib/scheduler/types.ts (BLAST_RADIUS_RANK / DEFAULT_BLAST_RADIUS).
|
|
29
|
+
const BLAST_RADIUS_RANK = Object.freeze({
|
|
30
|
+
'vault-wide': 0,
|
|
31
|
+
'cross-surface': 1,
|
|
32
|
+
local: 2,
|
|
33
|
+
});
|
|
34
|
+
const DEFAULT_BLAST_RADIUS = 'local';
|
|
35
|
+
|
|
36
|
+
/** Milliseconds in one day — the quantum the aging guard measures wait-time in. */
|
|
37
|
+
const MS_PER_DAY = 86_400_000;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Stage 4.5 — aging guard threshold ([[HS-TASK-62Y8]] / [[HS-EPIC-RBST]] AC#3).
|
|
41
|
+
* A spec whose `created` is at least this many days older than the supplied
|
|
42
|
+
* reference clock is "aged" and promoted ahead of non-aged peers within its
|
|
43
|
+
* partition — never across a tier/topological boundary. Code constant under
|
|
44
|
+
* ADR-level change control, NOT a settings knob. Keep in lockstep with
|
|
45
|
+
* spex-console/src/lib/scheduler/types.ts.
|
|
46
|
+
*/
|
|
47
|
+
const AGING_THRESHOLD_DAYS = 45;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Stage 9 — hysteresis band ([[HS-TASK-MI65]] / [[HS-EPIC-RBST]] AC#4).
|
|
51
|
+
* A spec keeps its prior slot unless its freshly computed position moved by at
|
|
52
|
+
* least this many places; sub-threshold jitter is suppressed so a one-field
|
|
53
|
+
* edit no longer reshuffles the deck. Applied within a topological layer only
|
|
54
|
+
* (any permutation inside a layer is depends_on-safe). Inert when no priorOrder
|
|
55
|
+
* is supplied (the default), so a priorless run is byte-identical and stays in
|
|
56
|
+
* lockstep with console. Code constant under ADR-level change control, NOT a
|
|
57
|
+
* settings knob. Keep in lockstep with spex-console/src/lib/scheduler/types.ts.
|
|
58
|
+
*/
|
|
59
|
+
const HYSTERESIS_DELTA = 2;
|
|
60
|
+
|
|
61
|
+
export class CycleError extends Error {
|
|
62
|
+
constructor(cycle) {
|
|
63
|
+
super(`Cycle detected in depends_on graph: ${cycle.join(' → ')}`);
|
|
64
|
+
this.name = 'CycleError';
|
|
65
|
+
this.cycle = cycle;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Duplicate spec ids in the scheduler input — mirror of console
|
|
71
|
+
* toposort.ts:DuplicateIdError. Without the guard in topologicalSort, duplicate
|
|
72
|
+
* ids collapse the in-degree map and surface as a misleading CycleError; the
|
|
73
|
+
* guard catches them with a precise message instead. Keep byte-equivalent to
|
|
74
|
+
* the console class.
|
|
75
|
+
*/
|
|
76
|
+
export class DuplicateIdError extends Error {
|
|
77
|
+
constructor(ids) {
|
|
78
|
+
super(`Duplicate spec ids in scheduler input: ${ids.join(', ')}`);
|
|
79
|
+
this.name = 'DuplicateIdError';
|
|
80
|
+
this.ids = ids;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function normalizeDependsOnId(raw) {
|
|
85
|
+
if (raw == null) return '';
|
|
86
|
+
const inner = String(raw).trim().replace(/^\[\[|\]\]$/g, '').trim();
|
|
87
|
+
const m = inner.match(/^(HS-(?:TASK|EPIC|RES|CAP|REV|IDEA|FLOW)-(?:\d+|[A-Z0-9]+))/);
|
|
88
|
+
return m ? m[1] : inner;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The unique per-spec node key ([[HS-K5GQ-DIAG]]). Sibling tasks under one epic
|
|
93
|
+
* legitimately SHARE a family-key `id:`; keying node identity on `nodeKey` (the
|
|
94
|
+
* basename) keeps `topologicalSort` from throwing DuplicateIdError on them.
|
|
95
|
+
* Defaults to `id` so a corpus with no shared family keys is byte-identical.
|
|
96
|
+
* Keep in lockstep with console toposort.ts:nodeKeyOf.
|
|
97
|
+
*/
|
|
98
|
+
export function nodeKeyOf(spec) {
|
|
99
|
+
return spec?.nodeKey ?? spec?.id;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Resolve a raw `depends_on` value to the UNIQUE node key it targets within
|
|
104
|
+
* `specs`, or null when it resolves to no node (absent, or a bare family key
|
|
105
|
+
* shared by 2+ siblings — ambiguous, so it gates nothing). Edges resolve on the
|
|
106
|
+
* family-key `id:` even though nodes are keyed on nodeKey. Keep in lockstep with
|
|
107
|
+
* console toposort.ts:buildDepResolver.
|
|
108
|
+
*/
|
|
109
|
+
export function buildDepResolver(specs) {
|
|
110
|
+
const nodeKeys = new Set();
|
|
111
|
+
const nodeKeysByFamily = new Map();
|
|
112
|
+
for (const s of specs) {
|
|
113
|
+
const key = nodeKeyOf(s);
|
|
114
|
+
nodeKeys.add(key);
|
|
115
|
+
const arr = nodeKeysByFamily.get(s.id);
|
|
116
|
+
if (arr) arr.push(key);
|
|
117
|
+
else nodeKeysByFamily.set(s.id, [key]);
|
|
118
|
+
}
|
|
119
|
+
return (raw) => {
|
|
120
|
+
const dep = normalizeDependsOnId(raw);
|
|
121
|
+
if (nodeKeys.has(dep)) return dep;
|
|
122
|
+
const fam = nodeKeysByFamily.get(dep);
|
|
123
|
+
return fam && fam.length === 1 ? fam[0] : null;
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function tierOf(spec) {
|
|
128
|
+
const k = spec?.kind;
|
|
129
|
+
if (k === 'infra' || k === 'foundation' || k === 'feature' || k === 'polish') {
|
|
130
|
+
return k;
|
|
131
|
+
}
|
|
132
|
+
return DEFAULT_TIER;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function tierRankOf(spec) {
|
|
136
|
+
return TIER_RANK[tierOf(spec)];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Stage 2.7 — blast-radius band ([[encoding-foundational-and-blast-radius-p]]).
|
|
141
|
+
* Breakage-surface severity; missing/unrecognized → DEFAULT_BLAST_RADIUS
|
|
142
|
+
* ("local"). Within-tier only, above what_matters. Byte-equivalent to
|
|
143
|
+
* comparators.ts (blastRadiusOf / blastRadiusRank).
|
|
144
|
+
*/
|
|
145
|
+
export function blastRadiusOf(spec) {
|
|
146
|
+
const b = spec?.blast_radius;
|
|
147
|
+
if (b === 'vault-wide' || b === 'cross-surface' || b === 'local') return b;
|
|
148
|
+
return DEFAULT_BLAST_RADIUS;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function blastRadiusRank(spec) {
|
|
152
|
+
return BLAST_RADIUS_RANK[blastRadiusOf(spec)];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Stage 2.5 — what_matters within-tier value band ([[HS-TASK-PQID]],
|
|
157
|
+
* [[scheduler-and-whats-next-what-matters-column-mechanics]]). User's coarse
|
|
158
|
+
* value/cost-of-delay signal: heart (2) > star (1) > unflagged (0). Higher band
|
|
159
|
+
* sorts first (DESC). Within-tier only (the tier comparator returns first for
|
|
160
|
+
* cross-tier pairs); within a tier it outranks criticality, in-progress bias,
|
|
161
|
+
* and effort ("heart = schedule ASAP"). Byte-equivalent to comparators.ts.
|
|
162
|
+
*/
|
|
163
|
+
export function whatMattersRank(spec) {
|
|
164
|
+
return spec?.what_matters === 'heart' ? 2 : spec?.what_matters === 'star' ? 1 : 0;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function createdAt(spec) {
|
|
168
|
+
const raw = (spec?.created ?? '9999-12-31').toString();
|
|
169
|
+
return raw.slice(0, 19);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function effortOf(spec) {
|
|
173
|
+
return typeof spec?.effort === 'number' && Number.isFinite(spec.effort)
|
|
174
|
+
? spec.effort
|
|
175
|
+
: DEFAULT_EFFORT;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function inProgressRank(spec) {
|
|
179
|
+
return spec?.status === 'in_progress' ? 0 : 1;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Stage 4.5 — aging guard (anti-starvation). Whole days the spec has waited
|
|
184
|
+
* since `created`, measured against the caller-supplied reference clock
|
|
185
|
+
* `nowMs`. The wall clock is not read here — `nowMs` is an explicit input,
|
|
186
|
+
* quantized to whole days. Undefined `nowMs` ⇒ inert (0), so the order stays a
|
|
187
|
+
* pure function of frontmatter. Missing/unparseable `created` ⇒ 0 (treated as
|
|
188
|
+
* brand-new, never infinitely aged). Byte-equivalent to comparators.ts.
|
|
189
|
+
*/
|
|
190
|
+
export function agingDays(spec, nowMs) {
|
|
191
|
+
if (typeof nowMs !== 'number' || !Number.isFinite(nowMs)) return 0;
|
|
192
|
+
const createdMs = Date.parse(createdAt(spec));
|
|
193
|
+
if (Number.isNaN(createdMs)) return 0;
|
|
194
|
+
const days = Math.floor((nowMs - createdMs) / MS_PER_DAY);
|
|
195
|
+
return days > 0 ? days : 0;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function isAged(spec, nowMs) {
|
|
199
|
+
return agingDays(spec, nowMs) >= AGING_THRESHOLD_DAYS;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** 0 = aged (promoted), 1 = not aged. Inert (always 1) when nowMs is undefined. */
|
|
203
|
+
export function agingRank(spec, nowMs) {
|
|
204
|
+
return isAged(spec, nowMs) ? 0 : 1;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function stableHash(id) {
|
|
208
|
+
let h = 0x811c9dc5;
|
|
209
|
+
for (let i = 0; i < id.length; i++) {
|
|
210
|
+
h ^= id.charCodeAt(i);
|
|
211
|
+
h = Math.imul(h, 0x01000193);
|
|
212
|
+
}
|
|
213
|
+
return (h >>> 0).toString(16).padStart(8, '0');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function topologicalSort(specs) {
|
|
217
|
+
// Duplicate node keys collapse the in-degree map below and would otherwise
|
|
218
|
+
// surface as a misleading CycleError — catch them here with a precise message.
|
|
219
|
+
// Keyed on `nodeKey` (not the shared family-key `id:`) so legit sibling tasks
|
|
220
|
+
// no longer false-trip it ([[HS-K5GQ-DIAG]]). Byte-equivalent to console
|
|
221
|
+
// toposort.ts.
|
|
222
|
+
const seenIds = new Set();
|
|
223
|
+
const dupIds = new Set();
|
|
224
|
+
for (const s of specs) {
|
|
225
|
+
const key = nodeKeyOf(s);
|
|
226
|
+
if (seenIds.has(key)) dupIds.add(key);
|
|
227
|
+
seenIds.add(key);
|
|
228
|
+
}
|
|
229
|
+
if (dupIds.size > 0) throw new DuplicateIdError([...dupIds].sort());
|
|
230
|
+
|
|
231
|
+
const idSet = seenIds;
|
|
232
|
+
const resolveDep = buildDepResolver(specs);
|
|
233
|
+
const inDegree = new Map();
|
|
234
|
+
const adj = new Map();
|
|
235
|
+
for (const s of specs) {
|
|
236
|
+
const key = nodeKeyOf(s);
|
|
237
|
+
inDegree.set(key, 0);
|
|
238
|
+
adj.set(key, []);
|
|
239
|
+
}
|
|
240
|
+
for (const s of specs) {
|
|
241
|
+
const sKey = nodeKeyOf(s);
|
|
242
|
+
for (const raw of s.depends_on ?? []) {
|
|
243
|
+
const dep = resolveDep(raw);
|
|
244
|
+
if (dep === null || dep === sKey) continue;
|
|
245
|
+
adj.get(dep).push(sKey);
|
|
246
|
+
inDegree.set(sKey, (inDegree.get(sKey) ?? 0) + 1);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const inputIndex = new Map(specs.map((s, i) => [nodeKeyOf(s), i]));
|
|
251
|
+
const layer = new Map();
|
|
252
|
+
let queue = [];
|
|
253
|
+
for (const [id, d] of inDegree) {
|
|
254
|
+
if (d === 0) {
|
|
255
|
+
queue.push(id);
|
|
256
|
+
layer.set(id, 0);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
queue.sort((a, b) => inputIndex.get(a) - inputIndex.get(b));
|
|
260
|
+
|
|
261
|
+
const ordered = [];
|
|
262
|
+
let processed = 0;
|
|
263
|
+
while (queue.length) {
|
|
264
|
+
const next = [];
|
|
265
|
+
for (const id of queue) {
|
|
266
|
+
ordered.push({ id, layer: layer.get(id) });
|
|
267
|
+
processed++;
|
|
268
|
+
for (const successor of adj.get(id) ?? []) {
|
|
269
|
+
const remaining = (inDegree.get(successor) ?? 0) - 1;
|
|
270
|
+
inDegree.set(successor, remaining);
|
|
271
|
+
if (remaining === 0) {
|
|
272
|
+
next.push(successor);
|
|
273
|
+
layer.set(successor, layer.get(id) + 1);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
next.sort((a, b) => inputIndex.get(a) - inputIndex.get(b));
|
|
278
|
+
queue = next;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (processed < specs.length) {
|
|
282
|
+
throw new CycleError(findCycle(specs, idSet));
|
|
283
|
+
}
|
|
284
|
+
return ordered;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function findCycle(specs, idSet) {
|
|
288
|
+
const resolveDep = buildDepResolver(specs);
|
|
289
|
+
const out = new Map();
|
|
290
|
+
for (const s of specs) {
|
|
291
|
+
const sKey = nodeKeyOf(s);
|
|
292
|
+
out.set(
|
|
293
|
+
sKey,
|
|
294
|
+
(s.depends_on ?? [])
|
|
295
|
+
.map(resolveDep)
|
|
296
|
+
.filter((d) => d !== null && d !== sKey && idSet.has(d)),
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
const color = new Map();
|
|
300
|
+
const parent = new Map();
|
|
301
|
+
function dfs(start) {
|
|
302
|
+
const stack = [{ id: start, depIndex: 0 }];
|
|
303
|
+
color.set(start, 1);
|
|
304
|
+
while (stack.length) {
|
|
305
|
+
const frame = stack[stack.length - 1];
|
|
306
|
+
const deps = out.get(frame.id) ?? [];
|
|
307
|
+
if (frame.depIndex >= deps.length) {
|
|
308
|
+
color.set(frame.id, 2);
|
|
309
|
+
stack.pop();
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
const dep = deps[frame.depIndex++];
|
|
313
|
+
const c = color.get(dep) ?? 0;
|
|
314
|
+
if (c === 1) {
|
|
315
|
+
const cycle = [dep];
|
|
316
|
+
let cur = frame.id;
|
|
317
|
+
while (cur !== dep) {
|
|
318
|
+
cycle.push(cur);
|
|
319
|
+
cur = parent.get(cur);
|
|
320
|
+
}
|
|
321
|
+
cycle.push(dep);
|
|
322
|
+
return cycle.reverse();
|
|
323
|
+
}
|
|
324
|
+
if (c === 0) {
|
|
325
|
+
color.set(dep, 1);
|
|
326
|
+
parent.set(dep, frame.id);
|
|
327
|
+
stack.push({ id: dep, depIndex: 0 });
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
for (const s of specs) {
|
|
333
|
+
const sKey = nodeKeyOf(s);
|
|
334
|
+
if ((color.get(sKey) ?? 0) === 0) {
|
|
335
|
+
const found = dfs(sKey);
|
|
336
|
+
if (found) return found;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return ['<cycle detected but path not recoverable>'];
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Detect `depends_on` edges that point at a spec absent from the whole vault
|
|
344
|
+
* ([[HS-TASK-Y4EY]] / [[HS-EPIC-RBST]] AC#5). Mirror of console
|
|
345
|
+
* toposort.ts:detectDanglingEdges — keep byte-equivalent.
|
|
346
|
+
*
|
|
347
|
+
* `topologicalSort` tolerates such edges (it skips them so the order still
|
|
348
|
+
* computes), so a deleted/mistyped target is silently ordered around. This
|
|
349
|
+
* surfaces them as a list. Classification is against `knownVaultIds` — the
|
|
350
|
+
* FULL vault id set, not the scheduler input set — so a legitimate
|
|
351
|
+
* cross-cohort dependency (present in the vault, absent from this input) is
|
|
352
|
+
* NOT flagged; only a target absent from the entire vault is. Self-edges are
|
|
353
|
+
* skipped. Returns `[{ specId, target }]`. Pure, deterministic.
|
|
354
|
+
*/
|
|
355
|
+
export function detectDanglingEdges(specs, knownVaultIds) {
|
|
356
|
+
const dangling = [];
|
|
357
|
+
for (const s of specs) {
|
|
358
|
+
for (const raw of s.depends_on ?? []) {
|
|
359
|
+
const dep = normalizeDependsOnId(raw);
|
|
360
|
+
if (dep === s.id) continue;
|
|
361
|
+
if (knownVaultIds.has(dep)) continue;
|
|
362
|
+
dangling.push({ specId: s.id, target: dep });
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return dangling;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Stage 3 — critical-path length per spec (longest downstream chain). See
|
|
370
|
+
* spex-console/src/lib/scheduler/criticality.ts for the matching TypeScript version;
|
|
371
|
+
* this is byte-equivalent. Path length, not descendant count, so width-only
|
|
372
|
+
* fan-out can't inflate rank (HS-EPIC-RBST AC#2; for a pure chain the two
|
|
373
|
+
* measures coincide).
|
|
374
|
+
*/
|
|
375
|
+
export function computeCriticality(specs) {
|
|
376
|
+
// Keyed on the unique `nodeKey` (not the shared family-key `id:`) so sibling
|
|
377
|
+
// tasks each get their own critical-path length ([[HS-K5GQ-DIAG]]).
|
|
378
|
+
const resolveDep = buildDepResolver(specs);
|
|
379
|
+
const directDependents = new Map();
|
|
380
|
+
for (const s of specs) directDependents.set(nodeKeyOf(s), []);
|
|
381
|
+
for (const s of specs) {
|
|
382
|
+
const sKey = nodeKeyOf(s);
|
|
383
|
+
for (const raw of s.depends_on ?? []) {
|
|
384
|
+
const dep = resolveDep(raw);
|
|
385
|
+
if (dep === null || dep === sKey) continue;
|
|
386
|
+
directDependents.get(dep).push(sKey);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
const layered = topologicalSort(specs);
|
|
390
|
+
// Reverse topo order so each dependent's value is computed before we max over it.
|
|
391
|
+
const out = new Map();
|
|
392
|
+
for (let i = layered.length - 1; i >= 0; i--) {
|
|
393
|
+
const y = layered[i].id;
|
|
394
|
+
let longest = 0;
|
|
395
|
+
for (const x of directDependents.get(y) ?? []) {
|
|
396
|
+
const through = 1 + (out.get(x) ?? 0);
|
|
397
|
+
if (through > longest) longest = through;
|
|
398
|
+
}
|
|
399
|
+
out.set(y, longest);
|
|
400
|
+
}
|
|
401
|
+
return out;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export function buildExplanation(
|
|
405
|
+
spec,
|
|
406
|
+
layer,
|
|
407
|
+
criticality = 0,
|
|
408
|
+
wasClusteredByTesting = false,
|
|
409
|
+
agedDays = 0,
|
|
410
|
+
) {
|
|
411
|
+
const tier = tierOf(spec);
|
|
412
|
+
const rank = tierRankOf(spec);
|
|
413
|
+
const blast = blastRadiusOf(spec);
|
|
414
|
+
const created = createdAt(spec);
|
|
415
|
+
const hash = stableHash(spec.id);
|
|
416
|
+
const status = spec?.status ?? 'open';
|
|
417
|
+
const effort = effortOf(spec);
|
|
418
|
+
// Stage 6.7 — declared parent-order ([[HS-67F4-DIAG]]). `undefined` unless the
|
|
419
|
+
// cohort boundary stamped a parent-declared position, so `JSON.stringify`
|
|
420
|
+
// omits both fields and unstamped traces stay byte-identical (console parity).
|
|
421
|
+
const declaredOrderIndex =
|
|
422
|
+
typeof spec?.declaredOrderIndex === 'number' ? spec.declaredOrderIndex : undefined;
|
|
423
|
+
const parentKey = spec?.parent ?? spec?.milestone ?? undefined;
|
|
424
|
+
|
|
425
|
+
const reasons = [];
|
|
426
|
+
if (status === 'in_progress') reasons.push('in_progress');
|
|
427
|
+
reasons.push(`${tier} tier`);
|
|
428
|
+
reasons.push(`topological layer ${layer}`);
|
|
429
|
+
// Stage 2.7 — blast-radius band ([[encoding-foundational-and-blast-radius-p]]).
|
|
430
|
+
// Cited after tier and before what_matters; omitted for the `local` default.
|
|
431
|
+
// Mirror explanation.ts.
|
|
432
|
+
if (blast !== 'local') {
|
|
433
|
+
reasons.push(`${blast} blast radius`);
|
|
434
|
+
}
|
|
435
|
+
// Stage 2.5 — what_matters within-tier value band ([[HS-TASK-PQID]]). Cited
|
|
436
|
+
// after tier and before criticality; omitted when unset. Mirror explanation.ts.
|
|
437
|
+
if (spec?.what_matters === 'heart') {
|
|
438
|
+
reasons.push('user marked very important (heart)');
|
|
439
|
+
} else if (spec?.what_matters === 'star') {
|
|
440
|
+
reasons.push('user marked priority (star)');
|
|
441
|
+
}
|
|
442
|
+
if (criticality > 0) {
|
|
443
|
+
reasons.push(`longest downstream chain: ${criticality}`);
|
|
444
|
+
}
|
|
445
|
+
// Stage 4.5 — aging guard cite (precedes effort; an aged spec leads non-aged
|
|
446
|
+
// peers in its bucket regardless of effort).
|
|
447
|
+
if (agedDays >= AGING_THRESHOLD_DAYS) {
|
|
448
|
+
reasons.push(`promoted by aging guard (waited ${agedDays}d)`);
|
|
449
|
+
}
|
|
450
|
+
reasons.push(`${effort}-pt effort`);
|
|
451
|
+
if (wasClusteredByTesting) {
|
|
452
|
+
reasons.push('grouped with adjacent testing-flagged specs');
|
|
453
|
+
}
|
|
454
|
+
// Stage 6.7 — declared parent-order cite ([[HS-67F4-DIAG]]). Precedes created
|
|
455
|
+
// because the owner's authored sequence outranks incidental creation time;
|
|
456
|
+
// omitted when the spec has no parent-declared position. Mirror explanation.ts.
|
|
457
|
+
if (declaredOrderIndex !== undefined) {
|
|
458
|
+
reasons.push(`declared parent-order #${declaredOrderIndex + 1}`);
|
|
459
|
+
}
|
|
460
|
+
reasons.push(`created ${created.slice(0, 10)}`);
|
|
461
|
+
reasons.push(`id-hash=${hash}`);
|
|
462
|
+
|
|
463
|
+
return {
|
|
464
|
+
topologicalLayer: layer,
|
|
465
|
+
tier,
|
|
466
|
+
tierRank: rank,
|
|
467
|
+
blastRadius: blast,
|
|
468
|
+
criticality,
|
|
469
|
+
status,
|
|
470
|
+
agedDays,
|
|
471
|
+
effort,
|
|
472
|
+
createdAt: created,
|
|
473
|
+
idHash: hash,
|
|
474
|
+
declaredOrderIndex,
|
|
475
|
+
parentKey,
|
|
476
|
+
reasons,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Stage 6 bucket key — composite of every higher-stage signal (the stage-2.5
|
|
482
|
+
* what_matters band and the aging guard included). Two specs with the same key
|
|
483
|
+
* are swappable without violating stages 1–5; including what_matters keeps the
|
|
484
|
+
* cluster pass from reordering a heart spec past an unflagged peer.
|
|
485
|
+
*/
|
|
486
|
+
function stage6BucketKey(spec, layer, criticality, nowMs) {
|
|
487
|
+
return [
|
|
488
|
+
layer,
|
|
489
|
+
tierRankOf(spec),
|
|
490
|
+
blastRadiusRank(spec),
|
|
491
|
+
whatMattersRank(spec),
|
|
492
|
+
criticality,
|
|
493
|
+
inProgressRank(spec),
|
|
494
|
+
agingRank(spec, nowMs),
|
|
495
|
+
effortOf(spec),
|
|
496
|
+
].join('|');
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function applyTestingBatch(ordered, byId, criticality, nowMs) {
|
|
500
|
+
const out = [...ordered];
|
|
501
|
+
const movedTestingSpecs = new Set();
|
|
502
|
+
// Snapshot original indexes so the partition can cite which flagged specs
|
|
503
|
+
// actually moved (HS-TASK-Q4EZ — `reasons[]` must reflect the stage 6 swap).
|
|
504
|
+
const originalIndex = new Map();
|
|
505
|
+
for (let k = 0; k < ordered.length; k++) originalIndex.set(ordered[k].id, k);
|
|
506
|
+
|
|
507
|
+
let i = 0;
|
|
508
|
+
while (i < out.length) {
|
|
509
|
+
const seed = byId.get(out[i].id);
|
|
510
|
+
const key = stage6BucketKey(
|
|
511
|
+
seed,
|
|
512
|
+
out[i].layer,
|
|
513
|
+
criticality.get(out[i].id) ?? 0,
|
|
514
|
+
nowMs,
|
|
515
|
+
);
|
|
516
|
+
let j = i + 1;
|
|
517
|
+
while (j < out.length) {
|
|
518
|
+
const next = byId.get(out[j].id);
|
|
519
|
+
const nextKey = stage6BucketKey(
|
|
520
|
+
next,
|
|
521
|
+
out[j].layer,
|
|
522
|
+
criticality.get(out[j].id) ?? 0,
|
|
523
|
+
nowMs,
|
|
524
|
+
);
|
|
525
|
+
if (nextKey !== key) break;
|
|
526
|
+
j++;
|
|
527
|
+
}
|
|
528
|
+
if (j - i > 1) {
|
|
529
|
+
const testing = [];
|
|
530
|
+
const other = [];
|
|
531
|
+
for (let k = i; k < j; k++) {
|
|
532
|
+
if (byId.get(out[k].id)?.requires_user_testing === true) {
|
|
533
|
+
testing.push(out[k]);
|
|
534
|
+
} else {
|
|
535
|
+
other.push(out[k]);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
if (testing.length > 0 && other.length > 0) {
|
|
539
|
+
const merged = [...testing, ...other];
|
|
540
|
+
for (let k = 0; k < merged.length; k++) {
|
|
541
|
+
out[i + k] = merged[k];
|
|
542
|
+
const id = merged[k].id;
|
|
543
|
+
if (
|
|
544
|
+
byId.get(id)?.requires_user_testing === true &&
|
|
545
|
+
originalIndex.get(id) !== i + k
|
|
546
|
+
) {
|
|
547
|
+
movedTestingSpecs.add(id);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
i = j;
|
|
553
|
+
}
|
|
554
|
+
return { ordered: out, movedTestingSpecs };
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Stage 9 — hysteresis band. Mirror of console pipeline.ts:applyHysteresis.
|
|
559
|
+
* Reorders within topological layers only (layer boundaries are hard, so
|
|
560
|
+
* nothing crosses a layer), keying each spec by (effectivePos, freshIndex, id).
|
|
561
|
+
* effectivePos sticks to the prior index when the move is sub-threshold,
|
|
562
|
+
* otherwise adopts the fresh index. Inert when priorOrder is empty/undefined.
|
|
563
|
+
*/
|
|
564
|
+
function applyHysteresis(ordered, priorOrder) {
|
|
565
|
+
if (!priorOrder || priorOrder.length === 0) return ordered;
|
|
566
|
+
|
|
567
|
+
const priorIndex = new Map();
|
|
568
|
+
priorOrder.forEach((id, i) => {
|
|
569
|
+
if (!priorIndex.has(id)) priorIndex.set(id, i);
|
|
570
|
+
});
|
|
571
|
+
const freshIndex = new Map();
|
|
572
|
+
ordered.forEach((o, i) => freshIndex.set(o.id, i));
|
|
573
|
+
|
|
574
|
+
const effectivePosOf = (id) => {
|
|
575
|
+
const fi = freshIndex.get(id);
|
|
576
|
+
const pi = priorIndex.get(id);
|
|
577
|
+
if (pi === undefined) return fi;
|
|
578
|
+
return Math.abs(fi - pi) < HYSTERESIS_DELTA ? pi : fi;
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
const out = [...ordered];
|
|
582
|
+
let i = 0;
|
|
583
|
+
while (i < out.length) {
|
|
584
|
+
let j = i + 1;
|
|
585
|
+
while (j < out.length && out[j].layer === out[i].layer) j++;
|
|
586
|
+
if (j - i > 1) {
|
|
587
|
+
const run = out.slice(i, j);
|
|
588
|
+
run.sort((a, b) => {
|
|
589
|
+
const ea = effectivePosOf(a.id);
|
|
590
|
+
const eb = effectivePosOf(b.id);
|
|
591
|
+
if (ea !== eb) return ea - eb;
|
|
592
|
+
const fa = freshIndex.get(a.id);
|
|
593
|
+
const fb = freshIndex.get(b.id);
|
|
594
|
+
if (fa !== fb) return fa - fb;
|
|
595
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
596
|
+
});
|
|
597
|
+
for (let k = 0; k < run.length; k++) out[i + k] = run[k];
|
|
598
|
+
}
|
|
599
|
+
i = j;
|
|
600
|
+
}
|
|
601
|
+
return out;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
export function computeOrder(specs, opts = {}) {
|
|
605
|
+
const { nowMs, priorOrder } = opts;
|
|
606
|
+
// Layered output + `byId` key on the unique `nodeKey` (not the shared
|
|
607
|
+
// family-key `id:`) so sibling tasks are distinct nodes ([[HS-K5GQ-DIAG]]).
|
|
608
|
+
const layered = topologicalSort(specs);
|
|
609
|
+
const byId = new Map(specs.map((s) => [nodeKeyOf(s), s]));
|
|
610
|
+
const criticality = computeCriticality(specs);
|
|
611
|
+
|
|
612
|
+
const sorted = [...layered].sort((a, b) => {
|
|
613
|
+
if (a.layer !== b.layer) return a.layer - b.layer;
|
|
614
|
+
const sa = byId.get(a.id);
|
|
615
|
+
const sb = byId.get(b.id);
|
|
616
|
+
|
|
617
|
+
const tr = tierRankOf(sa) - tierRankOf(sb);
|
|
618
|
+
if (tr !== 0) return tr;
|
|
619
|
+
|
|
620
|
+
// Stage 2.7 — blast-radius band ASC (vault-wide < cross-surface < local)
|
|
621
|
+
// ([[encoding-foundational-and-blast-radius-p]]). Within-tier only; above
|
|
622
|
+
// what_matters. Inert when every spec is `local`. Mirror pipeline.ts.
|
|
623
|
+
const br = blastRadiusRank(sa) - blastRadiusRank(sb);
|
|
624
|
+
if (br !== 0) return br;
|
|
625
|
+
|
|
626
|
+
// Stage 2.5 — what_matters within-tier value band: heart > star > unflagged
|
|
627
|
+
// (DESC). Within-tier only; outranks criticality, in-progress bias, effort.
|
|
628
|
+
const wm = whatMattersRank(sb) - whatMattersRank(sa);
|
|
629
|
+
if (wm !== 0) return wm;
|
|
630
|
+
|
|
631
|
+
const ca3 = criticality.get(a.id) ?? 0;
|
|
632
|
+
const cb3 = criticality.get(b.id) ?? 0;
|
|
633
|
+
if (ca3 !== cb3) return cb3 - ca3;
|
|
634
|
+
|
|
635
|
+
const ip = inProgressRank(sa) - inProgressRank(sb);
|
|
636
|
+
if (ip !== 0) return ip;
|
|
637
|
+
|
|
638
|
+
// Stage 4.5 — aging guard (anti-starvation): aged specs lead non-aged peers
|
|
639
|
+
// within their (layer × tier × criticality × in_progress) partition.
|
|
640
|
+
const ag = agingRank(sa, nowMs) - agingRank(sb, nowMs);
|
|
641
|
+
if (ag !== 0) return ag;
|
|
642
|
+
|
|
643
|
+
const eff = effortOf(sa) - effortOf(sb);
|
|
644
|
+
if (eff !== 0) return eff;
|
|
645
|
+
|
|
646
|
+
// Stage 6.7 — declared parent-order ([[HS-67F4-DIAG]]): among siblings that
|
|
647
|
+
// are otherwise tied on every real signal, honor the owner's authored
|
|
648
|
+
// sequence (milestone `epics:` / epic `children:` position) BEFORE falling
|
|
649
|
+
// to created/hash. Guarded to siblings sharing a parent (`parent ?? milestone`)
|
|
650
|
+
// because the comparator ranks the whole cohort at once — comparing declared
|
|
651
|
+
// indices across different parents is meaningless. Inert (falls through) when
|
|
652
|
+
// either spec lacks a stamped index, so a cohort built without the stamp
|
|
653
|
+
// orders byte-identically. Mirror pipeline.ts.
|
|
654
|
+
const pa = sa.parent ?? sa.milestone;
|
|
655
|
+
const pb = sb.parent ?? sb.milestone;
|
|
656
|
+
if (
|
|
657
|
+
pa != null &&
|
|
658
|
+
pa === pb &&
|
|
659
|
+
sa.declaredOrderIndex != null &&
|
|
660
|
+
sb.declaredOrderIndex != null &&
|
|
661
|
+
sa.declaredOrderIndex !== sb.declaredOrderIndex
|
|
662
|
+
) {
|
|
663
|
+
return sa.declaredOrderIndex - sb.declaredOrderIndex;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const ca = createdAt(sa);
|
|
667
|
+
const cb = createdAt(sb);
|
|
668
|
+
if (ca !== cb) return ca < cb ? -1 : 1;
|
|
669
|
+
|
|
670
|
+
// Stage 8 — stable hash final tiebreaker on the family-key `id:`
|
|
671
|
+
// (byte-identical to the pre-nodeKey mirror for every non-sibling pair).
|
|
672
|
+
const ha = stableHash(sa.id);
|
|
673
|
+
const hb = stableHash(sb.id);
|
|
674
|
+
if (ha !== hb) return ha < hb ? -1 : 1;
|
|
675
|
+
// Same family key (legit sibling tasks, [[HS-K5GQ-DIAG]]) → disambiguate on
|
|
676
|
+
// the unique node key; distinct hash-collision pairs keep prior behavior.
|
|
677
|
+
if (sa.id === sb.id) return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
678
|
+
return 0;
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
const { ordered: clustered, movedTestingSpecs } = applyTestingBatch(
|
|
682
|
+
sorted,
|
|
683
|
+
byId,
|
|
684
|
+
criticality,
|
|
685
|
+
nowMs,
|
|
686
|
+
);
|
|
687
|
+
|
|
688
|
+
// Stage 9 — hysteresis band (anti-churn), inert without priorOrder.
|
|
689
|
+
const stabilized = applyHysteresis(clustered, priorOrder);
|
|
690
|
+
|
|
691
|
+
// `id` here is the unique node key. Emit the family-key `id:` for display /
|
|
692
|
+
// pin / claim back-compat AND the unique `nodeKey` for callers that must
|
|
693
|
+
// disambiguate sibling tasks ([[HS-K5GQ-DIAG]]).
|
|
694
|
+
return stabilized.map(({ id, layer }, position) => ({
|
|
695
|
+
id: byId.get(id).id,
|
|
696
|
+
nodeKey: id,
|
|
697
|
+
position,
|
|
698
|
+
explanation: buildExplanation(
|
|
699
|
+
byId.get(id),
|
|
700
|
+
layer,
|
|
701
|
+
criticality.get(id) ?? 0,
|
|
702
|
+
movedTestingSpecs.has(id),
|
|
703
|
+
agingDays(byId.get(id), nowMs),
|
|
704
|
+
),
|
|
705
|
+
}));
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Pin override ([[HS-TASK-XA40]] / [[HS-TASK-PINQ]]). Re-orders the
|
|
710
|
+
* `SchedulerOrder` so the pinned spec sits as close to position 0 as its
|
|
711
|
+
* **open prerequisites allow** — the PM-pushback contract from [[HS-EPIC-CNVG]].
|
|
712
|
+
*
|
|
713
|
+
* 1. With `specs` omitted (legacy XA40 contract), move the pin to position 0
|
|
714
|
+
* unconditionally. Kept for back-compat with callers that don't yet wire
|
|
715
|
+
* prereqs in.
|
|
716
|
+
* 2. With `specs` provided, walk the pin's transitive `depends_on` closure
|
|
717
|
+
* and seat every prereq that is present in the order ahead of the pin in
|
|
718
|
+
* its existing relative order. The pin lands immediately after the last
|
|
719
|
+
* unmet prereq.
|
|
720
|
+
*
|
|
721
|
+
* Keep in lockstep with spex-console/src/lib/scheduler/pipeline.ts:applyPin.
|
|
722
|
+
*/
|
|
723
|
+
export function applyPin(order, pinnedSpecId, specs) {
|
|
724
|
+
if (!pinnedSpecId) return order;
|
|
725
|
+
const idx = order.findIndex((o) => o.id === pinnedSpecId);
|
|
726
|
+
if (idx === -1) return order;
|
|
727
|
+
|
|
728
|
+
const unmetPrereqs =
|
|
729
|
+
specs && specs.length > 0
|
|
730
|
+
? computeOpenPrereqClosure(pinnedSpecId, specs, order)
|
|
731
|
+
: new Set();
|
|
732
|
+
|
|
733
|
+
if (unmetPrereqs.size === 0) {
|
|
734
|
+
if (idx === 0) return order;
|
|
735
|
+
const pinned = order[idx];
|
|
736
|
+
const rest = [...order.slice(0, idx), ...order.slice(idx + 1)];
|
|
737
|
+
return [pinned, ...rest].map((o, position) => ({ ...o, position }));
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
const prereqEntries = [];
|
|
741
|
+
const otherEntries = [];
|
|
742
|
+
const pinned = order[idx];
|
|
743
|
+
for (let i = 0; i < order.length; i++) {
|
|
744
|
+
if (i === idx) continue;
|
|
745
|
+
const entry = order[i];
|
|
746
|
+
if (unmetPrereqs.has(entry.id)) prereqEntries.push(entry);
|
|
747
|
+
else otherEntries.push(entry);
|
|
748
|
+
}
|
|
749
|
+
const reordered = [...prereqEntries, pinned, ...otherEntries];
|
|
750
|
+
return reordered.map((o, position) => ({ ...o, position }));
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function computeOpenPrereqClosure(pinId, specs, order) {
|
|
754
|
+
const orderIds = new Set(order.map((o) => o.id));
|
|
755
|
+
const byId = new Map(specs.map((s) => [s.id, s]));
|
|
756
|
+
const unmet = new Set();
|
|
757
|
+
const visited = new Set([pinId]);
|
|
758
|
+
const queue = [pinId];
|
|
759
|
+
while (queue.length > 0) {
|
|
760
|
+
const cur = queue.shift();
|
|
761
|
+
const spec = byId.get(cur);
|
|
762
|
+
if (!spec || !spec.depends_on) continue;
|
|
763
|
+
for (const depRaw of spec.depends_on) {
|
|
764
|
+
const dep = normalizeDependsOnId(depRaw);
|
|
765
|
+
if (!dep || dep === cur || dep === pinId) continue;
|
|
766
|
+
if (visited.has(dep)) continue;
|
|
767
|
+
visited.add(dep);
|
|
768
|
+
if (orderIds.has(dep)) unmet.add(dep);
|
|
769
|
+
queue.push(dep);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
return unmet;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Convert a vault spec's frontmatter (raw, as loaded by parseFrontmatter)
|
|
777
|
+
* into the minimal scheduler input. Tolerates `depends_on` as wikilink array
|
|
778
|
+
* or scalar; missing/legacy `kind` defaults to feature.
|
|
779
|
+
*/
|
|
780
|
+
export function frontmatterToSchedulerSpec(specId, frontmatter = {}) {
|
|
781
|
+
const dependsRaw = frontmatter.depends_on;
|
|
782
|
+
const depends = Array.isArray(dependsRaw)
|
|
783
|
+
? dependsRaw
|
|
784
|
+
: dependsRaw == null
|
|
785
|
+
? []
|
|
786
|
+
: [dependsRaw];
|
|
787
|
+
return {
|
|
788
|
+
id: specId,
|
|
789
|
+
kind: typeof frontmatter.kind === 'string' ? frontmatter.kind : undefined,
|
|
790
|
+
effort: typeof frontmatter.effort === 'number' ? frontmatter.effort : undefined,
|
|
791
|
+
requires_user_testing:
|
|
792
|
+
typeof frontmatter.requires_user_testing === 'boolean'
|
|
793
|
+
? frontmatter.requires_user_testing
|
|
794
|
+
: undefined,
|
|
795
|
+
depends_on: depends,
|
|
796
|
+
created:
|
|
797
|
+
typeof frontmatter.created === 'string'
|
|
798
|
+
? frontmatter.created
|
|
799
|
+
: frontmatter.created instanceof Date
|
|
800
|
+
? frontmatter.created.toISOString()
|
|
801
|
+
: undefined,
|
|
802
|
+
status: typeof frontmatter.status === 'string' ? frontmatter.status : undefined,
|
|
803
|
+
canonical:
|
|
804
|
+
typeof frontmatter.canonical === 'boolean' ? frontmatter.canonical : undefined,
|
|
805
|
+
// Stage 2.5 input — what_matters value band ([[HS-TASK-PQID]]). Guarded to
|
|
806
|
+
// the two recognized levels; any other value (or absent) → undefined, so the
|
|
807
|
+
// band is inert. Mirrors console toSchedulerSpec (focus-explanations.ts).
|
|
808
|
+
what_matters:
|
|
809
|
+
frontmatter.what_matters === 'star' || frontmatter.what_matters === 'heart'
|
|
810
|
+
? frontmatter.what_matters
|
|
811
|
+
: undefined,
|
|
812
|
+
};
|
|
813
|
+
}
|