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