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,524 @@
1
+ /**
2
+ * Placement descends, and everything spawned names a milestone ([[HS-FLGU]] of
3
+ * [[HS-ALBV]]).
4
+ *
5
+ * Epic §Scope: "Placement goes down, never up. Matching epic gets tasks. Matching
6
+ * milestone with no fitting epic gets a new epic. Only when neither exists does it
7
+ * mint a milestone with epic stubs, and that case asks the owner first." And:
8
+ * "Milestone required on everything spawned, no exemption. … carving an exemption is
9
+ * the only way stray epics could appear."
10
+ *
11
+ * Two things this module refuses to do, both load-bearing:
12
+ *
13
+ * - **It never parents anything to the bug write-up.** An epic parented to a bug
14
+ * report does not render on the roadmap at all. Spawned specs point UP at their
15
+ * milestone/epic and BACK at the write-up via `derived_from:` — a link, not a
16
+ * parent edge. The diagnose's own `parent:` keeps its existing job of grouping
17
+ * small bugs under one epic and is never touched here.
18
+ * - **It never writes an epic without a resolvable milestone.** The check runs
19
+ * before any file is written, and no argument or environment variable relaxes it.
20
+ *
21
+ * Every write is STAGED and committed through `commitWrites`, including the parent's
22
+ * `epics:` / `children:` mirror — so a failure at any step rolls the whole set back
23
+ * and no half-placed breakdown is left on disk.
24
+ */
25
+
26
+ import { existsSync, readFileSync } from 'node:fs';
27
+ import { join } from 'node:path';
28
+
29
+ import { collectOccupiedKeysEitherKind, allocateFileKey, parseSpecId } from './spec-id-lib.mjs';
30
+ import { listVaultPillars, resolvePillar } from './promote-research.mjs';
31
+ import {
32
+ appendChangeLogRow,
33
+ appendToListField,
34
+ bumpUpdated,
35
+ commitWrites,
36
+ deriveSlug,
37
+ getFrontmatterBlock,
38
+ getScalarField,
39
+ isoNow,
40
+ todayDate,
41
+ } from './promotion-helpers.mjs';
42
+ import { yamlScalar } from './yaml-scalar.mjs';
43
+
44
+ const FAMILY_KEY_SHAPE_RE = /^[A-Z0-9]{4,}$/;
45
+
46
+ /** The family key spawned specs inherit from the bug write-up they came from. */
47
+ export function inheritedFamilyKey(sourceFrontmatter, sourceBasename) {
48
+ const candidates = [
49
+ sourceFrontmatter?.family,
50
+ parseSpecId(sourceFrontmatter?.id)?.familyKey,
51
+ parseSpecId(sourceBasename)?.familyKey,
52
+ ];
53
+ for (const c of candidates) {
54
+ if (typeof c === 'string' && FAMILY_KEY_SHAPE_RE.test(c)) return c;
55
+ }
56
+ return null;
57
+ }
58
+
59
+ function readSpecFile(vaultRoot, dirParts, basename) {
60
+ const absPath = join(vaultRoot, ...dirParts, `${basename}.md`);
61
+ if (!existsSync(absPath)) return null;
62
+ const raw = readFileSync(absPath, 'utf8');
63
+ return { basename, absPath, relPath: join(...dirParts, `${basename}.md`), raw };
64
+ }
65
+
66
+ /**
67
+ * Stage an append onto a parent's list field (`epics:` on a milestone, `children:`
68
+ * on an epic) — the bidirectional mirror that keeps `milestone.epics ⇔ epic.milestone`
69
+ * and `epic.children ⇔ task.parent` honest.
70
+ *
71
+ * Returns a staged write rather than performing one, so the mirror rolls back with
72
+ * everything else. `promote-research.mjs` does this append POST-commit, which leaves
73
+ * a window where the files exist and the parent does not list them.
74
+ */
75
+ function stageParentAppend(record, field, childBasenames, now) {
76
+ const fm = getFrontmatterBlock(record.raw);
77
+ if (!fm) {
78
+ return { error: 'PARENT_FRONTMATTER_MISSING', message: `${record.basename} has no frontmatter` };
79
+ }
80
+ let block = fm.block;
81
+ let changed = false;
82
+ for (const child of childBasenames) {
83
+ const next = appendToListField(block, field, child);
84
+ block = next.block;
85
+ changed = changed || next.changed;
86
+ }
87
+ if (!changed) return { write: null };
88
+ block = bumpUpdated(block, now);
89
+ return { write: { kind: 'update', absPath: record.absPath, content: `${fm.open}${block}${fm.close}${fm.body}` } };
90
+ }
91
+
92
+ function buildTaskContent({ id, fileKey, familyKey, name, parentBasename, sourceBasename, summary, now, today, fix }) {
93
+ return `---
94
+ id: ${id}
95
+ file_id: ${fileKey}
96
+ family: ${familyKey}
97
+ name: ${yamlScalar(name)}
98
+ type: task
99
+ status: open
100
+ kind: feature
101
+ effort: 3
102
+ owner: pwher
103
+ parent: "[[${parentBasename}]]"
104
+ derived_from: "[[${sourceBasename}]]"
105
+ tags:
106
+ - bug-fix
107
+ summary: ${yamlScalar(summary)}
108
+ created: "${now}"
109
+ updated: "${now}"
110
+ ---
111
+
112
+ # ${name}
113
+
114
+ One of the separately-verifiable fixes listed in the bug write-up [[${sourceBasename}]] — that write-up is the record of the investigation and the source of this task, NOT its parent. It closes when its own reproduction stops reproducing, not when this task closes.
115
+
116
+ ## Acceptance criteria
117
+
118
+ 1. ${fix}
119
+ 2. The reproduction step in [[${sourceBasename}]] that this fix addresses no longer reproduces.
120
+
121
+ ## Change log
122
+
123
+ | Date | Author | Change |
124
+ | --- | --- | --- |
125
+ | ${today} | promote-diagnose (agent) | Spawned from bug write-up [[${sourceBasename}]] under [[${parentBasename}]]. |
126
+ `;
127
+ }
128
+
129
+ function buildEpicContent({ id, fileKey, familyKey, name, milestoneBasename, sourceBasename, pillarValue, summary, fixes, now, today }) {
130
+ const criteria = fixes.map((f, i) => `${i + 1}. ${f}`).join('\n');
131
+ return `---
132
+ id: ${id}
133
+ file_id: ${fileKey}
134
+ family: ${familyKey}
135
+ name: ${yamlScalar(name)}
136
+ type: epic
137
+ kind: foundation
138
+ effort: 3
139
+ requires_user_testing: false
140
+ status: open
141
+ owner: pwher
142
+ parent: "[[${milestoneBasename}]]"
143
+ milestone: "[[${milestoneBasename}]]"
144
+ pillar: ${pillarValue}
145
+ derived_from: "[[${sourceBasename}]]"
146
+ depends_on: []
147
+ tags:
148
+ - epic
149
+ - bug-fix
150
+ children: []
151
+ related:
152
+ - "[[${sourceBasename}]]"
153
+ summary: ${yamlScalar(summary)}
154
+ created: "${now}"
155
+ updated: "${now}"
156
+ ---
157
+
158
+ # ${name}
159
+
160
+ Spawned by the breakdown of bug write-up [[${sourceBasename}]], which lists ${fixes.length} separately-verifiable fixes. The write-up is the SOURCE of this epic, never its parent — an epic parented to a bug report does not render on the roadmap. The write-up closes when its reproduction stops reproducing, not when this epic closes.
161
+
162
+ ## Intended outcome
163
+
164
+ **User-visible outcome of this epic:** _What changes for the user when these fixes ship?_
165
+
166
+ **Moves (↑ link):** _Which outcome of [[${milestoneBasename}]] does it move, and by what mechanism?_
167
+
168
+ ## Acceptance criteria
169
+
170
+ ${criteria}
171
+
172
+ ## Change log
173
+
174
+ | Date | Author | Change |
175
+ | --- | --- | --- |
176
+ | ${today} | promote-diagnose (agent) | Spawned from bug write-up [[${sourceBasename}]] under milestone [[${milestoneBasename}]]. |
177
+ `;
178
+ }
179
+
180
+ function buildMilestoneContent({ id, fileKey, familyKey, name, sourceBasename, pillarValue, summary, now, today }) {
181
+ return `---
182
+ id: ${id}
183
+ file_id: ${fileKey}
184
+ family: ${familyKey}
185
+ name: ${yamlScalar(name)}
186
+ type: milestone
187
+ status: open
188
+ owner: pwher
189
+ ${pillarValue ? `pillar: ${pillarValue}\n` : ''}created: "${now}"
190
+ updated: "${now}"
191
+ target_date: ""
192
+ tags:
193
+ - milestone
194
+ - bug-fix
195
+ epics: []
196
+ informed_by:
197
+ - "[[${sourceBasename}]]"
198
+ related: []
199
+ summary: ${yamlScalar(summary)}
200
+ ---
201
+
202
+ # ${name}
203
+
204
+ Minted by the breakdown of bug write-up [[${sourceBasename}]] after grounding found no open milestone or epic covering this area. Replace this prose with the real milestone framing — why these fixes ship together.
205
+
206
+ ## Intended outcome
207
+
208
+ **Outcome at the milestone altitude:** _What outcome does this milestone deliver?_
209
+
210
+ ## Change log
211
+
212
+ | Date | Author | Change |
213
+ | --- | --- | --- |
214
+ | ${today} | promote-diagnose (agent) | Created from bug write-up [[${sourceBasename}]] (owner-confirmed). |
215
+ `;
216
+ }
217
+
218
+ /**
219
+ * The milestone an epic will name. Returns `{ basename }` or an error.
220
+ *
221
+ * The check is RESOLUTION, not presence: a `milestone:` wikilink pointing at a file
222
+ * that is not on disk is exactly the stray-epic shape the epic's "no exemption" rule
223
+ * exists to prevent, and it reads as fine in frontmatter.
224
+ */
225
+ export function requireResolvableMilestone(vaultRoot, milestoneRef) {
226
+ const basename = typeof milestoneRef === 'string'
227
+ ? milestoneRef.trim().replace(/^\[\[|\]\]$/g, '').replace(/^['"]|['"]$/g, '').replace(/\.md$/i, '')
228
+ : '';
229
+ if (!basename) {
230
+ return {
231
+ error: 'MILESTONE_REQUIRED',
232
+ message:
233
+ 'Every epic this stage writes must name a milestone. No argument, flag, or environment variable relaxes this — an epic without one never appears on the roadmap.',
234
+ };
235
+ }
236
+ const record = readSpecFile(vaultRoot, ['specs', 'milestones'], basename);
237
+ if (!record) {
238
+ return {
239
+ error: 'MILESTONE_REQUIRED',
240
+ message: `milestone [[${basename}]] does not resolve to a file under specs/milestones/ — an epic naming a milestone that is not on disk is a stray epic with a plausible-looking frontmatter line.`,
241
+ unresolved: basename,
242
+ };
243
+ }
244
+ return { record, basename };
245
+ }
246
+
247
+ /**
248
+ * Execute the placement the ladder + coverage scan decided on.
249
+ *
250
+ * @returns `{ spawned, writes }` on success, or `{ error, message, ... }`. Nothing is
251
+ * written until every gate has passed; the commit itself rolls back as a unit.
252
+ */
253
+ export function executeBreakdownWrites({ vaultRoot, source, sourceFrontmatter, ladder, placement, args = {}, deps = {} }) {
254
+ const now = (deps.clock ?? isoNow)();
255
+ const today = now.slice(0, 10);
256
+ const sourceBasename = source.basename;
257
+ const familyKey = inheritedFamilyKey(sourceFrontmatter, sourceBasename) ?? 'BFIX';
258
+ const occupied = collectOccupiedKeysEitherKind(vaultRoot);
259
+ const mintKey = () => {
260
+ const key = allocateFileKey(occupied, { randomUnit: deps.randomUnit });
261
+ occupied.add(key);
262
+ return key;
263
+ };
264
+ const fixes = ladder.fixes ?? [];
265
+ const title = sourceFrontmatter.name || sourceBasename;
266
+
267
+ // Already broken down? Re-running mints fresh ids and would leave two sets of work
268
+ // for one investigation, with the on-deck queue and the Resolution row pointing at
269
+ // the newer half. Mirrors the research path's `RESEARCH_ALREADY_PROMOTED` guard.
270
+ const existingSpawn = getFrontmatterBlock(source.raw)?.block ?? '';
271
+ const alreadySpawned = ['spawned_epics', 'spawned_tasks'].flatMap((f) => {
272
+ const m = existingSpawn.match(new RegExp(`^${f}:[ \\t]*\\r?\\n((?:[ \\t]+-[^\\n]*\\r?\\n?)+)`, 'm'));
273
+ if (!m) return [];
274
+ return [...m[1].matchAll(/-[ \t]*["']?\[\[([^\]]+)\]\]["']?/g)].map((x) => x[1]);
275
+ });
276
+ if (alreadySpawned.length > 0 && args.force !== true) {
277
+ return {
278
+ error: 'DIAGNOSE_ALREADY_BROKEN_DOWN',
279
+ message: `${sourceBasename} has already been broken down into ${alreadySpawned.join(', ')}. Breaking it down again mints a second set of work for one investigation. Work the existing specs, or pass \`force: true\` if the first breakdown was wrong and has been cleaned up.`,
280
+ spawned_work: alreadySpawned,
281
+ };
282
+ }
283
+
284
+ const writes = [];
285
+ const spawned = { milestone: null, epics: [], tasks: [] };
286
+
287
+ const stageTasksUnder = (epicRecord, fixList) => {
288
+ const taskSpecs = fixList.map((fix) => {
289
+ const fileKey = mintKey();
290
+ const name = fix.replace(/[.]$/, '').slice(0, 80);
291
+ const basename = `HS-${fileKey}-${familyKey}-TASK-${deriveSlug(name)}`;
292
+ return {
293
+ id: `HS-${fileKey}`,
294
+ fileKey,
295
+ basename,
296
+ name,
297
+ fix,
298
+ absPath: join(vaultRoot, 'specs', 'tasks', `${basename}.md`),
299
+ relPath: join('specs', 'tasks', `${basename}.md`),
300
+ };
301
+ });
302
+ for (const t of taskSpecs) {
303
+ writes.push({
304
+ kind: 'create',
305
+ absPath: t.absPath,
306
+ content: buildTaskContent({
307
+ id: t.id,
308
+ fileKey: t.fileKey,
309
+ familyKey,
310
+ name: t.name,
311
+ parentBasename: epicRecord.basename,
312
+ sourceBasename,
313
+ summary: `One of the ${fixes.length} separately-verifiable fixes the bug write-up "${title}" lists. ${t.fix}`,
314
+ now,
315
+ today,
316
+ fix: t.fix,
317
+ }),
318
+ });
319
+ }
320
+ const mirror = stageParentAppend(epicRecord, 'children', taskSpecs.map((t) => t.basename), now);
321
+ if (mirror.error) return mirror;
322
+ if (mirror.write) writes.push(mirror.write);
323
+ spawned.tasks = taskSpecs.map((t) => ({ id: t.id, basename: t.basename, path: t.relPath }));
324
+ return {};
325
+ };
326
+
327
+ // ── tasks under a matching epic ───────────────────────────────────────────
328
+ if (placement.placement === 'tasks_under_epic') {
329
+ const epicRecord = readSpecFile(vaultRoot, ['specs', 'epics'], placement.attach_to);
330
+ if (!epicRecord) {
331
+ return { error: 'EPIC_NOT_FOUND', message: `${placement.attach_to} did not resolve under specs/epics/` };
332
+ }
333
+ // The target epic is not written here, but tasks placed under an epic that
334
+ // floats outside the roadmap are just as invisible as a stray epic would be.
335
+ const epicBlock = getFrontmatterBlock(epicRecord.raw)?.block ?? '';
336
+ const check = requireResolvableMilestone(vaultRoot, getScalarField(epicBlock, 'milestone'));
337
+ if (check.error) {
338
+ return {
339
+ ...check,
340
+ message: `target epic ${epicRecord.basename}: ${check.message}`,
341
+ };
342
+ }
343
+ const staged = stageTasksUnder(epicRecord, fixes);
344
+ if (staged.error) return staged;
345
+ }
346
+
347
+ // ── one new epic under a matching milestone ──────────────────────────────
348
+ else if (placement.placement === 'epic_under_milestone') {
349
+ const check = requireResolvableMilestone(vaultRoot, placement.attach_to);
350
+ if (check.error) return check;
351
+ const milestoneRecord = check.record;
352
+ const milestoneBlock = getFrontmatterBlock(milestoneRecord.raw)?.block ?? '';
353
+ const pillarRef = args.pillar ?? getScalarField(milestoneBlock, 'pillar');
354
+ const pillar = resolvePillar(vaultRoot, pillarRef);
355
+ if (!pillar.ok) {
356
+ return {
357
+ error: 'pillar_required',
358
+ message: pillarRef
359
+ ? `pillar \`${pillarRef}\` does not resolve under specs/pillars/ — pass a resolvable \`pillar\``
360
+ : `milestone ${milestoneRecord.basename} declares no \`pillar:\` — pass one as \`pillar\``,
361
+ existing_pillars: listVaultPillars(vaultRoot),
362
+ };
363
+ }
364
+ const fileKey = mintKey();
365
+ const name = title;
366
+ const basename = `HS-${fileKey}-${familyKey}-EPIC-${deriveSlug(name)}`;
367
+ writes.push({
368
+ kind: 'create',
369
+ absPath: join(vaultRoot, 'specs', 'epics', `${basename}.md`),
370
+ content: buildEpicContent({
371
+ id: `HS-${fileKey}`,
372
+ fileKey,
373
+ familyKey,
374
+ name,
375
+ milestoneBasename: milestoneRecord.basename,
376
+ sourceBasename,
377
+ pillarValue: pillar.frontmatterValue,
378
+ summary: `The ${fixes.length} separately-verifiable fixes the bug write-up "${title}" lists, gathered as one epic under an existing milestone.`,
379
+ fixes,
380
+ now,
381
+ today,
382
+ }),
383
+ });
384
+ const mirror = stageParentAppend(milestoneRecord, 'epics', [basename], now);
385
+ if (mirror.error) return mirror;
386
+ if (mirror.write) writes.push(mirror.write);
387
+ spawned.epics = [{ id: `HS-${fileKey}`, basename, path: join('specs', 'epics', `${basename}.md`) }];
388
+ }
389
+
390
+ // ── a new milestone (owner-confirmed) with one epic under it ─────────────
391
+ else if (placement.placement === 'new_milestone') {
392
+ if (args.confirm_new_milestone !== true) {
393
+ return {
394
+ error: 'OWNER_CONFIRMATION_REQUIRED',
395
+ message:
396
+ 'Minting a milestone is the last rung and asks the owner first. Nothing was written. Re-run with `confirm_new_milestone: true` once the owner has agreed to the proposed milestone.',
397
+ proposed_milestone: title,
398
+ };
399
+ }
400
+ const pillar = resolvePillar(vaultRoot, args.pillar);
401
+ if (!pillar.ok) {
402
+ return {
403
+ error: 'pillar_required',
404
+ message:
405
+ 'A new milestone mints a new epic under it, and every epic requires a resolvable `pillar:` — pass one.',
406
+ existing_pillars: listVaultPillars(vaultRoot),
407
+ };
408
+ }
409
+ const milestoneKey = mintKey();
410
+ const milestoneBasename = `HS-${milestoneKey}-${familyKey}-MIL-${deriveSlug(title)}`;
411
+ writes.push({
412
+ kind: 'create',
413
+ absPath: join(vaultRoot, 'specs', 'milestones', `${milestoneBasename}.md`),
414
+ content: buildMilestoneContent({
415
+ id: `HS-${milestoneKey}`,
416
+ fileKey: milestoneKey,
417
+ familyKey,
418
+ name: title,
419
+ sourceBasename,
420
+ pillarValue: pillar.frontmatterValue,
421
+ summary: `Minted from the bug write-up "${title}" after grounding found no open milestone or epic covering this area.`,
422
+ now,
423
+ today,
424
+ }),
425
+ });
426
+
427
+ const epicKey = mintKey();
428
+ const epicBasename = `HS-${epicKey}-${familyKey}-EPIC-${deriveSlug(title)}`;
429
+ writes.push({
430
+ kind: 'create',
431
+ absPath: join(vaultRoot, 'specs', 'epics', `${epicBasename}.md`),
432
+ content: buildEpicContent({
433
+ id: `HS-${epicKey}`,
434
+ fileKey: epicKey,
435
+ familyKey,
436
+ name: title,
437
+ milestoneBasename,
438
+ sourceBasename,
439
+ pillarValue: pillar.frontmatterValue,
440
+ summary: `The ${fixes.length} separately-verifiable fixes the bug write-up "${title}" lists.`,
441
+ fixes,
442
+ now,
443
+ today,
444
+ }),
445
+ });
446
+ spawned.milestone = { id: `HS-${milestoneKey}`, basename: milestoneBasename };
447
+ spawned.epics = [{ id: `HS-${epicKey}`, basename: epicBasename, path: join('specs', 'epics', `${epicBasename}.md`) }];
448
+ // The milestone is created with `epics: []` and mirrored in the same commit, so
449
+ // the pair is never observable half-linked.
450
+ const pending = { ...writes[0] };
451
+ const mirrored = stageParentAppend(
452
+ { basename: milestoneBasename, absPath: pending.absPath, raw: pending.content },
453
+ 'epics',
454
+ [epicBasename],
455
+ now,
456
+ );
457
+ if (mirrored.error) return mirrored;
458
+ if (mirrored.write) writes[0] = { kind: 'create', absPath: pending.absPath, content: mirrored.write.content };
459
+ }
460
+
461
+ // ── measure first ────────────────────────────────────────────────────────
462
+ else if (placement.placement === 'measure_first') {
463
+ const epicRef = args.measurement_parent_epic ?? placement.attach_to;
464
+ const epicRecord = epicRef ? readSpecFile(vaultRoot, ['specs', 'epics'], epicRef) : null;
465
+ if (!epicRecord) {
466
+ return {
467
+ error: 'MEASUREMENT_NEEDS_PARENT',
468
+ message:
469
+ 'The write-up concludes the cause is not yet known, so the only thing filed is the measurement — but a task needs an epic to live under and grounding found no epic covering this area. Name one with `measurement_parent_epic`. No epic and no milestone is minted on this path.',
470
+ };
471
+ }
472
+ const measurement = `Measure what is actually happening in "${title}" before any fix is designed`;
473
+ const staged = stageTasksUnder(epicRecord, [measurement]);
474
+ if (staged.error) return staged;
475
+ } else {
476
+ return { error: 'UNKNOWN_PLACEMENT', message: `placement ${placement.placement} is not a shape this stage writes` };
477
+ }
478
+
479
+ // ── The write-up itself: LINKS to what it spawned, and a change-log row. ──
480
+ //
481
+ // `spawned_epics:` / `spawned_tasks:` mirror the shape the research path already
482
+ // uses (`research.spawned_epics` ⇔ `epic.derived_from`), so the console's pipeline
483
+ // graph and the vault validator already understand them. What is NOT written, in
484
+ // any code path, is the diagnose's `parent:` — that field keeps its existing job of
485
+ // grouping several small bugs under one epic, and an epic parented to a bug report
486
+ // would not render on the roadmap at all.
487
+ //
488
+ // `appendToListField` is idempotent per entry, so a re-run of the same breakdown
489
+ // extends the list rather than duplicating what is already in it.
490
+ const sourceFm = getFrontmatterBlock(source.raw);
491
+ if (sourceFm) {
492
+ const spawnedNames = [
493
+ ...(spawned.milestone ? [spawned.milestone.basename] : []),
494
+ ...spawned.epics.map((e) => e.basename),
495
+ ...spawned.tasks.map((t) => t.basename),
496
+ ];
497
+ let block = sourceFm.block;
498
+ for (const e of spawned.epics) block = appendToListField(block, 'spawned_epics', e.basename).block;
499
+ for (const t of spawned.tasks) block = appendToListField(block, 'spawned_tasks', t.basename).block;
500
+ block = bumpUpdated(block, now);
501
+ const body = appendChangeLogRow(
502
+ sourceFm.body,
503
+ today,
504
+ 'promote-diagnose (agent)',
505
+ `Broken down (${ladder.rung}, ${ladder.fix_count} fix(es)): spawned ${spawnedNames.map((n) => `[[${n}]]`).join(', ')}. Links, not parenting — this write-up is the source, never the parent, and it closes on its own reproduction.`,
506
+ );
507
+ writes.push({
508
+ kind: 'update',
509
+ absPath: source.absPath,
510
+ content: `${sourceFm.open}${block}${sourceFm.close}${body}`,
511
+ });
512
+ }
513
+
514
+ const commit = (deps.commitWrites ?? commitWrites)(writes);
515
+ if (!commit.ok) {
516
+ return {
517
+ error: 'WRITE_FAILED',
518
+ message: commit.error?.message ?? 'unknown write failure',
519
+ rollback_error: commit.error?.rollbackError,
520
+ };
521
+ }
522
+
523
+ return { spawned, writes: writes.map((w) => ({ kind: w.kind, path: w.absPath })) };
524
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Brief-synthesis observability ([[HS-TASK-68NO]] / [[HS-EPIC-DXBR]] §8).
3
+ *
4
+ * Records which synthesis tier each derived execution brief was served at and
5
+ * exposes the **degraded-mode rate** — the share of briefs served below the
6
+ * 'derived' tier ('minimal' + 'skip'). A rising degraded rate is the early
7
+ * warning that specs are thinning or the parser regressed: degradation becomes
8
+ * visible instead of failing quietly.
9
+ *
10
+ * In-memory counters (process-lifetime) are the queryable signal; each synthesis
11
+ * also emits one structured log line so the tier + running rate are observable
12
+ * in the MCP host's stderr stream. The classifier itself lives in
13
+ * alignment-gate.mjs (`classifyBriefTier`) and stays pure; this module owns the
14
+ * stateful counter + log formatting.
15
+ */
16
+
17
+ import { DEGRADED_BRIEF_TIERS } from './alignment-gate.mjs';
18
+
19
+ const KNOWN_TIERS = ['override', 'derived', 'minimal', 'skip'];
20
+
21
+ function emptyCounts() {
22
+ const c = {};
23
+ for (const t of KNOWN_TIERS) c[t] = 0;
24
+ return c;
25
+ }
26
+
27
+ // Module-level counters — reset only on process restart (or explicitly in tests).
28
+ const counts = emptyCounts();
29
+
30
+ /** Increment the counter for `tier`. Unknown tiers are ignored (defensive). */
31
+ export function recordBriefTier(tier) {
32
+ if (Object.prototype.hasOwnProperty.call(counts, tier)) {
33
+ counts[tier] += 1;
34
+ }
35
+ return getBriefTierStats();
36
+ }
37
+
38
+ /**
39
+ * `{ counts, total, degraded, degradedRate }`. `degradedRate` is the share of
40
+ * briefs served below 'derived' (0 when nothing recorded yet). Pure read.
41
+ */
42
+ export function getBriefTierStats() {
43
+ const total = KNOWN_TIERS.reduce((n, t) => n + counts[t], 0);
44
+ const degraded = DEGRADED_BRIEF_TIERS.reduce((n, t) => n + (counts[t] || 0), 0);
45
+ return {
46
+ counts: { ...counts },
47
+ total,
48
+ degraded,
49
+ degradedRate: total > 0 ? degraded / total : 0,
50
+ };
51
+ }
52
+
53
+ /** Test-only: zero the counters so suites stay hermetic. */
54
+ export function resetBriefTierStats() {
55
+ for (const t of KNOWN_TIERS) counts[t] = 0;
56
+ }
57
+
58
+ /**
59
+ * Pure formatter for the per-synthesis structured log line. Includes the tier,
60
+ * spec id, and the running degraded-mode rate + sample size so the signal is
61
+ * visible inline in the log stream (not just via getBriefTierStats).
62
+ */
63
+ export function formatBriefTierLog({ tier, id, stats }) {
64
+ const s = stats || getBriefTierStats();
65
+ const rate = s.degradedRate.toFixed(3);
66
+ return `[heyspex-mcp brief] tier=${tier} id=${id || 'unknown'} degraded_rate=${rate} n=${s.total}`;
67
+ }