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,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `heyspex_promote_diagnose` — the breakdown stage, entered from a bug write-up.
|
|
3
|
+
*
|
|
4
|
+
* [[HS-ALBV-BFIX-EPIC-breakdown-stage-accepts-a-diagnose]]: "Breakdown accepts a
|
|
5
|
+
* research doc or a bug write-up. Not a second parallel tool." So this module is
|
|
6
|
+
* deliberately thin — it is the diagnose-shaped FRONT DOOR onto machinery that is
|
|
7
|
+
* shared with the research path:
|
|
8
|
+
*
|
|
9
|
+
* - finding + identifying the source → `breakdown-source.mjs` ([[HS-K1TR]])
|
|
10
|
+
* - grounding + the fix-count ladder → `breakdown-ladder.mjs` ([[HS-EYJ3]])
|
|
11
|
+
* - placement + the writes → this module + `promotion-helpers.mjs` ([[HS-FLGU]])
|
|
12
|
+
* - links, never parenting → ([[HS-ZTZG]])
|
|
13
|
+
*
|
|
14
|
+
* The one thing NOT shared with the research path is the sizing input: a research doc
|
|
15
|
+
* is sized by an AI stub proposer, a bug write-up is sized by counting the
|
|
16
|
+
* separately-verifiable fixes its own `## Fix` section already lists. "AI deciding
|
|
17
|
+
* the size" is explicitly out of scope for the epic — the count is authored by
|
|
18
|
+
* whoever ran the investigation.
|
|
19
|
+
*
|
|
20
|
+
* ORDER MATTERS. Grounding runs BEFORE sizing, and its result is reported in both
|
|
21
|
+
* phases. Sizing first is what produced seven near-miss fixes next to a milestone
|
|
22
|
+
* and an epic that had been sitting open the whole time
|
|
23
|
+
* ([[HS-WZ62-JJIP-DIAG-console-loads-the-whole-vault-as-one-5]]).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { readSourceFrontmatter, resolveBreakdownSource } from './breakdown-source.mjs';
|
|
27
|
+
import { applyLadder, causeIsKnown, scanExistingCoverage } from './breakdown-ladder.mjs';
|
|
28
|
+
import { executeBreakdownWrites } from './breakdown-writer.mjs';
|
|
29
|
+
|
|
30
|
+
/** Rungs that produce no work at all — the stage reports and writes nothing. */
|
|
31
|
+
const NO_SPAWN_RUNGS = new Set(['no_breakdown', 'no_fixes_listed']);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Render the "attach, don't mint" recommendation from the coverage scan.
|
|
35
|
+
*
|
|
36
|
+
* Placement goes DOWN, never up (epic §Scope): a matching epic gets tasks, a matching
|
|
37
|
+
* milestone with no fitting epic gets a new epic, and only when neither exists is a
|
|
38
|
+
* milestone proposed — which asks the owner first.
|
|
39
|
+
*/
|
|
40
|
+
export function recommendPlacement(coverage) {
|
|
41
|
+
const epic = coverage?.epics?.[0];
|
|
42
|
+
const milestone = coverage?.milestones?.[0];
|
|
43
|
+
if (epic) {
|
|
44
|
+
return {
|
|
45
|
+
placement: 'tasks_under_epic',
|
|
46
|
+
attach_to: epic.basename,
|
|
47
|
+
recommendation: `Attach to the existing open epic [[${epic.basename}]] (matched on ${epic.matched_terms.join(', ')}) — file the fixes as TASKS under it. Do not mint a new epic or milestone.`,
|
|
48
|
+
owner_confirmation_required: false,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
if (milestone) {
|
|
52
|
+
return {
|
|
53
|
+
placement: 'epic_under_milestone',
|
|
54
|
+
attach_to: milestone.basename,
|
|
55
|
+
recommendation: `Attach to the existing open milestone [[${milestone.basename}]] (matched on ${milestone.matched_terms.join(', ')}) — file ONE new epic under it. Do not mint a new milestone.`,
|
|
56
|
+
owner_confirmation_required: false,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
placement: 'new_milestone',
|
|
61
|
+
attach_to: null,
|
|
62
|
+
recommendation:
|
|
63
|
+
'No open milestone or epic covers this area, so a new milestone is proposed. This is the only rung that mints one, and the owner confirms before anything is filed.',
|
|
64
|
+
owner_confirmation_required: true,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @param {{ vaultRoot: string, args?: Record<string, unknown>, deps?: Record<string, unknown> }} ctx
|
|
70
|
+
*/
|
|
71
|
+
export async function executePromoteDiagnose({ vaultRoot, args = {}, deps = {} }) {
|
|
72
|
+
const ref = args.diagnose_id ?? args.source_id ?? args.spec_id;
|
|
73
|
+
const resolved = resolveBreakdownSource(vaultRoot, ref, { kinds: ['diagnose'] });
|
|
74
|
+
if (resolved.error) {
|
|
75
|
+
// `INVALID_ARGS` from the shared resolver names no field — say which one.
|
|
76
|
+
if (resolved.error === 'INVALID_ARGS') {
|
|
77
|
+
return { error: 'INVALID_ARGS', message: 'diagnose_id is required' };
|
|
78
|
+
}
|
|
79
|
+
return resolved;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const source = resolved.record;
|
|
83
|
+
const frontmatter = readSourceFrontmatter(source);
|
|
84
|
+
|
|
85
|
+
// ── Step one: GROUND. What already covers this area? ──────────────────────
|
|
86
|
+
const coverage = scanExistingCoverage(vaultRoot, source);
|
|
87
|
+
|
|
88
|
+
// ── Step two: SIZE. Count the fixes the write-up itself lists. ────────────
|
|
89
|
+
const ladder = applyLadder({
|
|
90
|
+
raw: source.raw,
|
|
91
|
+
coverage,
|
|
92
|
+
causeKnown: causeIsKnown(source.raw),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const base = {
|
|
96
|
+
source_kind: source.kind,
|
|
97
|
+
source_id: frontmatter.id,
|
|
98
|
+
source_basename: source.basename,
|
|
99
|
+
source_path: source.relPath,
|
|
100
|
+
source_title: frontmatter.name,
|
|
101
|
+
source_status: frontmatter.status,
|
|
102
|
+
existing_coverage: coverage,
|
|
103
|
+
ladder,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
if (NO_SPAWN_RUNGS.has(ladder.rung)) {
|
|
107
|
+
return {
|
|
108
|
+
...base,
|
|
109
|
+
phase: 'no_breakdown',
|
|
110
|
+
spawns: [],
|
|
111
|
+
human_review_required: false,
|
|
112
|
+
message: ladder.reason,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (ladder.rung === 'measure_first') {
|
|
117
|
+
// The hold still gets grounding: the measurement task needs an epic to live
|
|
118
|
+
// under, and the coverage scan is where that epic comes from.
|
|
119
|
+
const measurePlacement = {
|
|
120
|
+
placement: 'measure_first',
|
|
121
|
+
attach_to: coverage.epics?.[0]?.basename ?? null,
|
|
122
|
+
recommendation: ladder.reason,
|
|
123
|
+
owner_confirmation_required: false,
|
|
124
|
+
};
|
|
125
|
+
if (args.accepted !== true) {
|
|
126
|
+
return {
|
|
127
|
+
...base,
|
|
128
|
+
phase: 'measure_first',
|
|
129
|
+
placement: measurePlacement,
|
|
130
|
+
human_review_required: true,
|
|
131
|
+
message: ladder.reason,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
const measured = executeBreakdownWrites({
|
|
135
|
+
vaultRoot,
|
|
136
|
+
source,
|
|
137
|
+
sourceFrontmatter: frontmatter,
|
|
138
|
+
ladder,
|
|
139
|
+
placement: measurePlacement,
|
|
140
|
+
args,
|
|
141
|
+
deps,
|
|
142
|
+
});
|
|
143
|
+
if (measured.error) return { ...base, placement: measurePlacement, ...measured };
|
|
144
|
+
return {
|
|
145
|
+
...base,
|
|
146
|
+
phase: 'executed',
|
|
147
|
+
placement: measurePlacement,
|
|
148
|
+
spawns: measured.spawned,
|
|
149
|
+
writes: measured.writes,
|
|
150
|
+
human_review_required: false,
|
|
151
|
+
message: ladder.reason,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const placement = recommendPlacement(coverage);
|
|
156
|
+
|
|
157
|
+
if (args.accepted !== true) {
|
|
158
|
+
return {
|
|
159
|
+
...base,
|
|
160
|
+
phase: 'review',
|
|
161
|
+
placement,
|
|
162
|
+
human_review_required: true,
|
|
163
|
+
next_call_hint:
|
|
164
|
+
'Review the existing coverage and the ladder result above, then call again with `accepted: true` (plus `confirm_new_milestone: true` when the placement is new_milestone) to execute the writes.',
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const written = executeBreakdownWrites({
|
|
169
|
+
vaultRoot,
|
|
170
|
+
source,
|
|
171
|
+
sourceFrontmatter: frontmatter,
|
|
172
|
+
ladder,
|
|
173
|
+
placement,
|
|
174
|
+
args,
|
|
175
|
+
deps,
|
|
176
|
+
});
|
|
177
|
+
if (written.error) return { ...base, placement, ...written };
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
...base,
|
|
181
|
+
phase: 'executed',
|
|
182
|
+
placement,
|
|
183
|
+
spawns: written.spawned,
|
|
184
|
+
writes: written.writes,
|
|
185
|
+
human_review_required: false,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `heyspex_promote_research_to_diagnose` MCP tool — research → diagnose
|
|
3
|
+
* intake edge ([[HS-MSHY-TASK-research-diagnose-link-send-bugs-to-the]] of
|
|
4
|
+
* [[HS-MSHY-EPIC-research-to-diagnose-link-send-bugs-foun]]).
|
|
5
|
+
*
|
|
6
|
+
* Symmetric to `heyspex_promote_capture_to_diagnose` (promote-capture.mjs)
|
|
7
|
+
* but sourced from a research investigation instead of a bug-report capture:
|
|
8
|
+
* a research doc that surfaced a bug is promoted into a new first-class
|
|
9
|
+
* `HS-DIAG-*` diagnose spec (specs/diagnoses/) — reusing the existing
|
|
10
|
+
* diagnose machinery, no new infrastructure. Single-phase (no AI outline):
|
|
11
|
+
* the research's `## Executive Summary` (falling back to the research
|
|
12
|
+
* `name:`) seeds the diagnose `## Symptom`; Reproduction/Root cause/Fix/
|
|
13
|
+
* Acceptance criteria are fill-in skeletons, same shape as the capture path.
|
|
14
|
+
*
|
|
15
|
+
* Bidirectional linkage (mirrors `spawned_epics` ⇔ `epic.derived_from` per
|
|
16
|
+
* [[entity-model]] §Provenance edges):
|
|
17
|
+
* research.produces_diagnose : appended with the new diagnose wikilink (1:N)
|
|
18
|
+
* diagnose.derived_from : set to the source research wikilink (single)
|
|
19
|
+
*
|
|
20
|
+
* Duplicate-promotion guard: unlike a capture (which is single-shot and
|
|
21
|
+
* discarded after use), a research doc is evergreen and may surface several
|
|
22
|
+
* *different* bugs over its life — so the guard is keyed on (research_id,
|
|
23
|
+
* diagnose name/slug), not on "has this research ever produced a diagnose."
|
|
24
|
+
* Re-running with the same research_id + name that already produced a
|
|
25
|
+
* diagnose from this exact research is rejected as
|
|
26
|
+
* `DIAGNOSE_ALREADY_PROMOTED` (idempotent no-op — no new file written),
|
|
27
|
+
* matching the "no duplicate epic spawn" spirit of
|
|
28
|
+
* `RESEARCH_ALREADY_PROMOTED` in promote-research.mjs.
|
|
29
|
+
*
|
|
30
|
+
* All writes commit transactionally via the shared `commitWrites` helper
|
|
31
|
+
* from `./promotion-helpers.mjs`; any failure rolls back.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { readFileSync, existsSync, readdirSync } from 'node:fs';
|
|
35
|
+
import { join } from 'node:path';
|
|
36
|
+
import { allocateRandomVaultId } from '../../scripts/vault-id-lib.mjs';
|
|
37
|
+
import { familyOf, mintFamilyKey, mintFileKey, parseSpecId } from './spec-id-lib.mjs';
|
|
38
|
+
import { yamlScalar } from './yaml-scalar.mjs';
|
|
39
|
+
import { buildDiagnoseSkeletonSections } from './diagnose-skeleton.mjs';
|
|
40
|
+
import {
|
|
41
|
+
isoNow,
|
|
42
|
+
deriveSlug,
|
|
43
|
+
findFileByPrefix,
|
|
44
|
+
getFrontmatterBlock,
|
|
45
|
+
getScalarField,
|
|
46
|
+
parseListFieldBasenames,
|
|
47
|
+
appendToListField,
|
|
48
|
+
bumpUpdated,
|
|
49
|
+
appendChangeLogRow,
|
|
50
|
+
commitWrites,
|
|
51
|
+
} from './promotion-helpers.mjs';
|
|
52
|
+
|
|
53
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
54
|
+
// Dual-read id classification ([[HS-MIL-KY1F]] family-first migration) —
|
|
55
|
+
// mirrors promote-capture.mjs / split-research.mjs; each promotion module
|
|
56
|
+
// keeps its own small local copy rather than sharing across modules.
|
|
57
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
function hasTypeLabel(id, label) {
|
|
60
|
+
const p = parseSpecId(id);
|
|
61
|
+
return !!p && p.typeLabel === label;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const isResearchId = (id) => hasTypeLabel(id, 'RES');
|
|
65
|
+
|
|
66
|
+
function canonicalIdOfType(value, label) {
|
|
67
|
+
const p = parseSpecId(value);
|
|
68
|
+
if (!p || p.typeLabel !== label) return null;
|
|
69
|
+
return `HS-${p.familyKey}-${p.typeLabel}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
73
|
+
// File loaders
|
|
74
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
function findResearchByIdLocal(vaultRoot, researchId) {
|
|
77
|
+
const dir = join(vaultRoot, 'specs', 'research');
|
|
78
|
+
if (!existsSync(dir)) return null;
|
|
79
|
+
const direct = findFileByPrefix(dir, researchId);
|
|
80
|
+
const tryFile = (file) => {
|
|
81
|
+
const basename = file.replace(/\.md$/, '');
|
|
82
|
+
const absPath = join(dir, file);
|
|
83
|
+
return {
|
|
84
|
+
basename,
|
|
85
|
+
absPath,
|
|
86
|
+
relPath: join('specs', 'research', file),
|
|
87
|
+
raw: readFileSync(absPath, 'utf8'),
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
if (direct) return tryFile(direct);
|
|
91
|
+
for (const file of readdirSync(dir)) {
|
|
92
|
+
if (!file.endsWith('.md')) continue;
|
|
93
|
+
const raw = readFileSync(join(dir, file), 'utf8');
|
|
94
|
+
const idMatch = raw.match(/^id:\s*(\S+)/m);
|
|
95
|
+
if (idMatch) {
|
|
96
|
+
const id = idMatch[1].replace(/^['"]|['"]$/g, '');
|
|
97
|
+
if (id === researchId) return tryFile(file);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function findDiagnoseByBasename(vaultRoot, basename) {
|
|
104
|
+
const absPath = join(vaultRoot, 'specs', 'diagnoses', `${basename}.md`);
|
|
105
|
+
if (!existsSync(absPath)) return null;
|
|
106
|
+
return {
|
|
107
|
+
basename,
|
|
108
|
+
absPath,
|
|
109
|
+
relPath: join('specs', 'diagnoses', `${basename}.md`),
|
|
110
|
+
raw: readFileSync(absPath, 'utf8'),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function collectOccupiedDiagnoseIds(vaultRoot) {
|
|
115
|
+
const occupied = new Set();
|
|
116
|
+
const dir = join(vaultRoot, 'specs', 'diagnoses');
|
|
117
|
+
if (!existsSync(dir)) return occupied;
|
|
118
|
+
for (const file of readdirSync(dir)) {
|
|
119
|
+
if (!file.endsWith('.md')) continue;
|
|
120
|
+
const fromBasename = canonicalIdOfType(file.replace(/\.md$/, ''), 'DIAG');
|
|
121
|
+
if (fromBasename) occupied.add(fromBasename);
|
|
122
|
+
try {
|
|
123
|
+
const raw = readFileSync(join(dir, file), 'utf8');
|
|
124
|
+
const idMatch = raw.match(/^id:\s*(\S+)/m);
|
|
125
|
+
if (idMatch) {
|
|
126
|
+
const id = idMatch[1].replace(/^['"]|['"]$/g, '');
|
|
127
|
+
const fromId = canonicalIdOfType(id, 'DIAG');
|
|
128
|
+
if (fromId) occupied.add(fromId);
|
|
129
|
+
}
|
|
130
|
+
} catch {
|
|
131
|
+
/* skip */
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return occupied;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Pull the `## Executive Summary` section from a research body as the bug symptom. */
|
|
138
|
+
function extractResearchExecutiveSummary(raw) {
|
|
139
|
+
const fm = getFrontmatterBlock(raw);
|
|
140
|
+
const body = fm ? fm.body : raw;
|
|
141
|
+
const m = body.match(/##\s*Executive Summary\s*\n+([\s\S]*?)(?=\n#{1,6}\s|$)/i);
|
|
142
|
+
if (!m) return '';
|
|
143
|
+
return m[1].trim();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const VALID_DIAGNOSE_SEVERITIES = new Set(['error', 'warning', 'info']);
|
|
147
|
+
|
|
148
|
+
function buildDiagnoseContent(diagnoseId, fileKey, familyKey, name, severity, researchBasename, symptomSeed, now) {
|
|
149
|
+
const today = now.slice(0, 10);
|
|
150
|
+
const symptom = symptomSeed
|
|
151
|
+
? symptomSeed
|
|
152
|
+
: '_Describe the observed bug (promoted from the source research below)._';
|
|
153
|
+
return `---
|
|
154
|
+
id: ${diagnoseId}
|
|
155
|
+
file_id: ${fileKey}
|
|
156
|
+
family: ${familyKey}
|
|
157
|
+
name: ${yamlScalar(name)}
|
|
158
|
+
type: diagnose
|
|
159
|
+
status: open
|
|
160
|
+
severity: ${severity}
|
|
161
|
+
owner: pwher
|
|
162
|
+
created: "${now}"
|
|
163
|
+
updated: "${now}"
|
|
164
|
+
derived_from: "[[${researchBasename}]]"
|
|
165
|
+
tags:
|
|
166
|
+
- "bug-fix"
|
|
167
|
+
related:
|
|
168
|
+
- "[[${researchBasename}]]"
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
# ${name}
|
|
172
|
+
|
|
173
|
+
## Symptom
|
|
174
|
+
|
|
175
|
+
${symptom}
|
|
176
|
+
|
|
177
|
+
## Reproduction
|
|
178
|
+
|
|
179
|
+
_TBD — steps to reproduce._
|
|
180
|
+
|
|
181
|
+
${buildDiagnoseSkeletonSections()}
|
|
182
|
+
|
|
183
|
+
## Change log
|
|
184
|
+
|
|
185
|
+
| Date | Author | Change |
|
|
186
|
+
| --- | --- | --- |
|
|
187
|
+
| ${today} | promote-research-to-diagnose (agent) | Created from research [[${researchBasename}]] via heyspex_promote_research_to_diagnose. |
|
|
188
|
+
`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Research mutation: append produces_diagnose (idempotent) + change-log row. */
|
|
192
|
+
function buildUpdatedResearchContent(raw, params) {
|
|
193
|
+
const fm = getFrontmatterBlock(raw);
|
|
194
|
+
if (!fm) {
|
|
195
|
+
throw new Error('research has no frontmatter — cannot link produces_diagnose');
|
|
196
|
+
}
|
|
197
|
+
let block = fm.block;
|
|
198
|
+
const result = appendToListField(block, 'produces_diagnose', params.diagnoseBasename);
|
|
199
|
+
if (!result.changed) return { content: raw, changed: false };
|
|
200
|
+
block = result.block;
|
|
201
|
+
block = bumpUpdated(block, params.now);
|
|
202
|
+
const change = `Promoted to diagnose [[${params.diagnoseBasename}]] via heyspex_promote_research_to_diagnose.`;
|
|
203
|
+
const newBody = appendChangeLogRow(fm.body, params.today, params.author, change);
|
|
204
|
+
return { content: `${fm.open}${block}${fm.close}${newBody}`, changed: true };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Promote a research investigation into a new `HS-DIAG-*` diagnose spec.
|
|
209
|
+
* Single-phase (no AI outline): the research's Executive Summary seeds the
|
|
210
|
+
* Symptom; the rest is a fill-in skeleton. Bidirectional linkage:
|
|
211
|
+
* research.produces_diagnose ⇔ diagnose.derived_from.
|
|
212
|
+
*/
|
|
213
|
+
export async function executePromoteResearchToDiagnose(vaultRoot, args = {}, deps = {}) {
|
|
214
|
+
const researchId = args.research_id;
|
|
215
|
+
if (typeof researchId !== 'string' || !isResearchId(researchId)) {
|
|
216
|
+
return { error: 'INVALID_RESEARCH_ID', message: 'research_id must be a research id (HS-<key>-RES, or legacy HS-RES-<key>)' };
|
|
217
|
+
}
|
|
218
|
+
const severityRaw = typeof args.severity === 'string' ? args.severity.trim().toLowerCase() : 'warning';
|
|
219
|
+
const severity = VALID_DIAGNOSE_SEVERITIES.has(severityRaw) ? severityRaw : 'warning';
|
|
220
|
+
|
|
221
|
+
const research = findResearchByIdLocal(vaultRoot, researchId);
|
|
222
|
+
if (!research) {
|
|
223
|
+
return { error: 'RESEARCH_NOT_FOUND', message: `research ${researchId} not found under specs/research/` };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const researchFm = getFrontmatterBlock(research.raw);
|
|
227
|
+
if (!researchFm) {
|
|
228
|
+
return { error: 'RESEARCH_FRONTMATTER_MISSING', message: 'research has no frontmatter' };
|
|
229
|
+
}
|
|
230
|
+
const researchName = getScalarField(researchFm.block, 'name') ?? '';
|
|
231
|
+
const name = (typeof args.name === 'string' && args.name.trim())
|
|
232
|
+
? args.name.trim()
|
|
233
|
+
: (researchName || `Bug from ${researchId}`);
|
|
234
|
+
const slug = deriveSlug(name);
|
|
235
|
+
|
|
236
|
+
// Duplicate-promotion guard: same research + same diagnose name/slug that
|
|
237
|
+
// was already produced from THIS research is rejected, not re-created.
|
|
238
|
+
// (A research doc may legitimately spawn several *different* diagnoses
|
|
239
|
+
// over its evergreen life — the guard is per-name, not "any diagnose ever".)
|
|
240
|
+
const existingProduced = parseListFieldBasenames(researchFm.block, 'produces_diagnose') ?? [];
|
|
241
|
+
for (const diagBasename of existingProduced) {
|
|
242
|
+
if (!diagBasename.endsWith(`-${slug}`)) continue;
|
|
243
|
+
const existingDiagnose = findDiagnoseByBasename(vaultRoot, diagBasename);
|
|
244
|
+
if (!existingDiagnose) continue;
|
|
245
|
+
const existingDiagnoseFm = getFrontmatterBlock(existingDiagnose.raw);
|
|
246
|
+
const derivedFrom = existingDiagnoseFm ? getScalarField(existingDiagnoseFm.block, 'derived_from') : null;
|
|
247
|
+
const derivedFromBasename = derivedFrom ? derivedFrom.replace(/^\[\[|\]\]$/g, '') : null;
|
|
248
|
+
if (derivedFromBasename === research.basename) {
|
|
249
|
+
return {
|
|
250
|
+
error: 'DIAGNOSE_ALREADY_PROMOTED',
|
|
251
|
+
message: `research ${researchId} already produced [[${diagBasename}]] for a diagnose named "${name}" — re-running would create a duplicate. Use a different name for a genuinely new bug, or edit the existing diagnose directly.`,
|
|
252
|
+
existing_diagnose_basename: diagBasename,
|
|
253
|
+
existing_diagnose_path: existingDiagnose.relPath,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const now = (deps?.clock ?? isoNow)();
|
|
259
|
+
const today = now.slice(0, 10);
|
|
260
|
+
const symptomSeed = extractResearchExecutiveSummary(research.raw);
|
|
261
|
+
|
|
262
|
+
// Era-3 family inheritance ([[naming-v3-7-rulings-hs-namespace-kept]]): the
|
|
263
|
+
// diagnose inherits the SOURCE RESEARCH DOC's family (familyOf: explicit
|
|
264
|
+
// `family:` frontmatter wins, else positional from id/basename). The legacy
|
|
265
|
+
// allocator's `HS-<family>-DIAG` draw is only the defensive fallback family
|
|
266
|
+
// source; `id:` keys onto the file key alone.
|
|
267
|
+
const researchFmId = getScalarField(researchFm.block, 'id')?.replace(/^['"]|['"]$/g, '') ?? null;
|
|
268
|
+
const researchFamilyValue = getScalarField(researchFm.block, 'family')?.replace(/^['"]|['"]$/g, '') ?? null;
|
|
269
|
+
const familyKey =
|
|
270
|
+
familyOf({
|
|
271
|
+
id: researchFmId,
|
|
272
|
+
basename: research.basename,
|
|
273
|
+
frontmatter: { family: researchFamilyValue },
|
|
274
|
+
}) ??
|
|
275
|
+
parseSpecId(
|
|
276
|
+
allocateRandomVaultId('diagnose', collectOccupiedDiagnoseIds(vaultRoot), { randomUnit: deps?.randomUnit }),
|
|
277
|
+
)?.familyKey ??
|
|
278
|
+
mintFamilyKey(vaultRoot, { randomUnit: deps?.randomUnit });
|
|
279
|
+
// Per-file FILE KEY (owner ruling 2026-07-10) — filename + `file_id:` SSOT,
|
|
280
|
+
// and (V3) the anchor for `id:` itself.
|
|
281
|
+
const fileKey = mintFileKey(vaultRoot, { randomUnit: deps?.randomUnit });
|
|
282
|
+
const diagnoseId = `HS-${fileKey}`;
|
|
283
|
+
const diagnoseBasename = `${diagnoseId}-${familyKey}-DIAG-${slug}`;
|
|
284
|
+
const diagnoseRelPath = `specs/diagnoses/${diagnoseBasename}.md`;
|
|
285
|
+
const diagnoseAbsPath = join(vaultRoot, 'specs', 'diagnoses', `${diagnoseBasename}.md`);
|
|
286
|
+
|
|
287
|
+
const writes = [];
|
|
288
|
+
writes.push({
|
|
289
|
+
kind: 'create',
|
|
290
|
+
absPath: diagnoseAbsPath,
|
|
291
|
+
content: buildDiagnoseContent(diagnoseId, fileKey, familyKey, name, severity, research.basename, symptomSeed, now),
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const researchUpdate = buildUpdatedResearchContent(research.raw, {
|
|
295
|
+
diagnoseBasename,
|
|
296
|
+
now,
|
|
297
|
+
today,
|
|
298
|
+
author: 'promote-research-to-diagnose (agent)',
|
|
299
|
+
});
|
|
300
|
+
if (researchUpdate.changed) {
|
|
301
|
+
writes.push({ kind: 'update', absPath: research.absPath, content: researchUpdate.content });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const commitResult = commitWrites(writes);
|
|
305
|
+
if (!commitResult.ok) {
|
|
306
|
+
return {
|
|
307
|
+
error: 'WRITE_FAILED',
|
|
308
|
+
message: commitResult.error?.message ?? 'unknown write failure',
|
|
309
|
+
rollback_error: commitResult.error?.rollbackError,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
phase: 'executed',
|
|
315
|
+
diagnose_id: diagnoseId,
|
|
316
|
+
diagnose_basename: diagnoseBasename,
|
|
317
|
+
diagnose_path: diagnoseRelPath,
|
|
318
|
+
diagnose_created: true,
|
|
319
|
+
severity,
|
|
320
|
+
research: {
|
|
321
|
+
research_id: researchId,
|
|
322
|
+
research_basename: research.basename,
|
|
323
|
+
produces_diagnose_added: researchUpdate.changed,
|
|
324
|
+
},
|
|
325
|
+
writes: writes.map((w) => ({ kind: w.kind, path: w.absPath })),
|
|
326
|
+
human_review_required: false,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export const __testables = {
|
|
331
|
+
findResearchByIdLocal,
|
|
332
|
+
findDiagnoseByBasename,
|
|
333
|
+
collectOccupiedDiagnoseIds,
|
|
334
|
+
extractResearchExecutiveSummary,
|
|
335
|
+
buildDiagnoseContent,
|
|
336
|
+
buildUpdatedResearchContent,
|
|
337
|
+
isResearchId,
|
|
338
|
+
};
|