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,314 @@
1
+ /**
2
+ * `heyspex_verify_reproduction` — record a reproduction re-run on a diagnose, and
3
+ * close it when the run comes back clean.
4
+ *
5
+ * Criterion 7 of [[HS-ALBV-BFIX-EPIC-breakdown-stage-accepts-a-diagnose]], built in
6
+ * [[HS-09W1]]: a bug write-up that has been broken down closes when its REPRODUCTION
7
+ * stops reproducing — not when its last child task closes. Every one of the seven
8
+ * prior console read-path attempts closed clean while the bug stayed live, which is
9
+ * exactly what a child-rollup close rewards.
10
+ *
11
+ * This is also the first route a diagnose has ever had to `closed` at all. A diagnose
12
+ * is standalone — it has no parent in the milestone → epic → task chain — so
13
+ * `cascadeCloseReviewPending` in the console's status engine never reaches it (that
14
+ * cascade only walks milestone → epic and epic → task). Measured 2026-08-22: 211
15
+ * diagnoses parked at `in_review` / `review_pending` with nothing in the system that
16
+ * would ever move them. Recording a clean re-run here is what moves one.
17
+ *
18
+ * The status flip goes through `PATCH /api/specs/:id/status`, never a hand-edited
19
+ * `status:` — the status API owns coercion, rollup, and spec events ([[HS-DIAG-DOSH]]),
20
+ * and the reproduction gate lives inside it, so a clean record is what unlocks the
21
+ * close rather than this module deciding on its own.
22
+ */
23
+
24
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
25
+ import { join } from 'node:path';
26
+
27
+ import {
28
+ appendChangeLogRow,
29
+ bumpUpdated,
30
+ commitWrites,
31
+ getFrontmatterBlock,
32
+ getScalarField,
33
+ isoNow,
34
+ parseListFieldBasenames,
35
+ setScalarField,
36
+ todayDate,
37
+ } from './promotion-helpers.mjs';
38
+ import { yamlScalar } from './yaml-scalar.mjs';
39
+
40
+ /** The two verdicts a re-run can produce. Authored by whoever ran it, never inferred. */
41
+ export const REPRODUCTION_VERDICTS = ['no_longer_reproduces', 'still_reproduces'];
42
+
43
+ /**
44
+ * Statuses from which a clean re-run closes the diagnose.
45
+ *
46
+ * Build-complete only. An `open` / `in_progress` diagnose has not been built yet, and
47
+ * a re-run that happens to pass on it proves the bug was never reproduced in the first
48
+ * place, not that a fix landed — that one still goes through the normal build→review
49
+ * hand-off. The verdict is recorded either way.
50
+ */
51
+ export const CLOSEABLE_FROM_STATUSES = new Set(['in_review', 'review_pending']);
52
+
53
+ /**
54
+ * Evidence floor. A verdict with no account of what was run is a green light wired to
55
+ * nothing — the same failure mode a vacuous checks-manifest row is refused for
56
+ * ([[checks-manifest-authoring]]). 30 characters is short enough for "ran the repro
57
+ * steps in §Reproduction against localhost:3000, clean" and long enough to refuse "ok".
58
+ */
59
+ export const EVIDENCE_MIN_LENGTH = 30;
60
+
61
+ /** Mirror of the console's `REPRODUCTION_RERUN_FIELDS` (`@/lib/diagnose-spawned`). */
62
+ export const REPRODUCTION_RERUN_FIELDS = {
63
+ at: 'reproduction_rerun_at',
64
+ by: 'reproduction_rerun_by',
65
+ result: 'reproduction_rerun_result',
66
+ evidence: 'reproduction_rerun_evidence',
67
+ };
68
+
69
+ /** Mirror of the console's `SPAWNED_WORK_FIELDS`. */
70
+ export const SPAWNED_WORK_FIELDS = ['spawned_epics', 'spawned_tasks'];
71
+
72
+ /**
73
+ * Resolve a diagnose reference to a file under `specs/diagnoses/`.
74
+ *
75
+ * Resolution-first, like every other vault resolver here: exact basename, then
76
+ * frontmatter `id:` / `file_id:`, then an unambiguous id-prefix match. No id-shape
77
+ * pattern match — an era-3 file key carries no `DIAG` label
78
+ * ([[HS-B1ZT-QCP2-DIAG-heyspex-promote-research-cannot-resolve]]).
79
+ */
80
+ export function resolveDiagnoseRef(vaultRoot, rawRef) {
81
+ const ref =
82
+ typeof rawRef === 'string'
83
+ ? rawRef
84
+ .trim()
85
+ .replace(/^\[\[|\]\]$/g, '')
86
+ .replace(/^['"]|['"]$/g, '')
87
+ .replace(/\.md$/i, '')
88
+ .trim()
89
+ : '';
90
+ if (!ref) return { error: 'INVALID_ARGS', message: 'spec_id is required' };
91
+
92
+ const dir = join(vaultRoot, 'specs', 'diagnoses');
93
+ const notFound = {
94
+ error: 'DIAGNOSE_NOT_FOUND',
95
+ message: `diagnose ${rawRef} not found under specs/diagnoses/ (tried exact basename, frontmatter id:, and id-prefix match)`,
96
+ };
97
+ if (!existsSync(dir)) return notFound;
98
+
99
+ const load = (file) => {
100
+ const absPath = join(dir, file);
101
+ return {
102
+ basename: file.replace(/\.md$/i, ''),
103
+ absPath,
104
+ relPath: join('specs', 'diagnoses', file),
105
+ raw: readFileSync(absPath, 'utf8'),
106
+ };
107
+ };
108
+
109
+ if (existsSync(join(dir, `${ref}.md`))) return { record: load(`${ref}.md`) };
110
+
111
+ const files = readdirSync(dir).filter((f) => f.toLowerCase().endsWith('.md'));
112
+
113
+ const idHits = [];
114
+ for (const file of files) {
115
+ let raw;
116
+ try {
117
+ raw = readFileSync(join(dir, file), 'utf8');
118
+ } catch {
119
+ continue;
120
+ }
121
+ const block = getFrontmatterBlock(raw)?.block;
122
+ if (!block) continue;
123
+ if (getScalarField(block, 'id') === ref || getScalarField(block, 'file_id') === ref) {
124
+ idHits.push(file);
125
+ }
126
+ }
127
+ if (idHits.length === 1) return { record: load(idHits[0]) };
128
+ if (idHits.length > 1) {
129
+ return {
130
+ error: 'DIAGNOSE_ID_AMBIGUOUS',
131
+ message: `spec_id ${rawRef} matches the frontmatter id of ${idHits.length} files under specs/diagnoses/ — pass the full basename instead`,
132
+ candidates: idHits.map((f) => f.replace(/\.md$/i, '')),
133
+ };
134
+ }
135
+
136
+ const prefixHits = files.filter((f) => f.startsWith(`${ref}-`));
137
+ if (prefixHits.length === 1) return { record: load(prefixHits[0]) };
138
+ if (prefixHits.length > 1) {
139
+ return {
140
+ error: 'DIAGNOSE_ID_AMBIGUOUS',
141
+ message: `spec_id ${rawRef} is a prefix of ${prefixHits.length} files under specs/diagnoses/ — pass the full basename instead`,
142
+ candidates: prefixHits.map((f) => f.replace(/\.md$/i, '')),
143
+ };
144
+ }
145
+
146
+ return notFound;
147
+ }
148
+
149
+ /** Every spec this diagnose spawned — the link fields, never a parent edge. */
150
+ export function spawnedWorkRefs(block) {
151
+ const out = [];
152
+ for (const field of SPAWNED_WORK_FIELDS) {
153
+ const list = parseListFieldBasenames(block, field);
154
+ if (Array.isArray(list)) out.push(...list);
155
+ }
156
+ return out;
157
+ }
158
+
159
+ /**
160
+ * Stage the frontmatter mutation for one recorded re-run.
161
+ *
162
+ * `setScalarField` REPLACES an existing line, so a second re-run overwrites the first
163
+ * rather than leaving two verdicts in one file for a reader to pick between. The
164
+ * history of re-runs lives in the change log, where it is append-only and dated.
165
+ */
166
+ export function buildVerifiedContent(raw, { result, evidence, by, now, today }) {
167
+ const fm = getFrontmatterBlock(raw);
168
+ if (!fm) throw new Error('diagnose has no frontmatter — cannot record a reproduction re-run');
169
+ let block = fm.block;
170
+ block = setScalarField(block, REPRODUCTION_RERUN_FIELDS.at, `"${now}"`);
171
+ block = setScalarField(block, REPRODUCTION_RERUN_FIELDS.by, yamlScalar(by));
172
+ block = setScalarField(block, REPRODUCTION_RERUN_FIELDS.result, result);
173
+ block = setScalarField(block, REPRODUCTION_RERUN_FIELDS.evidence, yamlScalar(evidence));
174
+ block = bumpUpdated(block, now);
175
+
176
+ const verdict =
177
+ result === 'no_longer_reproduces'
178
+ ? 'Reproduction re-run: NO LONGER REPRODUCES.'
179
+ : 'Reproduction re-run: STILL REPRODUCES.';
180
+ const body = appendChangeLogRow(fm.body, today, by, `${verdict} ${evidence}`);
181
+ return `${fm.open}${block}${fm.close}${body}`;
182
+ }
183
+
184
+ /**
185
+ * @param {{
186
+ * vaultRoot: string,
187
+ * consoleFetch: Function,
188
+ * args?: Record<string, unknown>,
189
+ * clock?: () => string,
190
+ * agent?: string,
191
+ * }} ctx
192
+ */
193
+ export async function executeVerifyReproduction({
194
+ vaultRoot,
195
+ consoleFetch,
196
+ args = {},
197
+ clock = isoNow,
198
+ agent = 'agent',
199
+ }) {
200
+ const result = typeof args.result === 'string' ? args.result.trim().toLowerCase() : '';
201
+ if (!REPRODUCTION_VERDICTS.includes(result)) {
202
+ return {
203
+ error: 'INVALID_ARGS',
204
+ message: `result must be one of: ${REPRODUCTION_VERDICTS.join(' | ')}`,
205
+ };
206
+ }
207
+ const evidence = typeof args.evidence === 'string' ? args.evidence.trim() : '';
208
+ if (evidence.length < EVIDENCE_MIN_LENGTH) {
209
+ return {
210
+ error: 'EVIDENCE_REQUIRED',
211
+ message:
212
+ `evidence must be at least ${EVIDENCE_MIN_LENGTH} characters describing what you re-ran ` +
213
+ 'and what happened. A verdict with no account of the run is a green light wired to nothing.',
214
+ };
215
+ }
216
+
217
+ const resolved = resolveDiagnoseRef(vaultRoot, args.spec_id);
218
+ if (resolved.error) return resolved;
219
+ const diagnose = resolved.record;
220
+
221
+ const fm = getFrontmatterBlock(diagnose.raw);
222
+ if (!fm) {
223
+ return {
224
+ error: 'DIAGNOSE_FRONTMATTER_MISSING',
225
+ message: `${diagnose.basename} has no frontmatter — cannot record a reproduction re-run`,
226
+ };
227
+ }
228
+ const type = (getScalarField(fm.block, 'type') ?? '').trim().toLowerCase();
229
+ if (type && type !== 'diagnose') {
230
+ return {
231
+ error: 'NOT_A_DIAGNOSE',
232
+ message: `${diagnose.basename} is \`type: ${type}\` — a reproduction re-run is recorded on a diagnose`,
233
+ resolved_type: type,
234
+ };
235
+ }
236
+
237
+ const specId = getScalarField(fm.block, 'id') ?? diagnose.basename;
238
+ const statusBefore = (getScalarField(fm.block, 'status') ?? 'open').trim().toLowerCase();
239
+ const spawned = spawnedWorkRefs(fm.block);
240
+
241
+ const now = clock();
242
+ const today = todayDate();
243
+ const by = typeof args.by === 'string' && args.by.trim() ? args.by.trim() : agent;
244
+
245
+ const content = buildVerifiedContent(diagnose.raw, { result, evidence, by, now, today });
246
+ const commit = commitWrites([{ kind: 'update', absPath: diagnose.absPath, content }]);
247
+ if (!commit.ok) {
248
+ return {
249
+ error: 'WRITE_FAILED',
250
+ message: commit.error?.message ?? 'unknown write failure',
251
+ };
252
+ }
253
+
254
+ const recorded = {
255
+ spec_id: specId,
256
+ basename: diagnose.basename,
257
+ path: diagnose.relPath,
258
+ result,
259
+ evidence,
260
+ by,
261
+ at: now,
262
+ spawned_work: spawned,
263
+ status_before: statusBefore,
264
+ };
265
+
266
+ // A run that still reproduces records and closes nothing — the diagnose is left
267
+ // exactly as it was, and its Resolution row stays open.
268
+ if (result !== 'no_longer_reproduces') {
269
+ return {
270
+ ...recorded,
271
+ closed: false,
272
+ close_skipped: 'still_reproduces',
273
+ message:
274
+ 'Verdict recorded. The bug still reproduces, so nothing was closed — the write-up stays open.',
275
+ };
276
+ }
277
+
278
+ if (!CLOSEABLE_FROM_STATUSES.has(statusBefore)) {
279
+ return {
280
+ ...recorded,
281
+ closed: false,
282
+ close_skipped: 'not_build_complete',
283
+ message:
284
+ `Verdict recorded. \`${specId}\` is \`status: ${statusBefore}\`, not build-complete, so it was ` +
285
+ 'not closed — hand it off with `heyspex_submit_for_review` first, then the recorded clean run closes it.',
286
+ };
287
+ }
288
+
289
+ const res = await consoleFetch(`/api/specs/${encodeURIComponent(specId)}/status`, {
290
+ method: 'PATCH',
291
+ // `close_reason`, snake_case — the route's body contract. A camelCase key is
292
+ // silently ignored, so the close would land with no reason recorded.
293
+ body: { status: 'closed', close_reason: `Reproduction re-run ${now}: no longer reproduces.` },
294
+ });
295
+ if (!res?.ok) {
296
+ return {
297
+ ...recorded,
298
+ closed: false,
299
+ close_skipped: 'status_api_refused',
300
+ status: res?.status,
301
+ console_response: res?.body,
302
+ message:
303
+ 'Verdict recorded on disk, but the status API did not close the spec. The record stands — ' +
304
+ 're-run the close once the reason in `console_response` is cleared.',
305
+ };
306
+ }
307
+
308
+ return {
309
+ ...recorded,
310
+ closed: true,
311
+ status_after: res.body?.spec?.status ?? 'closed',
312
+ message: `Reproduction no longer reproduces — \`${specId}\` closed via the status API.`,
313
+ };
314
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * "Why now" one-line legibility builder — vendor mirror of
3
+ * spex-console/src/lib/scheduler/why-now.ts ([[HS-TASK-WYNW]] / [[HS-RES-7J97]]).
4
+ * Keep in lockstep with the console module (heart/star always appear when flagged).
5
+ */
6
+
7
+ export const HEART_REASON_SENTINEL = 'user marked very important (heart)';
8
+ export const STAR_REASON_SENTINEL = 'user marked priority (star)';
9
+ export const TESTING_CLUSTER_REASON_SENTINEL = 'grouped with adjacent testing-flagged specs';
10
+
11
+ const MAX_CLAUSES = 3;
12
+ const SMALL_EFFORT_MAX = 2;
13
+ const HIGH_CRITICALITY_MIN = 3;
14
+ /** Mirrors console AGING_THRESHOLD_DAYS. */
15
+ export const AGING_THRESHOLD_DAYS = 14;
16
+
17
+ function hasReason(trace, sentinel) {
18
+ return Array.isArray(trace?.reasons) && trace.reasons.includes(sentinel);
19
+ }
20
+
21
+ function collectStageClauses(trace, ctx) {
22
+ const out = [];
23
+ if (trace.topologicalLayer === 0) out.push('no open blockers');
24
+ if (trace.tier === 'infra') out.push('infra work first');
25
+ else if (trace.tier === 'foundation') out.push('foundation work first');
26
+ if (trace.criticality >= HIGH_CRITICALITY_MIN) out.push('blocks the most');
27
+ else if (trace.criticality > 0) out.push(`blocking ${trace.criticality} downstream`);
28
+ if (trace.agedDays >= AGING_THRESHOLD_DAYS) out.push(`waiting ${trace.agedDays}d`);
29
+ if (trace.effort <= SMALL_EFFORT_MAX) out.push('smallest effort');
30
+ if (hasReason(trace, TESTING_CLUSTER_REASON_SENTINEL)) out.push('groups with other testing work');
31
+ if (ctx.hysteresisHeld) out.push('kept its slot (anti-churn)');
32
+ return out;
33
+ }
34
+
35
+ /**
36
+ * @param {object} trace ExplanationTrace from buildExplanation / computeOrder
37
+ * @param {{ pinned?: boolean, pinnedBehindPrereqsCount?: number, hysteresisHeld?: boolean }} [ctx]
38
+ */
39
+ export function buildWhyNow(trace, ctx = {}) {
40
+ const clauses = [];
41
+ if (ctx.pinned) {
42
+ clauses.push('pinned by you');
43
+ const behind = ctx.pinnedBehindPrereqsCount ?? 0;
44
+ if (behind > 0) clauses.push(`behind ${behind} open prereq${behind === 1 ? '' : 's'}`);
45
+ }
46
+ const wmClause = hasReason(trace, HEART_REASON_SENTINEL)
47
+ ? "you ❤️'d it"
48
+ : hasReason(trace, STAR_REASON_SENTINEL)
49
+ ? "you ⭐'d it"
50
+ : null;
51
+ if (wmClause) clauses.push(wmClause);
52
+
53
+ const inProgress = trace.status === 'in_progress';
54
+ const footer = inProgress ? 'already in progress' : null;
55
+ const stageClauses = collectStageClauses(trace, ctx);
56
+ const reserveForFooter = footer ? 1 : 0;
57
+ const slotsForStages = Math.max(0, MAX_CLAUSES - clauses.length - reserveForFooter);
58
+ for (let i = 0; i < stageClauses.length && i < slotsForStages; i++) {
59
+ clauses.push(stageClauses[i]);
60
+ }
61
+ if (footer) clauses.push(footer);
62
+ if (clauses.length === 0) return 'default deterministic order';
63
+ return clauses.join(' · ');
64
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Dependency-free YAML scalar quoter for the MCP frontmatter writers.
3
+ *
4
+ * The MCP runtime deliberately avoids loading js-yaml (startup cost — see the
5
+ * note in plan-push.mjs and HS-REV-006B), so hand-built frontmatter needs a
6
+ * correct, library-free quoting rule. The prior per-writer rule only quoted a
7
+ * value if it *contained* `:` or `"`, which let values that START with a
8
+ * backtick or a double-quote (and other YAML indicators) through unquoted —
9
+ * the exact corruption that silently dropped specs from the index.
10
+ *
11
+ * Strategy: emit the bare string only when it matches a conservative
12
+ * "plainly safe" pattern; otherwise emit `JSON.stringify(...)`. A JSON
13
+ * double-quoted string is always a valid YAML double-quoted scalar, so this is
14
+ * round-trip safe for any input. This is the seed of the single canonical
15
+ * serializer recommended by [[HS-RES-COOA-deterministic-frontmatter-integrity]].
16
+ */
17
+
18
+ /**
19
+ * Bare-safe iff: starts with an alphanumeric and contains only word chars,
20
+ * spaces, dots, slashes, or hyphens. That charset excludes every YAML
21
+ * indicator (`: # ` " ' & * ! | > % @ , [ ] { }` and leading `-`/space), so
22
+ * the value cannot be misread as structure. Everything else is JSON-quoted.
23
+ */
24
+ const BARE_SAFE = /^[A-Za-z0-9][\w ./-]*$/;
25
+
26
+ /**
27
+ * @param {unknown} value
28
+ * @returns {string} a YAML-safe scalar representation of `value`.
29
+ */
30
+ export function yamlScalar(value) {
31
+ const s = String(value);
32
+ if (BARE_SAFE.test(s)) return s;
33
+ return JSON.stringify(s);
34
+ }