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,1280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /hs-promote-capture MCP tool — capture(s) → research promotion with
|
|
3
|
+
* AI-assisted outline. Spec: [[HS-TASK-WQCE]] of [[HS-EPIC-M2J3]].
|
|
4
|
+
*
|
|
5
|
+
* Symmetric to [[HS-TASK-QQHP]] (/hs-promote-research) but at the lower edge
|
|
6
|
+
* of the lifecycle: one or more `HS-CAP-*` captures get aggregated into a
|
|
7
|
+
* single `HS-RES-*` research doc, with bidirectional linkage wired both ways.
|
|
8
|
+
*
|
|
9
|
+
* Two-phase flow driven by presence/absence of `accepted_outline`:
|
|
10
|
+
*
|
|
11
|
+
* Phase 1 (review): caller invokes with `target_research: "NEW"` and no
|
|
12
|
+
* `accepted_outline`. Returns capture summaries to the calling agent, which
|
|
13
|
+
* authors the outline and shows it to the human for confirmation. No file writes.
|
|
14
|
+
*
|
|
15
|
+
* Phase 2 (execute): caller invokes again with `accepted_outline` populated
|
|
16
|
+
* (or directly with an existing `target_research: "HS-RES-XXX"`). Tool
|
|
17
|
+
* performs the transactional write set:
|
|
18
|
+
* - create-or-update the research file (sources_captures appended)
|
|
19
|
+
* - mutate each capture's `informs_research:` (idempotent append)
|
|
20
|
+
* - set `promoted_to:` on each capture ONLY when it was previously
|
|
21
|
+
* unset/null (binary first-promotion semantics per
|
|
22
|
+
* [[entity-model]] §capture)
|
|
23
|
+
* - append change-log rows on the research + each capture
|
|
24
|
+
* and rolls back anything written if a later step fails.
|
|
25
|
+
*
|
|
26
|
+
* Closed-research target is NOT a hard error (unlike QQHP, where closed
|
|
27
|
+
* research refuses re-promotion). Per spec §5, the tool warns
|
|
28
|
+
* (`warning: "TARGET_RESEARCH_CLOSED"`) and allows the append; the audit
|
|
29
|
+
* trail in the research's change-log records the post-close addition.
|
|
30
|
+
*
|
|
31
|
+
* Linkage written per [[HS-TASK-YJX6]]:
|
|
32
|
+
* research.sources_captures : appended with each capture wikilink
|
|
33
|
+
* capture.informs_research : appended with the research wikilink
|
|
34
|
+
* capture.promoted_to : set to research wikilink on FIRST promotion
|
|
35
|
+
*
|
|
36
|
+
* All writes are staged then committed via the shared `commitWrites`
|
|
37
|
+
* helper from [[./promotion-helpers.mjs]]; any failure rolls back the prior
|
|
38
|
+
* writes (created files deleted, mutated files restored from snapshot).
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import {
|
|
42
|
+
readFileSync,
|
|
43
|
+
existsSync,
|
|
44
|
+
readdirSync,
|
|
45
|
+
} from 'node:fs';
|
|
46
|
+
import { join } from 'node:path';
|
|
47
|
+
// Sufficiency gate at promotion ([[HS-0CFX-TASK]] / [[HS-3EFV-EPIC]]) — the same
|
|
48
|
+
// dev-factory scorer the Phase-1 entry gate uses (cross-import precedented by
|
|
49
|
+
// fleet-readiness.mjs in cli.mjs; deliberately NOT a third mirror copy).
|
|
50
|
+
import { scoreSufficiency } from '../../dev-factory/lib/sufficiency-score.mjs';
|
|
51
|
+
// Already-expanded bypass ([[HS-55YM-DIAG]]) — reuses the existing
|
|
52
|
+
// capture-focus.mjs deep-dive reader rather than a third regex copy.
|
|
53
|
+
import { extractCaptureExpandedConceptFromBody } from './capture-focus.mjs';
|
|
54
|
+
import {
|
|
55
|
+
mintFileKey,
|
|
56
|
+
mintFamilyKey,
|
|
57
|
+
parseSpecId,
|
|
58
|
+
familyOf,
|
|
59
|
+
bareFileKeyOf,
|
|
60
|
+
lookupFileKeyOf,
|
|
61
|
+
shortLookupIdOf,
|
|
62
|
+
} from './spec-id-lib.mjs';
|
|
63
|
+
import { yamlScalar } from './yaml-scalar.mjs';
|
|
64
|
+
import { suggestResearchType } from './research-type-suggest.mjs';
|
|
65
|
+
import { buildDiagnoseSkeletonSections } from './diagnose-skeleton.mjs';
|
|
66
|
+
// HS-QOBQ: re-check kind at promote time — a capture wrongly filed as `idea`
|
|
67
|
+
// (the default) whose body actually reads as a bug report should be re-kinded
|
|
68
|
+
// before promoting, not silently routed to research (capture-guardrails
|
|
69
|
+
// §"Which exit?"). Same lexical guess the intent classifier suggests at
|
|
70
|
+
// capture time, so both call sites agree.
|
|
71
|
+
import { checkCaptureKindMismatch } from './capture-kind-suggest.mjs';
|
|
72
|
+
import {
|
|
73
|
+
isoNow,
|
|
74
|
+
todayDate,
|
|
75
|
+
deriveSlug,
|
|
76
|
+
findFileByPrefix,
|
|
77
|
+
getFrontmatterBlock,
|
|
78
|
+
getScalarField,
|
|
79
|
+
parseListFieldBasenames,
|
|
80
|
+
setScalarField,
|
|
81
|
+
appendToListField,
|
|
82
|
+
bumpUpdated,
|
|
83
|
+
appendChangeLogRow,
|
|
84
|
+
commitWrites,
|
|
85
|
+
} from './promotion-helpers.mjs';
|
|
86
|
+
// Reuse the exact same placeholder-count logic RESEARCH_STILL_STUB uses to
|
|
87
|
+
// refuse `heyspex_promote_research` on a stub research doc — proactively
|
|
88
|
+
// surfaced here at creation/extension time instead of only reactively at
|
|
89
|
+
// the downstream refusal ([[HS-DSFO-49G3-DIAG-promote-capture-s-success-response]]).
|
|
90
|
+
import { findUnresolvedResearchPlaceholders } from './promote-research.mjs';
|
|
91
|
+
|
|
92
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
93
|
+
// Dual-read id classification ([[HS-MIL-KY1F]] family-first migration).
|
|
94
|
+
//
|
|
95
|
+
// The vault now mints family-first ids (`HS-<familyKey>-<TYPE>`, e.g.
|
|
96
|
+
// `HS-IY9V-CAP`) but legacy specs may still carry the type-first ordering
|
|
97
|
+
// (`HS-CAP-IY9V`). We classify an id/basename by its parsed **type label**
|
|
98
|
+
// rather than a fixed-ordering regex, so both orderings resolve. See
|
|
99
|
+
// vendor/heyspex-mcp/spec-id-lib.mjs (`parseSpecId`).
|
|
100
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
/** True when `id` (either ordering) parses to a spec with the given type label. */
|
|
103
|
+
function hasTypeLabel(id, label) {
|
|
104
|
+
const p = parseSpecId(id);
|
|
105
|
+
return !!p && p.typeLabel === label;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const isCaptureId = (id) => hasTypeLabel(id, 'CAP');
|
|
109
|
+
const isResearchId = (id) => hasTypeLabel(id, 'RES');
|
|
110
|
+
const isMilestoneId = (id) => hasTypeLabel(id, 'MIL');
|
|
111
|
+
|
|
112
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
113
|
+
// Family inheritance (capture → research), owner ruling 2026-07-12
|
|
114
|
+
// ([[family-id-is-inherited-capture-research]] / [[HS-3JUN-DIAG-HZ8T]]),
|
|
115
|
+
// rewritten for the V3 shared-surname model
|
|
116
|
+
// ([[HS-ZU1Q-TASK-PNAS-flip-new-spec-minting-to-the-file-key]],
|
|
117
|
+
// [[naming-v3-7-rulings-hs-namespace-kept]]).
|
|
118
|
+
//
|
|
119
|
+
// Research promoted from a capture INHERITS the FIRST/primary source
|
|
120
|
+
// capture's family key — UNCONDITIONALLY, no fallback. Under era-3 minting,
|
|
121
|
+
// `id:` keys onto the per-file file key alone (`HS-<fileKey>`), not the
|
|
122
|
+
// family key, so two research docs sharing one family can never collide on
|
|
123
|
+
// `id:` — the disambiguation problem `FAMILY_INHERITANCE_FALLBACK` guarded
|
|
124
|
+
// against ([[HS-3JUN-DIAG-HZ8T]]) no longer exists. See
|
|
125
|
+
// vault-id-assignment-protocol §Family inheritance ("`FAMILY_INHERITANCE_
|
|
126
|
+
// FALLBACK` is removed, not documented as a case").
|
|
127
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The family key of a promotion source: explicit `frontmatter.family` when
|
|
131
|
+
* present and well-formed, else the positional family key parsed from `id`,
|
|
132
|
+
* else the positional key parsed from the BASENAME — the same "explicit wins,
|
|
133
|
+
* else positional" precedence as {@link familyOf} (which this delegates to).
|
|
134
|
+
* Returns null when none is derivable.
|
|
135
|
+
*
|
|
136
|
+
* The basename argument is load-bearing under era-3 naming: a minted `id:` is
|
|
137
|
+
* the bare file key (`HS-6VT0`), which parseSpecId cannot read a family out
|
|
138
|
+
* of — the family rides in the basename (`HS-6VT0-84HC-CAP-<slug>`). Passing
|
|
139
|
+
* only the id silently fell through to mintFamilyKey and broke the owner's
|
|
140
|
+
* unconditional-inheritance ruling ([[family-id-is-inherited-capture-research]]).
|
|
141
|
+
* Callers inherit the result directly — no candidate-vs-occupied collision
|
|
142
|
+
* check is needed (see block comment above).
|
|
143
|
+
*/
|
|
144
|
+
function inheritedFamilyKey(sourceId, sourceFrontmatter, sourceBasename) {
|
|
145
|
+
return familyOf({ id: sourceId, basename: sourceBasename, frontmatter: sourceFrontmatter });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
149
|
+
// `research_type` — the declared purpose of a research doc, required by
|
|
150
|
+
// [[entity-model]] §research / `schemas/heyspex-frontmatter.schema.json`
|
|
151
|
+
// ([[HS-120I-TASK-implement-research-type-field-end-to-end]]). Written here on
|
|
152
|
+
// every NEW research doc this tool creates — never blank.
|
|
153
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
export const RESEARCH_TYPE_VALUES = [
|
|
156
|
+
'new-feature',
|
|
157
|
+
'enhancement',
|
|
158
|
+
'diagnose',
|
|
159
|
+
'code-audit',
|
|
160
|
+
'reference',
|
|
161
|
+
];
|
|
162
|
+
export const DEFAULT_RESEARCH_TYPE = 'reference';
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Resolve the `research_type` to write for a newly created research doc.
|
|
166
|
+
* Precedence: an explicit `accepted_outline.research_type` (human-confirmed)
|
|
167
|
+
* wins; otherwise a `suggestedType` — computed by
|
|
168
|
+
* [[HS-MVC1-EPIC-deterministic-type-assist-suggest-resear]]'s
|
|
169
|
+
* `suggestResearchType()` from the source capture's `kind` (see
|
|
170
|
+
* `runExecutePhase` below) — is used when present; falls back to
|
|
171
|
+
* {@link DEFAULT_RESEARCH_TYPE}. Never returns a blank/invalid value.
|
|
172
|
+
* @param {unknown} providedValue
|
|
173
|
+
* @param {unknown} [suggestedType] — kind-derived suggestion (HS-MVC1).
|
|
174
|
+
* @returns {string}
|
|
175
|
+
*/
|
|
176
|
+
export function resolveResearchType(providedValue, suggestedType = null) {
|
|
177
|
+
if (typeof providedValue === 'string' && RESEARCH_TYPE_VALUES.includes(providedValue)) {
|
|
178
|
+
return providedValue;
|
|
179
|
+
}
|
|
180
|
+
if (typeof suggestedType === 'string' && RESEARCH_TYPE_VALUES.includes(suggestedType)) {
|
|
181
|
+
return suggestedType;
|
|
182
|
+
}
|
|
183
|
+
return DEFAULT_RESEARCH_TYPE;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
187
|
+
// `scope` — how broad a search context this research was run at, per
|
|
188
|
+
// [[entity-model]] §research / `schemas/heyspex-frontmatter.schema.json`
|
|
189
|
+
// ([[HS-NGD1-TASK-consolidate-and-fix-the-research-scope-field]]). NOT
|
|
190
|
+
// required (consistency fix only — no grace window, unlike `research_type`).
|
|
191
|
+
// Written here with the same enum + same default as `heyspex_save_research`
|
|
192
|
+
// and `heyspex_complete_research`.
|
|
193
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
export const RESEARCH_SCOPE_VALUES = ['internal', 'codebase', 'external', 'all'];
|
|
196
|
+
export const DEFAULT_RESEARCH_SCOPE = 'internal';
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Resolve the `scope` to write for a newly created research doc. Falls back
|
|
200
|
+
* to {@link DEFAULT_RESEARCH_SCOPE} for a missing/invalid value.
|
|
201
|
+
* @param {unknown} providedValue
|
|
202
|
+
* @returns {string}
|
|
203
|
+
*/
|
|
204
|
+
export function resolveResearchScope(providedValue) {
|
|
205
|
+
if (typeof providedValue === 'string' && RESEARCH_SCOPE_VALUES.includes(providedValue)) {
|
|
206
|
+
return providedValue;
|
|
207
|
+
}
|
|
208
|
+
return DEFAULT_RESEARCH_SCOPE;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Canonical family-first bare id (`HS-<familyKey>-<TYPE>`) for an id/basename of
|
|
213
|
+
* EITHER ordering, gated to a single type label — or null. Feeds
|
|
214
|
+
* {@link collectOccupiedResearchIds}/{@link collectOccupiedDiagnoseIds}
|
|
215
|
+
* (dual-read occupancy sets, kept for their own test coverage — no longer
|
|
216
|
+
* consulted for era-3 minting, which keys `id:` on the vault-wide-unique file
|
|
217
|
+
* key instead of a per-kind family+type bucket).
|
|
218
|
+
*/
|
|
219
|
+
function canonicalIdOfType(value, label) {
|
|
220
|
+
const p = parseSpecId(value);
|
|
221
|
+
if (!p || p.typeLabel !== label) return null;
|
|
222
|
+
return `HS-${p.familyKey}-${p.typeLabel}`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
226
|
+
// File loaders
|
|
227
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
function findCaptureByIdLocal(vaultRoot, captureId) {
|
|
230
|
+
const dir = join(vaultRoot, 'specs', 'captures');
|
|
231
|
+
if (!existsSync(dir)) return null;
|
|
232
|
+
const direct = findFileByPrefix(dir, captureId);
|
|
233
|
+
if (direct) {
|
|
234
|
+
const basename = direct.replace(/\.md$/, '');
|
|
235
|
+
const absPath = join(dir, direct);
|
|
236
|
+
return {
|
|
237
|
+
basename,
|
|
238
|
+
absPath,
|
|
239
|
+
relPath: join('specs', 'captures', direct),
|
|
240
|
+
raw: readFileSync(absPath, 'utf8'),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
for (const file of readdirSync(dir)) {
|
|
244
|
+
if (!file.endsWith('.md')) continue;
|
|
245
|
+
const raw = readFileSync(join(dir, file), 'utf8');
|
|
246
|
+
const idMatch = raw.match(/^id:\s*(\S+)/m);
|
|
247
|
+
if (idMatch) {
|
|
248
|
+
const id = idMatch[1].replace(/^['"]|['"]$/g, '');
|
|
249
|
+
if (id === captureId) {
|
|
250
|
+
const basename = file.replace(/\.md$/, '');
|
|
251
|
+
return {
|
|
252
|
+
basename,
|
|
253
|
+
absPath: join(dir, file),
|
|
254
|
+
relPath: join('specs', 'captures', file),
|
|
255
|
+
raw,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function findResearchByIdLocal(vaultRoot, researchId) {
|
|
264
|
+
const dir = join(vaultRoot, 'specs', 'research');
|
|
265
|
+
if (!existsSync(dir)) return null;
|
|
266
|
+
const direct = findFileByPrefix(dir, researchId);
|
|
267
|
+
const tryFile = (file) => {
|
|
268
|
+
const basename = file.replace(/\.md$/, '');
|
|
269
|
+
const absPath = join(dir, file);
|
|
270
|
+
return {
|
|
271
|
+
basename,
|
|
272
|
+
absPath,
|
|
273
|
+
relPath: join('specs', 'research', file),
|
|
274
|
+
raw: readFileSync(absPath, 'utf8'),
|
|
275
|
+
};
|
|
276
|
+
};
|
|
277
|
+
if (direct) return tryFile(direct);
|
|
278
|
+
for (const file of readdirSync(dir)) {
|
|
279
|
+
if (!file.endsWith('.md')) continue;
|
|
280
|
+
const raw = readFileSync(join(dir, file), 'utf8');
|
|
281
|
+
const idMatch = raw.match(/^id:\s*(\S+)/m);
|
|
282
|
+
if (idMatch) {
|
|
283
|
+
const id = idMatch[1].replace(/^['"]|['"]$/g, '');
|
|
284
|
+
if (id === researchId) {
|
|
285
|
+
const basename = file.replace(/\.md$/, '');
|
|
286
|
+
return {
|
|
287
|
+
basename,
|
|
288
|
+
absPath: join(dir, file),
|
|
289
|
+
relPath: join('specs', 'research', file),
|
|
290
|
+
raw,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function findResearchByBasename(vaultRoot, basename) {
|
|
299
|
+
const absPath = join(vaultRoot, 'specs', 'research', `${basename}.md`);
|
|
300
|
+
if (!existsSync(absPath)) return null;
|
|
301
|
+
return {
|
|
302
|
+
basename,
|
|
303
|
+
absPath,
|
|
304
|
+
relPath: join('specs', 'research', `${basename}.md`),
|
|
305
|
+
raw: readFileSync(absPath, 'utf8'),
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function collectOccupiedResearchIds(vaultRoot) {
|
|
310
|
+
const occupied = new Set();
|
|
311
|
+
const dir = join(vaultRoot, 'specs', 'research');
|
|
312
|
+
if (!existsSync(dir)) return occupied;
|
|
313
|
+
for (const file of readdirSync(dir)) {
|
|
314
|
+
if (!file.endsWith('.md')) continue;
|
|
315
|
+
const fromBasename = canonicalIdOfType(file.replace(/\.md$/, ''), 'RES');
|
|
316
|
+
if (fromBasename) occupied.add(fromBasename);
|
|
317
|
+
try {
|
|
318
|
+
const raw = readFileSync(join(dir, file), 'utf8');
|
|
319
|
+
const idMatch = raw.match(/^id:\s*(\S+)/m);
|
|
320
|
+
if (idMatch) {
|
|
321
|
+
const id = idMatch[1].replace(/^['"]|['"]$/g, '');
|
|
322
|
+
const fromId = canonicalIdOfType(id, 'RES');
|
|
323
|
+
if (fromId) occupied.add(fromId);
|
|
324
|
+
}
|
|
325
|
+
} catch {
|
|
326
|
+
/* skip */
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return occupied;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
333
|
+
// Argument validation
|
|
334
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
function validateInputShape(args) {
|
|
337
|
+
if (!args || typeof args !== 'object') {
|
|
338
|
+
return { error: 'INVALID_ARGS', message: 'args must be an object' };
|
|
339
|
+
}
|
|
340
|
+
if (!Array.isArray(args.capture_ids) || args.capture_ids.length === 0) {
|
|
341
|
+
return {
|
|
342
|
+
error: 'INVALID_ARGS',
|
|
343
|
+
message: 'capture_ids must be a non-empty array',
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
for (const id of args.capture_ids) {
|
|
347
|
+
// Era-3 minted ids are the bare file key (`HS-<fileKey>`) — deliberately
|
|
348
|
+
// TYPE-LESS, so no label check is possible on the id itself. Any
|
|
349
|
+
// recognizable era-3 form (bare key, display long `HS-<fileKey>-<familyKey>`,
|
|
350
|
+
// full basename) is accepted via lookupFileKeyOf (owner ruling 2026-08-21:
|
|
351
|
+
// resolvers recognize every form the human might paste). The loader
|
|
352
|
+
// (findCaptureByIdLocal, scoped to specs/captures/) is what proves it
|
|
353
|
+
// names a capture, and misses fail as CAPTURE_NOT_FOUND.
|
|
354
|
+
if (typeof id !== 'string' || (!isCaptureId(id) && !lookupFileKeyOf(id))) {
|
|
355
|
+
return {
|
|
356
|
+
error: 'INVALID_ARGS',
|
|
357
|
+
message: `capture_ids entry ${id} is not a capture id (expected HS-<key>-CAP, legacy HS-CAP-<key>, or an era-3 id/basename carrying the file key)`,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
if (typeof args.target_research !== 'string' || !args.target_research.trim()) {
|
|
362
|
+
return { error: 'INVALID_ARGS', message: 'target_research is required' };
|
|
363
|
+
}
|
|
364
|
+
if (args.target_research === 'NEW') {
|
|
365
|
+
if (
|
|
366
|
+
typeof args.new_research_name !== 'string' ||
|
|
367
|
+
!args.new_research_name.trim()
|
|
368
|
+
) {
|
|
369
|
+
return {
|
|
370
|
+
error: 'INVALID_ARGS',
|
|
371
|
+
message: 'new_research_name is required when target_research === "NEW"',
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
} else if (!isResearchId(args.target_research) && !lookupFileKeyOf(args.target_research)) {
|
|
375
|
+
// Any era-3 form accepted for the same reason as capture_ids above — the
|
|
376
|
+
// research loader (specs/research/ scoped) is the existence/type check
|
|
377
|
+
// ([[HS-DQ9H-8J7V-DIAG-promote-capture-cannot-target-era-3]]).
|
|
378
|
+
return {
|
|
379
|
+
error: 'INVALID_ARGS',
|
|
380
|
+
message: `target_research ${args.target_research} is not a research id (expected HS-<key>-RES, legacy HS-RES-<key>, or an era-3 id/basename carrying the file key) or the literal "NEW"`,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
if (
|
|
384
|
+
args.enhances !== undefined &&
|
|
385
|
+
args.enhances !== null &&
|
|
386
|
+
args.enhances !== ''
|
|
387
|
+
) {
|
|
388
|
+
if (typeof args.enhances !== 'string' || !isMilestoneId(args.enhances)) {
|
|
389
|
+
return {
|
|
390
|
+
error: 'INVALID_ARGS',
|
|
391
|
+
message: `enhances ${args.enhances} must be a milestone id or basename (HS-<key>-MIL, or legacy HS-MIL-<key>)`,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
if (args.accepted_outline !== undefined && args.accepted_outline !== null) {
|
|
396
|
+
if (typeof args.accepted_outline !== 'object') {
|
|
397
|
+
return {
|
|
398
|
+
error: 'INVALID_ARGS',
|
|
399
|
+
message: 'accepted_outline must be an object when provided',
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
if (
|
|
403
|
+
typeof args.accepted_outline.name !== 'string' ||
|
|
404
|
+
!args.accepted_outline.name.trim()
|
|
405
|
+
) {
|
|
406
|
+
return {
|
|
407
|
+
error: 'INVALID_ARGS',
|
|
408
|
+
message: 'accepted_outline.name is required',
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
if (!Array.isArray(args.accepted_outline.outline)) {
|
|
412
|
+
return {
|
|
413
|
+
error: 'INVALID_ARGS',
|
|
414
|
+
message: 'accepted_outline.outline must be an array of section names',
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
if (
|
|
418
|
+
args.accepted_outline.research_type !== undefined &&
|
|
419
|
+
args.accepted_outline.research_type !== null &&
|
|
420
|
+
!RESEARCH_TYPE_VALUES.includes(args.accepted_outline.research_type)
|
|
421
|
+
) {
|
|
422
|
+
return {
|
|
423
|
+
error: 'INVALID_ARGS',
|
|
424
|
+
message: `accepted_outline.research_type ${JSON.stringify(args.accepted_outline.research_type)} must be one of: ${RESEARCH_TYPE_VALUES.join(', ')}`,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
if (
|
|
428
|
+
args.accepted_outline.scope !== undefined &&
|
|
429
|
+
args.accepted_outline.scope !== null &&
|
|
430
|
+
!RESEARCH_SCOPE_VALUES.includes(args.accepted_outline.scope)
|
|
431
|
+
) {
|
|
432
|
+
return {
|
|
433
|
+
error: 'INVALID_ARGS',
|
|
434
|
+
message: `accepted_outline.scope ${JSON.stringify(args.accepted_outline.scope)} must be one of: ${RESEARCH_SCOPE_VALUES.join(', ')}`,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
442
|
+
// Phase 1: review (AI proposal)
|
|
443
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
444
|
+
|
|
445
|
+
async function runProposalPhase(args, ctx, _deps) {
|
|
446
|
+
const captureSummaries = ctx.captures.map((c) => {
|
|
447
|
+
const fm = getFrontmatterBlock(c.raw);
|
|
448
|
+
const name = fm ? getScalarField(fm.block, 'name') : null;
|
|
449
|
+
const tagsList = fm ? parseListFieldBasenames(fm.block, 'tags') : null;
|
|
450
|
+
const body = c.raw
|
|
451
|
+
.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '')
|
|
452
|
+
.slice(0, 4000);
|
|
453
|
+
// HS-QOBQ: re-check the stored kind against the content at the moment of
|
|
454
|
+
// promotion — the diagnose's own rule is "a capture mistakenly filed as
|
|
455
|
+
// the wrong kind is re-kinded before promoting, not promoted twice."
|
|
456
|
+
const kindCheck = checkCaptureKindMismatch(c.frontmatter?.kind, body);
|
|
457
|
+
return {
|
|
458
|
+
id: c.frontmatter?.id ?? c.basename,
|
|
459
|
+
name: name ?? c.basename,
|
|
460
|
+
body,
|
|
461
|
+
tags: Array.isArray(tagsList) ? tagsList : [],
|
|
462
|
+
kind: kindCheck.storedKind,
|
|
463
|
+
...(kindCheck.mismatch ? { kind_suggestion: kindCheck.suggestedKind } : {}),
|
|
464
|
+
};
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
const mismatched = captureSummaries.filter((c) => c.kind_suggestion);
|
|
468
|
+
|
|
469
|
+
return {
|
|
470
|
+
phase: 'review',
|
|
471
|
+
capture_ids: ctx.captures.map((c) => c.frontmatter?.id ?? c.basename),
|
|
472
|
+
capture_basenames: ctx.captures.map((c) => c.basename),
|
|
473
|
+
target_research: 'NEW',
|
|
474
|
+
new_research_name: args.new_research_name,
|
|
475
|
+
proposed_outline: null,
|
|
476
|
+
capture_summaries: captureSummaries,
|
|
477
|
+
ai_proposal: {
|
|
478
|
+
ok: false,
|
|
479
|
+
reason: 'agent_authors_outline',
|
|
480
|
+
message:
|
|
481
|
+
'Outline authoring is delegated to the calling agent — no server-side AI call is made. The agent reads capture_summaries and proposes the outline directly.',
|
|
482
|
+
},
|
|
483
|
+
...(mismatched.length > 0
|
|
484
|
+
? {
|
|
485
|
+
warning: 'CAPTURE_KIND_MISMATCH',
|
|
486
|
+
kind_mismatch_message:
|
|
487
|
+
`${mismatched.length} capture${mismatched.length === 1 ? '' : 's'} read as a different kind than stored ` +
|
|
488
|
+
`(${mismatched.map((c) => `${c.id}: stored ${c.kind} → reads like ${c.kind_suggestion}`).join('; ')}). ` +
|
|
489
|
+
'Per capture-guardrails "Which exit?": a mismatched capture should be re-kinded before promoting, ' +
|
|
490
|
+
'not promoted twice — offer the reclassification (and, for bug-fix, the promote-to-diagnose exit ' +
|
|
491
|
+
'instead) to the user before authoring a research outline; call heyspex_update_capture with kind ' +
|
|
492
|
+
'ONLY on their yes ([[HS-5HYS]] elicitation — suggest, never auto-apply).',
|
|
493
|
+
}
|
|
494
|
+
: {}),
|
|
495
|
+
human_review_required: true,
|
|
496
|
+
next_call_hint:
|
|
497
|
+
'Author the outline from capture_summaries, show it to the user for confirmation, then call heyspex_promote_capture again with the same args plus `accepted_outline: { name, outline, tags, enhances_suggestion? }` to execute the writes.',
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
502
|
+
// Phase 2: execute (transactional writes)
|
|
503
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
504
|
+
|
|
505
|
+
function buildResearchStubContent(researchId, fileKey, familyKey, outline, captureBasenames, enhances, now, suggestedType = null, jtbdSeed = '') {
|
|
506
|
+
const today = now.slice(0, 10);
|
|
507
|
+
const tagsBlock =
|
|
508
|
+
outline.tags && outline.tags.length > 0
|
|
509
|
+
? `tags:\n${outline.tags.map((t) => ` - "${t}"`).join('\n')}`
|
|
510
|
+
: 'tags: []';
|
|
511
|
+
const sourcesBlock =
|
|
512
|
+
captureBasenames.length === 0
|
|
513
|
+
? 'sources_captures: []'
|
|
514
|
+
: `sources_captures:\n${captureBasenames
|
|
515
|
+
.map((b) => ` - "[[${b}]]"`)
|
|
516
|
+
.join('\n')}`;
|
|
517
|
+
// HS-MVC1: an explicit outline.research_type (human-confirmed) still wins;
|
|
518
|
+
// suggestedType (kind-derived, see runExecutePhase) fills in only when the
|
|
519
|
+
// outline left research_type unset — never overrides an explicit choice.
|
|
520
|
+
const researchType = resolveResearchType(outline.research_type, suggestedType);
|
|
521
|
+
const researchScope = resolveResearchScope(outline.scope);
|
|
522
|
+
const enhancesLine = enhances ? `enhances: "[[${enhances}]]"\n` : '';
|
|
523
|
+
// Drop any agent-authored "conclusion & intended outcome" heading from the
|
|
524
|
+
// outline — the stub always injects the canonical one below, so we avoid a
|
|
525
|
+
// duplicate section ([[HS-K4D4-2I29-RES-outcome-cascade-through-a-requirements-h]]).
|
|
526
|
+
const sectionsBody = outline.outline
|
|
527
|
+
.filter(
|
|
528
|
+
(section) =>
|
|
529
|
+
!/^\s*(conclusion\b.*intended outcome|intended outcome|conclusion & intended outcome)\s*$/i.test(
|
|
530
|
+
section,
|
|
531
|
+
),
|
|
532
|
+
)
|
|
533
|
+
.map((section) => `## ${section}\n\n_TBD — fill in this section._\n`)
|
|
534
|
+
.join('\n');
|
|
535
|
+
return `---
|
|
536
|
+
id: ${researchId}
|
|
537
|
+
file_id: ${fileKey}
|
|
538
|
+
family: ${familyKey}
|
|
539
|
+
name: ${yamlScalar(outline.name)}
|
|
540
|
+
type: research
|
|
541
|
+
research_type: ${researchType}
|
|
542
|
+
scope: ${researchScope}
|
|
543
|
+
status: open
|
|
544
|
+
priority: 2
|
|
545
|
+
owner: pwher
|
|
546
|
+
created: "${now}"
|
|
547
|
+
updated: "${now}"
|
|
548
|
+
${enhancesLine}${sourcesBlock}
|
|
549
|
+
${tagsBlock}
|
|
550
|
+
related: []
|
|
551
|
+
---
|
|
552
|
+
|
|
553
|
+
# ${outline.name}
|
|
554
|
+
|
|
555
|
+
_Research stub created by /hs-promote-capture from ${captureBasenames.length} capture${captureBasenames.length === 1 ? '' : 's'}. Each section header below is a placeholder — fill in the body during the research pass._
|
|
556
|
+
|
|
557
|
+
## Conclusion & intended outcome
|
|
558
|
+
|
|
559
|
+
<!-- The root outcome that milestones/epics/tasks spawned from this research refine — translate, don't copy ([[HS-K4D4-2I29-RES-outcome-cascade-through-a-requirements-h]]). -->
|
|
560
|
+
**Conclusion (one line):** _Acting on this research, what is the conclusion?_
|
|
561
|
+
|
|
562
|
+
**What acting on this achieves:** _What changes for the user or system if we act on it? This is the outcome lower levels re-derive at their own altitude._
|
|
563
|
+
|
|
564
|
+
## JTBD (job-to-be-done)
|
|
565
|
+
|
|
566
|
+
<!-- Validated or revised by research — per [[research-capture-protocol]]'s "JTBD (job-to-be-done) framing" §JTBD-questioning section. State plainly whether research confirms the captured job or revises it, and why. -->
|
|
567
|
+
${
|
|
568
|
+
jtbdSeed
|
|
569
|
+
? `**Job, as captured (pending research validation):**\n${jtbdSeed}`
|
|
570
|
+
: '**Job, as captured:** _TBD — the source capture had no interim JTBD block; state the job this research serves._'
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
**Validated or revised by research:** _TBD — once research runs, state plainly whether it confirms this job or revises it (and what changed, if revised)._
|
|
574
|
+
|
|
575
|
+
${sectionsBody}
|
|
576
|
+
## Change log
|
|
577
|
+
|
|
578
|
+
| Date | Author | Change |
|
|
579
|
+
| --- | --- | --- |
|
|
580
|
+
| ${today} | promote-capture (agent) | Created from ${captureBasenames.length} capture${captureBasenames.length === 1 ? '' : 's'}: ${captureBasenames.map((b) => `[[${b}]]`).join(', ')}. |
|
|
581
|
+
`;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function buildUpdatedResearchContent(raw, params) {
|
|
585
|
+
const fm = getFrontmatterBlock(raw);
|
|
586
|
+
if (!fm) {
|
|
587
|
+
throw new Error('research has no frontmatter — cannot promote captures into it');
|
|
588
|
+
}
|
|
589
|
+
let block = fm.block;
|
|
590
|
+
let changed = false;
|
|
591
|
+
for (const basename of params.captureBasenames) {
|
|
592
|
+
const next = appendToListField(block, 'sources_captures', basename);
|
|
593
|
+
if (next.changed) changed = true;
|
|
594
|
+
block = next.block;
|
|
595
|
+
}
|
|
596
|
+
if (!changed) {
|
|
597
|
+
return { content: raw, changed: false };
|
|
598
|
+
}
|
|
599
|
+
block = bumpUpdated(block, params.now);
|
|
600
|
+
const change = `Added ${params.captureBasenames.length} capture source${params.captureBasenames.length === 1 ? '' : 's'} via /hs-promote-capture: ${params.captureBasenames
|
|
601
|
+
.map((b) => `[[${b}]]`)
|
|
602
|
+
.join(', ')}.${params.warningClosed ? ' (target was status: closed — append recorded on closed research.)' : ''}`;
|
|
603
|
+
const newBody = appendChangeLogRow(fm.body, params.today, params.author, change);
|
|
604
|
+
return { content: `${fm.open}${block}${fm.close}${newBody}`, changed: true };
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function buildUpdatedCaptureContent(raw, params) {
|
|
608
|
+
const fm = getFrontmatterBlock(raw);
|
|
609
|
+
if (!fm) {
|
|
610
|
+
throw new Error('capture has no frontmatter — cannot link informs_research');
|
|
611
|
+
}
|
|
612
|
+
let block = fm.block;
|
|
613
|
+
let changed = false;
|
|
614
|
+
let setPromotedTo = false;
|
|
615
|
+
|
|
616
|
+
// 1. informs_research — always append (idempotent).
|
|
617
|
+
const informsResult = appendToListField(block, 'informs_research', params.researchBasename);
|
|
618
|
+
if (informsResult.changed) changed = true;
|
|
619
|
+
block = informsResult.block;
|
|
620
|
+
|
|
621
|
+
// 2. promoted_to — set only when currently null/absent (FIRST-promotion semantics).
|
|
622
|
+
const currentPromoted = getScalarField(block, 'promoted_to');
|
|
623
|
+
const isUnset =
|
|
624
|
+
currentPromoted === null ||
|
|
625
|
+
currentPromoted === 'null' ||
|
|
626
|
+
currentPromoted === '' ||
|
|
627
|
+
currentPromoted === '~';
|
|
628
|
+
if (isUnset) {
|
|
629
|
+
block = setScalarField(block, 'promoted_to', `"[[${params.researchBasename}]]"`);
|
|
630
|
+
setPromotedTo = true;
|
|
631
|
+
changed = true;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
if (!changed) {
|
|
635
|
+
return { content: raw, changed: false, setPromotedTo: false };
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
block = bumpUpdated(block, params.now);
|
|
639
|
+
const fragments = ['Linked to research'];
|
|
640
|
+
fragments.push(`[[${params.researchBasename}]]`);
|
|
641
|
+
if (setPromotedTo) fragments.push('(first promotion — promoted_to set)');
|
|
642
|
+
const change = `${fragments.join(' ')} via /hs-promote-capture.`;
|
|
643
|
+
const newBody = appendChangeLogRow(fm.body, params.today, params.author, change);
|
|
644
|
+
return {
|
|
645
|
+
content: `${fm.open}${block}${fm.close}${newBody}`,
|
|
646
|
+
changed: true,
|
|
647
|
+
setPromotedTo,
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
async function runExecutePhase(args, ctx, deps) {
|
|
652
|
+
const now = (deps?.clock ?? isoNow)();
|
|
653
|
+
const today = now.slice(0, 10);
|
|
654
|
+
const author = 'promote-capture (agent)';
|
|
655
|
+
|
|
656
|
+
// ── Resolve / allocate target research basename + path ────────────────────
|
|
657
|
+
let researchBasename;
|
|
658
|
+
let researchAbsPath;
|
|
659
|
+
let researchRelPath;
|
|
660
|
+
let researchCreated = false;
|
|
661
|
+
let researchId;
|
|
662
|
+
let researchFileKey = null;
|
|
663
|
+
let researchFamilyKey = null;
|
|
664
|
+
let warningClosed = false;
|
|
665
|
+
let outlineForCreate = null;
|
|
666
|
+
|
|
667
|
+
if (ctx.targetResolution.mode === 'new') {
|
|
668
|
+
// Family inheritance (owner ruling 2026-07-12, see block comment above
|
|
669
|
+
// inheritedFamilyKey): the FIRST/primary source capture's family key wins
|
|
670
|
+
// unconditionally when several captures are promoted together —
|
|
671
|
+
// deterministic, mirrors the HS-MVC1 research_type suggestion precedent
|
|
672
|
+
// (ctx.captures[0]) just below. No fallback: era-3 `id:` keys onto the
|
|
673
|
+
// file key alone, so a shared family can never collide.
|
|
674
|
+
const primaryCapture = ctx.captures[0] ?? null;
|
|
675
|
+
researchFamilyKey =
|
|
676
|
+
(primaryCapture
|
|
677
|
+
? inheritedFamilyKey(
|
|
678
|
+
primaryCapture.frontmatter?.id ?? null,
|
|
679
|
+
primaryCapture.frontmatter,
|
|
680
|
+
primaryCapture.basename,
|
|
681
|
+
)
|
|
682
|
+
: null) ?? mintFamilyKey(ctx.vaultRoot, { randomUnit: deps?.randomUnit });
|
|
683
|
+
// Per-file FILE KEY (owner ruling 2026-07-10) — filename + `file_id:` SSOT,
|
|
684
|
+
// and (V3) the anchor for `id:` itself ([[HS-ZU1Q-TASK-PNAS-flip-new-spec-minting-to-the-file-key]]).
|
|
685
|
+
researchFileKey = mintFileKey(ctx.vaultRoot, { randomUnit: deps?.randomUnit });
|
|
686
|
+
researchId = `HS-${researchFileKey}`;
|
|
687
|
+
const outlineName = args.accepted_outline?.name ?? args.new_research_name;
|
|
688
|
+
const slug = deriveSlug(outlineName);
|
|
689
|
+
researchBasename = `${researchId}-${researchFamilyKey}-RES-${slug}`;
|
|
690
|
+
researchAbsPath = join(
|
|
691
|
+
ctx.vaultRoot,
|
|
692
|
+
'specs',
|
|
693
|
+
'research',
|
|
694
|
+
`${researchBasename}.md`,
|
|
695
|
+
);
|
|
696
|
+
researchRelPath = join('specs', 'research', `${researchBasename}.md`);
|
|
697
|
+
researchCreated = true;
|
|
698
|
+
|
|
699
|
+
// Sanity: accepted_outline is required to actually create the file.
|
|
700
|
+
if (!args.accepted_outline) {
|
|
701
|
+
return {
|
|
702
|
+
error: 'NO_ACCEPTED_OUTLINE',
|
|
703
|
+
message:
|
|
704
|
+
'target_research is "NEW" but no accepted_outline was provided. Call the proposal phase first (omit accepted_outline) and confirm with the human.',
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
const baseOutline = {
|
|
708
|
+
name: args.accepted_outline.name.trim(),
|
|
709
|
+
outline: Array.isArray(args.accepted_outline.outline)
|
|
710
|
+
? args.accepted_outline.outline.filter(
|
|
711
|
+
(s) => typeof s === 'string' && s.trim(),
|
|
712
|
+
)
|
|
713
|
+
: [],
|
|
714
|
+
tags: Array.isArray(args.accepted_outline.tags)
|
|
715
|
+
? args.accepted_outline.tags.filter(
|
|
716
|
+
(t) => typeof t === 'string' && t.trim(),
|
|
717
|
+
)
|
|
718
|
+
: [],
|
|
719
|
+
// Raw passthrough — resolved (default/validated/suggestion-filled) by
|
|
720
|
+
// buildResearchStubContent via resolveResearchType(). When left null
|
|
721
|
+
// here (no explicit human choice), buildResearchStubContent's second
|
|
722
|
+
// resolveResearchType() arg receives a kind-derived suggestion
|
|
723
|
+
// computed just below (HS-MVC1) instead of falling straight to the
|
|
724
|
+
// blanket DEFAULT_RESEARCH_TYPE.
|
|
725
|
+
research_type:
|
|
726
|
+
typeof args.accepted_outline.research_type === 'string'
|
|
727
|
+
? args.accepted_outline.research_type
|
|
728
|
+
: null,
|
|
729
|
+
// Raw passthrough — resolved (default/validated) by buildResearchStubContent
|
|
730
|
+
// via resolveResearchScope(). NOT required — a consistency fix only
|
|
731
|
+
// ([[HS-NGD1-TASK-consolidate-and-fix-the-research-scope-field]]).
|
|
732
|
+
scope:
|
|
733
|
+
typeof args.accepted_outline.scope === 'string' ? args.accepted_outline.scope : null,
|
|
734
|
+
};
|
|
735
|
+
if (baseOutline.outline.length === 0) {
|
|
736
|
+
baseOutline.outline = ['Executive Summary', 'Sources'];
|
|
737
|
+
}
|
|
738
|
+
outlineForCreate = baseOutline;
|
|
739
|
+
} else {
|
|
740
|
+
const r = ctx.targetResolution.research;
|
|
741
|
+
researchBasename = r.basename;
|
|
742
|
+
researchAbsPath = r.absPath;
|
|
743
|
+
researchRelPath = r.relPath;
|
|
744
|
+
researchId =
|
|
745
|
+
getScalarField(getFrontmatterBlock(r.raw)?.block ?? '', 'id') ?? researchBasename;
|
|
746
|
+
const status = getScalarField(
|
|
747
|
+
getFrontmatterBlock(r.raw)?.block ?? '',
|
|
748
|
+
'status',
|
|
749
|
+
);
|
|
750
|
+
if (status === 'closed') warningClosed = true;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// ── Stage writes ──────────────────────────────────────────────────────────
|
|
754
|
+
const writes = [];
|
|
755
|
+
|
|
756
|
+
const captureBasenames = ctx.captures.map((c) => c.basename);
|
|
757
|
+
|
|
758
|
+
// Tracks the research doc's body content as it will exist after this call
|
|
759
|
+
// (freshly created, freshly extended, or unchanged) so the success response
|
|
760
|
+
// can proactively report whether it is still an unresolved outline stub —
|
|
761
|
+
// see the `findUnresolvedResearchPlaceholders` import above.
|
|
762
|
+
let finalResearchContent = null;
|
|
763
|
+
|
|
764
|
+
if (researchCreated) {
|
|
765
|
+
const enhancesBasename = resolveEnhancesBasename(args);
|
|
766
|
+
// HS-MVC1: derive a kind→research_type suggestion from the FIRST source
|
|
767
|
+
// capture (deterministic — most promotions are single-capture; when
|
|
768
|
+
// multiple captures with differing kinds are promoted together, the
|
|
769
|
+
// first one's kind wins rather than attempting to merge/reconcile).
|
|
770
|
+
// An explicit outline.research_type still overrides this inside
|
|
771
|
+
// buildResearchStubContent's resolveResearchType() call.
|
|
772
|
+
const suggestedType =
|
|
773
|
+
ctx.captures.length > 0 ? suggestResearchType(ctx.captures[0].frontmatter?.kind) : null;
|
|
774
|
+
// HS-M1MW: seed the stub's JTBD section from the primary capture's
|
|
775
|
+
// interim `JTBD (job-to-be-done):` leading block, when present.
|
|
776
|
+
const jtbdSeed =
|
|
777
|
+
ctx.captures.length > 0 ? extractCaptureJtbd(ctx.captures[0].raw) : '';
|
|
778
|
+
const newContent = buildResearchStubContent(
|
|
779
|
+
researchId,
|
|
780
|
+
researchFileKey,
|
|
781
|
+
researchFamilyKey,
|
|
782
|
+
outlineForCreate,
|
|
783
|
+
captureBasenames,
|
|
784
|
+
enhancesBasename,
|
|
785
|
+
now,
|
|
786
|
+
suggestedType,
|
|
787
|
+
jtbdSeed,
|
|
788
|
+
);
|
|
789
|
+
writes.push({ kind: 'create', absPath: researchAbsPath, content: newContent });
|
|
790
|
+
finalResearchContent = newContent;
|
|
791
|
+
} else {
|
|
792
|
+
const updated = buildUpdatedResearchContent(
|
|
793
|
+
ctx.targetResolution.research.raw,
|
|
794
|
+
{
|
|
795
|
+
captureBasenames,
|
|
796
|
+
warningClosed,
|
|
797
|
+
now,
|
|
798
|
+
today,
|
|
799
|
+
author,
|
|
800
|
+
},
|
|
801
|
+
);
|
|
802
|
+
if (updated.changed) {
|
|
803
|
+
writes.push({
|
|
804
|
+
kind: 'update',
|
|
805
|
+
absPath: researchAbsPath,
|
|
806
|
+
content: updated.content,
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
finalResearchContent = updated.changed ? updated.content : ctx.targetResolution.research.raw;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// Proactive stub warning ([[HS-DSFO-49G3-DIAG-promote-capture-s-success-response]]):
|
|
813
|
+
// mirrors RESEARCH_STILL_STUB's own placeholder count/language, surfaced here
|
|
814
|
+
// at creation/extension time instead of only reactively when
|
|
815
|
+
// `heyspex_promote_research` later refuses. Non-fatal — the write still
|
|
816
|
+
// proceeds; this only informs the caller what to do next.
|
|
817
|
+
const unresolvedPlaceholderCount = finalResearchContent
|
|
818
|
+
? findUnresolvedResearchPlaceholders(finalResearchContent)
|
|
819
|
+
: 0;
|
|
820
|
+
const researchStillStub = unresolvedPlaceholderCount > 0;
|
|
821
|
+
const stubNextStep = researchStillStub
|
|
822
|
+
? `This research doc is an outline of section headings only (${unresolvedPlaceholderCount} ` +
|
|
823
|
+
'unresolved "_TBD — fill in this section._" placeholder(s)) — not authored findings yet. ' +
|
|
824
|
+
'Run the /hs-res method against it (SpexCrawl gather via the Companion + DeepSeek synthesis) ' +
|
|
825
|
+
'before calling heyspex_promote_research; do NOT author the research yourself in the main ' +
|
|
826
|
+
'session — that skips the routed research seats.'
|
|
827
|
+
: undefined;
|
|
828
|
+
|
|
829
|
+
// Capture mutations.
|
|
830
|
+
const capturePromotionResults = [];
|
|
831
|
+
for (const c of ctx.captures) {
|
|
832
|
+
const updated = buildUpdatedCaptureContent(c.raw, {
|
|
833
|
+
researchBasename,
|
|
834
|
+
now,
|
|
835
|
+
today,
|
|
836
|
+
author,
|
|
837
|
+
});
|
|
838
|
+
capturePromotionResults.push({
|
|
839
|
+
capture_id: c.frontmatter?.id ?? c.basename,
|
|
840
|
+
capture_basename: c.basename,
|
|
841
|
+
informs_research_added: updated.changed,
|
|
842
|
+
promoted_to_set: updated.setPromotedTo,
|
|
843
|
+
});
|
|
844
|
+
if (updated.changed) {
|
|
845
|
+
writes.push({
|
|
846
|
+
kind: 'update',
|
|
847
|
+
absPath: c.absPath,
|
|
848
|
+
content: updated.content,
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
if (writes.length === 0) {
|
|
854
|
+
// Idempotent no-op: research already references every input capture and
|
|
855
|
+
// each capture already references the research. Surface this clearly.
|
|
856
|
+
return {
|
|
857
|
+
phase: 'executed',
|
|
858
|
+
research_id: researchId,
|
|
859
|
+
research_basename: researchBasename,
|
|
860
|
+
research_path: researchRelPath,
|
|
861
|
+
research_created: false,
|
|
862
|
+
captures: capturePromotionResults,
|
|
863
|
+
writes: [],
|
|
864
|
+
warning: warningClosed ? 'TARGET_RESEARCH_CLOSED' : undefined,
|
|
865
|
+
no_op: true,
|
|
866
|
+
human_review_required: false,
|
|
867
|
+
research_still_stub: researchStillStub,
|
|
868
|
+
unresolved_placeholder_count: unresolvedPlaceholderCount,
|
|
869
|
+
next_step: stubNextStep,
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// ── Commit ────────────────────────────────────────────────────────────────
|
|
874
|
+
const commitResult = commitWrites(writes);
|
|
875
|
+
if (!commitResult.ok) {
|
|
876
|
+
return {
|
|
877
|
+
error: 'WRITE_FAILED',
|
|
878
|
+
message: commitResult.error?.message ?? 'unknown write failure',
|
|
879
|
+
rollback_error: commitResult.error?.rollbackError,
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
return {
|
|
884
|
+
phase: 'executed',
|
|
885
|
+
research_id: researchId,
|
|
886
|
+
research_basename: researchBasename,
|
|
887
|
+
research_path: researchRelPath,
|
|
888
|
+
research_created: researchCreated,
|
|
889
|
+
captures: capturePromotionResults,
|
|
890
|
+
writes: writes.map((w) => ({ kind: w.kind, path: w.absPath })),
|
|
891
|
+
// TARGET_RESEARCH_CLOSED only occurs on the existing-research branch
|
|
892
|
+
// (researchCreated === false). Family inheritance on the NEW-research
|
|
893
|
+
// branch no longer has a fallback case to warn about (era-3 minting —
|
|
894
|
+
// see the block comment above inheritedFamilyKey).
|
|
895
|
+
warning: warningClosed ? 'TARGET_RESEARCH_CLOSED' : undefined,
|
|
896
|
+
human_review_required: false,
|
|
897
|
+
// Proactive stub signal — same placeholder count RESEARCH_STILL_STUB would
|
|
898
|
+
// later refuse on, surfaced here instead of only reactively. See
|
|
899
|
+
// [[HS-DSFO-49G3-DIAG-promote-capture-s-success-response]].
|
|
900
|
+
research_still_stub: researchStillStub,
|
|
901
|
+
unresolved_placeholder_count: unresolvedPlaceholderCount,
|
|
902
|
+
next_step: stubNextStep,
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
function resolveEnhancesBasename(args) {
|
|
907
|
+
if (!args.enhances) return null;
|
|
908
|
+
// Pass through a milestone id/basename of either ordering (validated upstream
|
|
909
|
+
// by isMilestoneId). A bare id relies on the caller supplying a real existing
|
|
910
|
+
// milestone basename when they need the link to survive the vault validator.
|
|
911
|
+
return args.enhances;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
915
|
+
// Public entry point
|
|
916
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
917
|
+
|
|
918
|
+
export async function executePromoteCapture(vaultRoot, args = {}, deps = {}) {
|
|
919
|
+
const validationError = validateInputShape(args);
|
|
920
|
+
if (validationError) return validationError;
|
|
921
|
+
|
|
922
|
+
// 1. Load every input capture; fail on first miss. Ids collapse to the
|
|
923
|
+
// short stored form first (display long `HS-XV4W-HV6H` → `HS-XV4W`) so the
|
|
924
|
+
// basename-prefix and frontmatter-id matches inside the loader hit for any
|
|
925
|
+
// pasted form — legacy `HS-CAP-*` ids pass through unchanged.
|
|
926
|
+
const captures = [];
|
|
927
|
+
for (const rawCaptureId of args.capture_ids) {
|
|
928
|
+
const captureId = shortLookupIdOf(rawCaptureId) ?? rawCaptureId;
|
|
929
|
+
const capture = findCaptureByIdLocal(vaultRoot, captureId);
|
|
930
|
+
if (!capture) {
|
|
931
|
+
return {
|
|
932
|
+
error: 'CAPTURE_NOT_FOUND',
|
|
933
|
+
message: `capture ${captureId} not found under specs/captures/`,
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
capture.frontmatter = {};
|
|
937
|
+
const fm = getFrontmatterBlock(capture.raw);
|
|
938
|
+
if (fm) {
|
|
939
|
+
capture.frontmatter.id = getScalarField(fm.block, 'id') ?? captureId;
|
|
940
|
+
capture.frontmatter.name = getScalarField(fm.block, 'name') ?? '';
|
|
941
|
+
// `family:` feeds the capture→research family-inheritance ruling
|
|
942
|
+
// ([[family-id-is-inherited-capture-research]]) — explicit frontmatter
|
|
943
|
+
// wins over the positional basename read inside inheritedFamilyKey.
|
|
944
|
+
capture.frontmatter.family = getScalarField(fm.block, 'family') ?? null;
|
|
945
|
+
// `kind:` drives the HS-MVC1 research_type suggestion below — read
|
|
946
|
+
// raw (unvalidated); an unrecognized/missing value safely falls back
|
|
947
|
+
// to the default inside suggestResearchType().
|
|
948
|
+
capture.frontmatter.kind = getScalarField(fm.block, 'kind') ?? null;
|
|
949
|
+
}
|
|
950
|
+
captures.push(capture);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// 1b. Sufficiency gate ([[HS-0CFX-TASK]]): an insufficient capture does not
|
|
954
|
+
// silently become research — the caller gets the missing elements as specific
|
|
955
|
+
// asks (elicit, improve the capture, re-promote), or overrides deliberately
|
|
956
|
+
// with `force_promote: true` (recorded in the response). Deterministic lexical
|
|
957
|
+
// scorer; injectable for tests via deps.scoreSufficiencyFn.
|
|
958
|
+
if (args.force_promote !== true) {
|
|
959
|
+
const scoreFn = deps.scoreSufficiencyFn ?? scoreSufficiency;
|
|
960
|
+
const insufficient = [];
|
|
961
|
+
for (const capture of captures) {
|
|
962
|
+
const fm = getFrontmatterBlock(capture.raw);
|
|
963
|
+
const body = fm ? fm.body : capture.raw;
|
|
964
|
+
// Already-expanded bypass ([[HS-55YM-DIAG]]): the gate's pre-expand,
|
|
965
|
+
// pipeline-entry rationale ([[HS-KNIF-TASK-BN60]] — catch weak,
|
|
966
|
+
// source-only input before an UNATTENDED pipeline expands it) does not
|
|
967
|
+
// apply once a human/agent has already grounded/expanded the capture —
|
|
968
|
+
// `## Expanded Concept` populated means real, reviewed content already
|
|
969
|
+
// exists, so re-blocking promotion here is not meaningful. Skip scoring
|
|
970
|
+
// entirely (no force_promote needed). Mirrored in
|
|
971
|
+
// spex-console/src/lib/capture-vault.ts's gateCapturePromotion — no
|
|
972
|
+
// drift between the two gate call sites.
|
|
973
|
+
if (extractCaptureExpandedConceptFromBody(body).trim().length > 0) {
|
|
974
|
+
continue;
|
|
975
|
+
}
|
|
976
|
+
const doc = {
|
|
977
|
+
frontmatter: { kind: capture.frontmatter?.kind ?? undefined },
|
|
978
|
+
body,
|
|
979
|
+
};
|
|
980
|
+
let suff;
|
|
981
|
+
try {
|
|
982
|
+
suff = scoreFn(doc);
|
|
983
|
+
} catch {
|
|
984
|
+
suff = { pass: false, score: 0, missing: ['*scorer-error*'] };
|
|
985
|
+
}
|
|
986
|
+
if (!suff?.pass) {
|
|
987
|
+
insufficient.push({
|
|
988
|
+
capture_id: capture.frontmatter?.id ?? 'unknown',
|
|
989
|
+
score: suff?.score ?? 0,
|
|
990
|
+
missing: suff?.missing ?? [],
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
if (insufficient.length > 0) {
|
|
995
|
+
return {
|
|
996
|
+
error: 'CAPTURE_INSUFFICIENT',
|
|
997
|
+
message:
|
|
998
|
+
'Promotion blocked: capture(s) below the sufficiency bar. Elicit the missing elements from the user (add them to the capture), then re-promote — or, if the human explicitly wants to proceed anyway, retry with force_promote: true.',
|
|
999
|
+
insufficient,
|
|
1000
|
+
how_to_resolve:
|
|
1001
|
+
'Missing element keys map to heyspex_update_capture arguments (call it with the SAME spec_id, then re-promote): core-idea → core_idea, example → example (label real examples "Real example:"), done-criteria → done_when (a "## Done When" checklist), kind → kind (a non-default classification: bug-fix/clip/reference/question), user-stories → user_stories. Elicit each from the user before writing it — do not invent content to pass the gate.',
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// 2. Resolve target research.
|
|
1007
|
+
let targetResolution;
|
|
1008
|
+
if (args.target_research === 'NEW') {
|
|
1009
|
+
targetResolution = { mode: 'new' };
|
|
1010
|
+
} else {
|
|
1011
|
+
const research = findResearchByIdLocal(
|
|
1012
|
+
vaultRoot,
|
|
1013
|
+
shortLookupIdOf(args.target_research) ?? args.target_research,
|
|
1014
|
+
);
|
|
1015
|
+
if (!research) {
|
|
1016
|
+
return {
|
|
1017
|
+
error: 'RESEARCH_NOT_FOUND',
|
|
1018
|
+
message: `target_research ${args.target_research} not found under specs/research/`,
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
targetResolution = { mode: 'existing', research };
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
const ctx = { vaultRoot, captures, targetResolution };
|
|
1025
|
+
|
|
1026
|
+
// 3. Phase split.
|
|
1027
|
+
// - target_research === "NEW" + no accepted_outline → run AI proposer (Phase 1).
|
|
1028
|
+
// - existing target_research → skip AI, go straight to execute.
|
|
1029
|
+
// - target_research === "NEW" + accepted_outline → execute.
|
|
1030
|
+
const wantsProposal =
|
|
1031
|
+
targetResolution.mode === 'new' && !args.accepted_outline;
|
|
1032
|
+
if (wantsProposal) {
|
|
1033
|
+
return runProposalPhase(args, ctx, deps);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
return runExecutePhase(args, ctx, deps);
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1040
|
+
// Capture → DIAGNOSE promotion ([[HS-MIL-BFIX]]). A bug-report capture becomes a
|
|
1041
|
+
// first-class `HS-DIAG-*` diagnose spec (the executable bug fix). 1:1 (one
|
|
1042
|
+
// capture → one diagnose), no AI outline — simpler than research aggregation.
|
|
1043
|
+
// Reuses the same promotion-helpers + transactional commitWrites.
|
|
1044
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1045
|
+
|
|
1046
|
+
const VALID_DIAGNOSE_SEVERITIES = new Set(['error', 'warning', 'info']);
|
|
1047
|
+
|
|
1048
|
+
function collectOccupiedDiagnoseIds(vaultRoot) {
|
|
1049
|
+
const occupied = new Set();
|
|
1050
|
+
const dir = join(vaultRoot, 'specs', 'diagnoses');
|
|
1051
|
+
if (!existsSync(dir)) return occupied;
|
|
1052
|
+
for (const file of readdirSync(dir)) {
|
|
1053
|
+
if (!file.endsWith('.md')) continue;
|
|
1054
|
+
const fromBasename = canonicalIdOfType(file.replace(/\.md$/, ''), 'DIAG');
|
|
1055
|
+
if (fromBasename) occupied.add(fromBasename);
|
|
1056
|
+
try {
|
|
1057
|
+
const raw = readFileSync(join(dir, file), 'utf8');
|
|
1058
|
+
const idMatch = raw.match(/^id:\s*(\S+)/m);
|
|
1059
|
+
if (idMatch) {
|
|
1060
|
+
const id = idMatch[1].replace(/^['"]|['"]$/g, '');
|
|
1061
|
+
const fromId = canonicalIdOfType(id, 'DIAG');
|
|
1062
|
+
if (fromId) occupied.add(fromId);
|
|
1063
|
+
}
|
|
1064
|
+
} catch {
|
|
1065
|
+
/* skip */
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
return occupied;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/** Pull the `# Core Idea` paragraph from a v2 capture body as the bug symptom. */
|
|
1072
|
+
function extractCaptureCoreIdea(raw) {
|
|
1073
|
+
const fm = getFrontmatterBlock(raw);
|
|
1074
|
+
const body = fm ? fm.body : raw;
|
|
1075
|
+
const m = body.match(/#\s*Core Idea\s*\n+([\s\S]*?)(?=\n#{1,6}\s|$)/i);
|
|
1076
|
+
if (!m) return '';
|
|
1077
|
+
return m[1].trim();
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/**
|
|
1081
|
+
* Pull the interim `JTBD (job-to-be-done):` leading block from a capture's
|
|
1082
|
+
* `## Expanded Concept` section ([[research-capture-protocol]] §"JTBD
|
|
1083
|
+
* (job-to-be-done) framing" — interim home until [[HS-TIE7-PR20-TASK-jtbd-job-to-be-done-field-on-captures]]
|
|
1084
|
+
* ships a dedicated field). Returns '' when no such block is present so
|
|
1085
|
+
* callers can fall back to a placeholder prompt.
|
|
1086
|
+
*/
|
|
1087
|
+
function extractCaptureJtbd(raw) {
|
|
1088
|
+
const fm = getFrontmatterBlock(raw);
|
|
1089
|
+
const body = fm ? fm.body : raw;
|
|
1090
|
+
const section = body.match(/##\s*Expanded Concept\s*\n+([\s\S]*?)(?=\n#{1,6}\s|$)/i);
|
|
1091
|
+
if (!section) return '';
|
|
1092
|
+
const m = section[1].match(/JTBD\s*\(job-to-be-done\):\s*\n+((?:\s*-[^\n]*\n?)+)/i);
|
|
1093
|
+
if (!m) return '';
|
|
1094
|
+
return m[1].trim();
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
function buildDiagnoseContent(diagnoseId, fileKey, familyKey, name, severity, captureBasename, coreIdea, now) {
|
|
1098
|
+
const today = now.slice(0, 10);
|
|
1099
|
+
const symptom = coreIdea
|
|
1100
|
+
? coreIdea
|
|
1101
|
+
: '_Describe the observed bug (promoted from the source capture below)._';
|
|
1102
|
+
return `---
|
|
1103
|
+
id: ${diagnoseId}
|
|
1104
|
+
file_id: ${fileKey}
|
|
1105
|
+
family: ${familyKey}
|
|
1106
|
+
name: ${yamlScalar(name)}
|
|
1107
|
+
type: diagnose
|
|
1108
|
+
status: open
|
|
1109
|
+
severity: ${severity}
|
|
1110
|
+
owner: pwher
|
|
1111
|
+
created: "${now}"
|
|
1112
|
+
updated: "${now}"
|
|
1113
|
+
promoted_from: "[[${captureBasename}]]"
|
|
1114
|
+
tags:
|
|
1115
|
+
- "bug-fix"
|
|
1116
|
+
related:
|
|
1117
|
+
- "[[${captureBasename}]]"
|
|
1118
|
+
---
|
|
1119
|
+
|
|
1120
|
+
# ${name}
|
|
1121
|
+
|
|
1122
|
+
## Symptom
|
|
1123
|
+
|
|
1124
|
+
${symptom}
|
|
1125
|
+
|
|
1126
|
+
## Reproduction
|
|
1127
|
+
|
|
1128
|
+
_TBD — steps to reproduce._
|
|
1129
|
+
|
|
1130
|
+
${buildDiagnoseSkeletonSections()}
|
|
1131
|
+
|
|
1132
|
+
## Change log
|
|
1133
|
+
|
|
1134
|
+
| Date | Author | Change |
|
|
1135
|
+
| --- | --- | --- |
|
|
1136
|
+
| ${today} | promote-capture (agent) | Created from capture [[${captureBasename}]] via /hs-promote-capture (diagnose). |
|
|
1137
|
+
`;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
/** Capture mutation for diagnose promotion: set promoted_to (first) + informs_diagnose. */
|
|
1141
|
+
function buildUpdatedCaptureContentForDiagnose(raw, params) {
|
|
1142
|
+
const fm = getFrontmatterBlock(raw);
|
|
1143
|
+
if (!fm) {
|
|
1144
|
+
throw new Error('capture has no frontmatter — cannot link informs_diagnose');
|
|
1145
|
+
}
|
|
1146
|
+
let block = fm.block;
|
|
1147
|
+
let changed = false;
|
|
1148
|
+
let setPromotedTo = false;
|
|
1149
|
+
|
|
1150
|
+
const informsResult = appendToListField(block, 'informs_diagnose', params.diagnoseBasename);
|
|
1151
|
+
if (informsResult.changed) changed = true;
|
|
1152
|
+
block = informsResult.block;
|
|
1153
|
+
|
|
1154
|
+
const currentPromoted = getScalarField(block, 'promoted_to');
|
|
1155
|
+
const isUnset =
|
|
1156
|
+
currentPromoted === null ||
|
|
1157
|
+
currentPromoted === 'null' ||
|
|
1158
|
+
currentPromoted === '' ||
|
|
1159
|
+
currentPromoted === '~';
|
|
1160
|
+
if (isUnset) {
|
|
1161
|
+
block = setScalarField(block, 'promoted_to', `"[[${params.diagnoseBasename}]]"`);
|
|
1162
|
+
setPromotedTo = true;
|
|
1163
|
+
changed = true;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
if (!changed) return { content: raw, changed: false, setPromotedTo: false };
|
|
1167
|
+
|
|
1168
|
+
block = bumpUpdated(block, params.now);
|
|
1169
|
+
const fragments = ['Promoted to diagnose', `[[${params.diagnoseBasename}]]`];
|
|
1170
|
+
if (setPromotedTo) fragments.push('(first promotion — promoted_to set)');
|
|
1171
|
+
const change = `${fragments.join(' ')} via /hs-promote-capture.`;
|
|
1172
|
+
const newBody = appendChangeLogRow(fm.body, params.today, params.author, change);
|
|
1173
|
+
return { content: `${fm.open}${block}${fm.close}${newBody}`, changed: true, setPromotedTo };
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
/**
|
|
1177
|
+
* Promote a single capture into a new `HS-DIAG-*` diagnose spec. Single-phase
|
|
1178
|
+
* (no AI outline): the capture's Core Idea seeds the Symptom; the rest is a
|
|
1179
|
+
* fill-in skeleton. Bidirectional linkage: diagnose.promoted_from ⇔
|
|
1180
|
+
* capture.promoted_to / informs_diagnose.
|
|
1181
|
+
*/
|
|
1182
|
+
export async function executePromoteCaptureToDiagnose(vaultRoot, args = {}, deps = {}) {
|
|
1183
|
+
// Same any-form id tolerance as executePromoteCapture (owner ruling
|
|
1184
|
+
// 2026-08-21): collapse a display/basename form to the stored short id.
|
|
1185
|
+
const rawCaptureId = args.capture_id;
|
|
1186
|
+
if (typeof rawCaptureId !== 'string' || (!isCaptureId(rawCaptureId) && !lookupFileKeyOf(rawCaptureId))) {
|
|
1187
|
+
return { error: 'INVALID_CAPTURE_ID', message: 'capture_id must be a capture id (HS-<key>-CAP, legacy HS-CAP-<key>, or an era-3 id/basename carrying the file key)' };
|
|
1188
|
+
}
|
|
1189
|
+
const captureId = isCaptureId(rawCaptureId) ? rawCaptureId : (shortLookupIdOf(rawCaptureId) ?? rawCaptureId);
|
|
1190
|
+
const severityRaw = typeof args.severity === 'string' ? args.severity.trim().toLowerCase() : 'warning';
|
|
1191
|
+
const severity = VALID_DIAGNOSE_SEVERITIES.has(severityRaw) ? severityRaw : 'warning';
|
|
1192
|
+
|
|
1193
|
+
const capture = findCaptureByIdLocal(vaultRoot, captureId);
|
|
1194
|
+
if (!capture) {
|
|
1195
|
+
return { error: 'CAPTURE_NOT_FOUND', message: `capture ${captureId} not found under specs/captures/` };
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
const captureFm = getFrontmatterBlock(capture.raw);
|
|
1199
|
+
const captureName = captureFm ? (getScalarField(captureFm.block, 'name') ?? '') : '';
|
|
1200
|
+
const name = (typeof args.name === 'string' && args.name.trim())
|
|
1201
|
+
? args.name.trim()
|
|
1202
|
+
: (captureName || `Bug from ${captureId}`).replace(/^Capture\s+/i, '');
|
|
1203
|
+
|
|
1204
|
+
const now = (deps?.clock ?? isoNow)();
|
|
1205
|
+
const today = now.slice(0, 10);
|
|
1206
|
+
const coreIdea = extractCaptureCoreIdea(capture.raw);
|
|
1207
|
+
|
|
1208
|
+
// A capture→diagnose promotion has no documented family-inheritance hop
|
|
1209
|
+
// (unlike capture→research) — mints a fresh family, same as any other
|
|
1210
|
+
// parentless spec.
|
|
1211
|
+
const familyKey = mintFamilyKey(vaultRoot, { randomUnit: deps?.randomUnit });
|
|
1212
|
+
// Per-file FILE KEY (owner ruling 2026-07-10) — filename + `file_id:` SSOT,
|
|
1213
|
+
// and (V3) the anchor for `id:` itself.
|
|
1214
|
+
const fileKey = mintFileKey(vaultRoot, { randomUnit: deps?.randomUnit });
|
|
1215
|
+
const diagnoseId = `HS-${fileKey}`;
|
|
1216
|
+
const slug = deriveSlug(name);
|
|
1217
|
+
const diagnoseBasename = `${diagnoseId}-${familyKey}-DIAG-${slug}`;
|
|
1218
|
+
const diagnoseRelPath = `specs/diagnoses/${diagnoseBasename}.md`;
|
|
1219
|
+
const diagnoseAbsPath = join(vaultRoot, 'specs', 'diagnoses', `${diagnoseBasename}.md`);
|
|
1220
|
+
|
|
1221
|
+
const writes = [];
|
|
1222
|
+
writes.push({
|
|
1223
|
+
kind: 'create',
|
|
1224
|
+
absPath: diagnoseAbsPath,
|
|
1225
|
+
content: buildDiagnoseContent(diagnoseId, fileKey, familyKey, name, severity, capture.basename, coreIdea, now),
|
|
1226
|
+
});
|
|
1227
|
+
|
|
1228
|
+
const captureUpdate = buildUpdatedCaptureContentForDiagnose(capture.raw, {
|
|
1229
|
+
diagnoseBasename,
|
|
1230
|
+
now,
|
|
1231
|
+
today,
|
|
1232
|
+
author: 'promote-capture (agent)',
|
|
1233
|
+
});
|
|
1234
|
+
if (captureUpdate.changed) {
|
|
1235
|
+
writes.push({ kind: 'update', absPath: capture.absPath, content: captureUpdate.content });
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
const commitResult = commitWrites(writes);
|
|
1239
|
+
if (!commitResult.ok) {
|
|
1240
|
+
return {
|
|
1241
|
+
error: 'WRITE_FAILED',
|
|
1242
|
+
message: commitResult.error?.message ?? 'unknown write failure',
|
|
1243
|
+
rollback_error: commitResult.error?.rollbackError,
|
|
1244
|
+
};
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
return {
|
|
1248
|
+
phase: 'executed',
|
|
1249
|
+
diagnose_id: diagnoseId,
|
|
1250
|
+
diagnose_basename: diagnoseBasename,
|
|
1251
|
+
diagnose_path: diagnoseRelPath,
|
|
1252
|
+
diagnose_created: true,
|
|
1253
|
+
severity,
|
|
1254
|
+
capture: {
|
|
1255
|
+
capture_id: captureId,
|
|
1256
|
+
capture_basename: capture.basename,
|
|
1257
|
+
informs_diagnose_added: captureUpdate.changed,
|
|
1258
|
+
promoted_to_set: captureUpdate.setPromotedTo,
|
|
1259
|
+
},
|
|
1260
|
+
writes: writes.map((w) => ({ kind: w.kind, path: w.absPath })),
|
|
1261
|
+
human_review_required: false,
|
|
1262
|
+
};
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
export const __testables = {
|
|
1266
|
+
validateInputShape,
|
|
1267
|
+
findCaptureByIdLocal,
|
|
1268
|
+
findResearchByIdLocal,
|
|
1269
|
+
findResearchByBasename,
|
|
1270
|
+
collectOccupiedResearchIds,
|
|
1271
|
+
collectOccupiedDiagnoseIds,
|
|
1272
|
+
extractCaptureCoreIdea,
|
|
1273
|
+
buildDiagnoseContent,
|
|
1274
|
+
buildResearchStubContent,
|
|
1275
|
+
buildUpdatedResearchContent,
|
|
1276
|
+
buildUpdatedCaptureContent,
|
|
1277
|
+
resolveEnhancesBasename,
|
|
1278
|
+
resolveResearchType,
|
|
1279
|
+
inheritedFamilyKey,
|
|
1280
|
+
};
|