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,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
+ }