heyspex 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +157 -0
  2. package/bin/hs.mjs +1028 -0
  3. package/bin/postinstall.mjs +88 -0
  4. package/dev-factory/lib/fleet-readiness.mjs +274 -0
  5. package/dev-factory/lib/fleet-state.mjs +250 -0
  6. package/dev-factory/lib/sufficiency-score.mjs +441 -0
  7. package/dist/cli/hs-check.mjs +747 -0
  8. package/dist/cli/hs-claim.mjs +541 -0
  9. package/dist/cli/hs-connect.mjs +3436 -0
  10. package/dist/cli/hs-defer.mjs +8455 -0
  11. package/dist/cli/hs-env.mjs +36845 -0
  12. package/dist/cli/hs-init.mjs +1145 -0
  13. package/dist/cli/hs-install.mjs +1390 -0
  14. package/dist/cli/hs-join.mjs +38553 -0
  15. package/dist/cli/hs-vault-pull.mjs +7782 -0
  16. package/dist/cli/hs-vault.mjs +4166 -0
  17. package/dist/cli/hs-workspace.mjs +2898 -0
  18. package/dist/cli-templates/customer-vault/specs/entity-model.md +1735 -0
  19. package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +531 -0
  20. package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +48 -0
  21. package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +548 -0
  22. package/docs/mcp-client-config.md +200 -0
  23. package/package.json +124 -0
  24. package/scripts/check-mcp-freshness.mjs +379 -0
  25. package/scripts/dev-server-status.mjs +722 -0
  26. package/scripts/gateway-skills-lib.mjs +421 -0
  27. package/scripts/lib/console-url-resolver.mjs +152 -0
  28. package/scripts/lib/dev-mode.mjs +369 -0
  29. package/scripts/lib/hs-link.mjs +154 -0
  30. package/scripts/lib/hs-source-commit.mjs +207 -0
  31. package/scripts/lib/hs-source-log.mjs +366 -0
  32. package/scripts/lib/hs-source-preflight.mjs +402 -0
  33. package/scripts/lib/hs-source-push.mjs +307 -0
  34. package/scripts/lib/hs-source-secret-scan.mjs +263 -0
  35. package/scripts/lib/standalone-doctor.mjs +89 -0
  36. package/scripts/vault-id-lib.mjs +173 -0
  37. package/vendor/heyspex-mcp/alignment-gate.mjs +1347 -0
  38. package/vendor/heyspex-mcp/blocker-resolver.mjs +891 -0
  39. package/vendor/heyspex-mcp/breakdown-ladder.mjs +309 -0
  40. package/vendor/heyspex-mcp/breakdown-source.mjs +236 -0
  41. package/vendor/heyspex-mcp/breakdown-writer.mjs +524 -0
  42. package/vendor/heyspex-mcp/brief-observability.mjs +67 -0
  43. package/vendor/heyspex-mcp/canvas-inbox.mjs +130 -0
  44. package/vendor/heyspex-mcp/capture-active-rehydrate.mjs +76 -0
  45. package/vendor/heyspex-mcp/capture-buffer-patches.mjs +147 -0
  46. package/vendor/heyspex-mcp/capture-channel.mjs +577 -0
  47. package/vendor/heyspex-mcp/capture-console-client.mjs +290 -0
  48. package/vendor/heyspex-mcp/capture-focus.mjs +1366 -0
  49. package/vendor/heyspex-mcp/capture-kind-suggest.mjs +121 -0
  50. package/vendor/heyspex-mcp/capture-outbox.mjs +239 -0
  51. package/vendor/heyspex-mcp/capture-stage-gate.mjs +160 -0
  52. package/vendor/heyspex-mcp/cli.mjs +15754 -0
  53. package/vendor/heyspex-mcp/config-settings.mjs +419 -0
  54. package/vendor/heyspex-mcp/console-fetch.mjs +342 -0
  55. package/vendor/heyspex-mcp/defer-spec.mjs +527 -0
  56. package/vendor/heyspex-mcp/deferred-reclaim.mjs +284 -0
  57. package/vendor/heyspex-mcp/design-tools.mjs +449 -0
  58. package/vendor/heyspex-mcp/dev-factory-bridge.mjs +66 -0
  59. package/vendor/heyspex-mcp/diagnose-skeleton.mjs +114 -0
  60. package/vendor/heyspex-mcp/fleet-queue-tools.mjs +241 -0
  61. package/vendor/heyspex-mcp/hooks-engine.mjs +399 -0
  62. package/vendor/heyspex-mcp/login-handoff.mjs +438 -0
  63. package/vendor/heyspex-mcp/mcp-subprocess.mjs +136 -0
  64. package/vendor/heyspex-mcp/mvp-mode.mjs +101 -0
  65. package/vendor/heyspex-mcp/next-resolution.mjs +240 -0
  66. package/vendor/heyspex-mcp/next-review.mjs +548 -0
  67. package/vendor/heyspex-mcp/peer-presence.mjs +430 -0
  68. package/vendor/heyspex-mcp/plan-ground-conflicts.mjs +321 -0
  69. package/vendor/heyspex-mcp/plan-ground.mjs +2001 -0
  70. package/vendor/heyspex-mcp/plan-ingest.mjs +476 -0
  71. package/vendor/heyspex-mcp/plan-push.mjs +1826 -0
  72. package/vendor/heyspex-mcp/plan-review.mjs +1419 -0
  73. package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
  74. package/vendor/heyspex-mcp/procedure-tools.mjs +970 -0
  75. package/vendor/heyspex-mcp/promote-capture.mjs +1280 -0
  76. package/vendor/heyspex-mcp/promote-diagnose.mjs +187 -0
  77. package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +338 -0
  78. package/vendor/heyspex-mcp/promote-research.mjs +1553 -0
  79. package/vendor/heyspex-mcp/promotion-helpers.mjs +280 -0
  80. package/vendor/heyspex-mcp/read-path.mjs +138 -0
  81. package/vendor/heyspex-mcp/recipe-tools.mjs +258 -0
  82. package/vendor/heyspex-mcp/regen-tasks.mjs +326 -0
  83. package/vendor/heyspex-mcp/register-spec.mjs +417 -0
  84. package/vendor/heyspex-mcp/research-focus.mjs +476 -0
  85. package/vendor/heyspex-mcp/research-tools.mjs +109 -0
  86. package/vendor/heyspex-mcp/research-type-suggest.mjs +112 -0
  87. package/vendor/heyspex-mcp/resolution-focus.mjs +45 -0
  88. package/vendor/heyspex-mcp/review-focus.mjs +55 -0
  89. package/vendor/heyspex-mcp/review-rollup.mjs +47 -0
  90. package/vendor/heyspex-mcp/run-front.mjs +60 -0
  91. package/vendor/heyspex-mcp/scheduler.mjs +813 -0
  92. package/vendor/heyspex-mcp/seat-identity.mjs +281 -0
  93. package/vendor/heyspex-mcp/settings-tools.mjs +119 -0
  94. package/vendor/heyspex-mcp/spec-id-lib.mjs +790 -0
  95. package/vendor/heyspex-mcp/split-research.mjs +832 -0
  96. package/vendor/heyspex-mcp/submit-for-review.mjs +190 -0
  97. package/vendor/heyspex-mcp/summary-contract.mjs +162 -0
  98. package/vendor/heyspex-mcp/test-helpers.mjs +43 -0
  99. package/vendor/heyspex-mcp/validate-linkage.mjs +574 -0
  100. package/vendor/heyspex-mcp/vault-resolve.mjs +297 -0
  101. package/vendor/heyspex-mcp/vault-settings.mjs +87 -0
  102. package/vendor/heyspex-mcp/verify-reproduction.mjs +314 -0
  103. package/vendor/heyspex-mcp/why-now.mjs +64 -0
  104. package/vendor/heyspex-mcp/yaml-scalar.mjs +34 -0
@@ -0,0 +1,1366 @@
1
+ /**
2
+ * Capture-on-deck — parallel pin to focus.yaml for epics.
3
+ * [[idea-capture-guardrails]] · MCP prompt `/hs-capture` (legacy alias: `/hs-idea`)
4
+ *
5
+ * Renamed from idea-focus.mjs by [[HS-TASK-SV2K-capture-rename-mcp-tools-and-slash-commands]].
6
+ * - Storage: `.heyspex/capture-focus.yaml` (legacy `.heyspex/idea-focus.yaml` auto-migrated on first load)
7
+ * - Bucket: `specs/captures/` writing `HS-CAP-*` ids with `type: capture` + `kind: idea`
8
+ * - Legacy `HS-IDEA-*` + `specs/captures/` paths still honored for read until [[HS-TASK-9HVY]] (E2)
9
+ */
10
+
11
+ import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, renameSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import { mintFileKey, mintFamilyKey, parseSpecId } from './spec-id-lib.mjs';
14
+ import { yamlScalar } from './yaml-scalar.mjs';
15
+ import { scoreSufficiency, SUFFICIENCY_THRESHOLD } from '../../dev-factory/lib/sufficiency-score.mjs';
16
+
17
+ export const CAPTURE_CORE_SOFT_MAX = 200;
18
+ export const CAPTURE_CORE_HARD_MAX = 500;
19
+
20
+ /** Word-based summary targets ([[HS-TASK-KR2S]]). */
21
+ export const CAPTURE_SUMMARY_SOFT_WORDS = 150;
22
+ export const CAPTURE_SUMMARY_HARD_WORDS = 250;
23
+ /** Char hard-cap for storage safety (~250 words ≈ 1500 chars). */
24
+ export const CAPTURE_SUMMARY_CHAR_HARD_MAX = 1500;
25
+
26
+ /**
27
+ * Silent fuse for auto-created EMPTY capture scaffolds. An `ensureActiveCapturePin`
28
+ * auto-create (e.g. cli.mjs `/hs-capture` file-mode fallback when the console
29
+ * sessions API is unreachable) writes a bare scaffold file + pin. If the user
30
+ * never adds content, that pin would otherwise stay on deck forever — the
31
+ * HS-CAP-SID6 zombie. The pin carries `expires = now + CAPTURE_PIN_FUSE_MS`;
32
+ * `loadCaptureFocus` nulls it out once past, so an abandoned empty scaffold
33
+ * self-retires from on-deck. The fuse is dropped (see `clearCaptureFocusExpiry`)
34
+ * the instant real content lands, so a USED capture never expires. Only ever
35
+ * un-pins — the markdown file is left on disk, never deleted.
36
+ *
37
+ * 7 days mirrors the established epic-pin silent-fuse convention. Tunable here.
38
+ */
39
+ export const CAPTURE_PIN_FUSE_MS = 7 * 24 * 60 * 60 * 1000;
40
+
41
+ /** Frontmatter statuses that mean a capture is no longer on deck. */
42
+ const RETIRED_CAPTURE_STATUSES = new Set(['closed', 'superseded', 'abandoned', 'done']);
43
+
44
+ /**
45
+ * [[HS-KFB2-6RGF-DIAG-capture-continuation-mints-a-new-orphan]]: a same-session
46
+ * follow-on to an idea that was JUST promoted should attach to that idea's
47
+ * lineage instead of minting a disconnected orphan. "Recently" is bounded by
48
+ * this window (checked against the retired capture's `updated:` timestamp) —
49
+ * generous enough to cover a working session, short enough that a follow-on
50
+ * days later (unrelated context) does not get force-linked to old research.
51
+ */
52
+ export const RECENT_PROMOTION_LINEAGE_MS = 6 * 60 * 60 * 1000; // 6h
53
+
54
+ /**
55
+ * v3 template — bare section headers, no scaffolding.
56
+ *
57
+ * Per [[HS-EPIC-Q1B7-idea-capture-modal-v3-single-textbox-cle]]: UI affordances
58
+ * (placeholders, helper copy) live in React; agent rules live in /hs-capture
59
+ * SKILL.md. Saved markdown bodies contain only saved data.
60
+ */
61
+ export const CAPTURE_BODY_TEMPLATE = `# Core Idea
62
+
63
+ ## Summary
64
+
65
+ ## Notes
66
+ `;
67
+
68
+ export function captureFocusPath(vaultRoot) {
69
+ return join(vaultRoot, '.heyspex', 'capture-focus.yaml');
70
+ }
71
+
72
+ export function legacyIdeaFocusPath(vaultRoot) {
73
+ return join(vaultRoot, '.heyspex', 'idea-focus.yaml');
74
+ }
75
+
76
+ export function parseSimpleFocusYaml(raw) {
77
+ const state = {};
78
+ for (const line of raw.split(/\r?\n/)) {
79
+ const trimmed = line.trim();
80
+ if (!trimmed || trimmed.startsWith('#')) continue;
81
+ const colon = trimmed.indexOf(':');
82
+ if (colon === -1) continue;
83
+ const key = trimmed.slice(0, colon).trim();
84
+ let val = trimmed.slice(colon + 1).trim();
85
+ val = val.replace(/^["']|["']$/g, '');
86
+ state[key] = val;
87
+ }
88
+ return state;
89
+ }
90
+
91
+ /**
92
+ * One-shot migration: if `.heyspex/idea-focus.yaml` exists and the new
93
+ * `.heyspex/capture-focus.yaml` does not, rewrite legacy keys + paths and
94
+ * persist the new file. Idempotent — second call is a no-op.
95
+ *
96
+ * Mapping:
97
+ * - `idea_spec_path:` → `capture_spec_path:`
98
+ * - `idea_id:` → `capture_id:`
99
+ * - `kind: idea` retained as `kind: idea` (kind field semantics unchanged)
100
+ * - `specs/captures/HS-IDEA-…` in spec_path/idea_spec_path → `specs/captures/HS-CAP-…`
101
+ *
102
+ * Logs a one-line warning to stderr (visible in MCP server logs).
103
+ */
104
+ export function migrateLegacyIdeaFocus(vaultRoot) {
105
+ const legacy = legacyIdeaFocusPath(vaultRoot);
106
+ const next = captureFocusPath(vaultRoot);
107
+ if (!existsSync(legacy)) return { migrated: false, reason: 'no_legacy_file' };
108
+ if (existsSync(next)) {
109
+ return { migrated: false, reason: 'new_file_already_present' };
110
+ }
111
+
112
+ let raw;
113
+ try {
114
+ raw = readFileSync(legacy, 'utf-8');
115
+ } catch (err) {
116
+ return { migrated: false, reason: 'read_failed', error: String(err) };
117
+ }
118
+
119
+ const state = parseSimpleFocusYaml(raw);
120
+ const remapPath = (p) =>
121
+ typeof p === 'string'
122
+ ? p.replace(/^specs\/ideas\//, 'specs/captures/').replace(/HS-IDEA-/g, 'HS-CAP-')
123
+ : p;
124
+
125
+ const remapId = (v) =>
126
+ typeof v === 'string' ? v.replace(/HS-IDEA-/g, 'HS-CAP-') : v;
127
+ const specPath = remapPath(state.spec_path || state.idea_spec_path || state.capture_spec_path || '');
128
+ const captureId = remapId(state.capture_id || state.idea_id || '');
129
+
130
+ const lines = [
131
+ '# HeySpex capture-on-deck — MCP /hs-capture ([[idea-capture-guardrails]])',
132
+ `kind: ${state.kind || 'idea'}`,
133
+ ];
134
+ if (specPath) lines.push(`spec_path: ${specPath}`);
135
+ if (captureId) lines.push(`capture_id: ${captureId}`);
136
+ lines.push(`pinned_at: ${state.pinned_at || new Date().toISOString()}`);
137
+ if (state.reason) lines.push(`reason: ${JSON.stringify(state.reason)}`);
138
+ if (state.expires) lines.push(`expires: ${state.expires}`);
139
+ if (state.claimed_by) lines.push(`claimed_by: ${JSON.stringify(state.claimed_by)}`);
140
+ if (state.claim_session) lines.push(`claim_session: ${JSON.stringify(state.claim_session)}`);
141
+
142
+ mkdirSync(join(vaultRoot, '.heyspex'), { recursive: true });
143
+ writeFileSync(next, `${lines.join('\n')}\n`, 'utf8');
144
+
145
+ try {
146
+ const stamped = `${legacy}.migrated-${new Date().toISOString().replace(/[:.]/g, '-')}`;
147
+ renameSync(legacy, stamped);
148
+ process.stderr.write?.(
149
+ `[heyspex-mcp] migrated ${legacy} → ${next}; backed up legacy file at ${stamped}\n`,
150
+ );
151
+ } catch {
152
+ process.stderr.write?.(
153
+ `[heyspex-mcp] migrated ${legacy} → ${next}; legacy file kept in place (rename failed)\n`,
154
+ );
155
+ }
156
+
157
+ return { migrated: true, from: legacy, to: next };
158
+ }
159
+
160
+ export function loadCaptureFocus(vaultRoot) {
161
+ migrateLegacyIdeaFocus(vaultRoot);
162
+ const p = captureFocusPath(vaultRoot);
163
+ if (!existsSync(p)) return null;
164
+ try {
165
+ const focus = parseSimpleFocusYaml(readFileSync(p, 'utf-8'));
166
+ if (focus.expires) {
167
+ const expiry = new Date(focus.expires);
168
+ if (expiry < new Date()) return null;
169
+ }
170
+ return focus;
171
+ } catch {
172
+ return null;
173
+ }
174
+ }
175
+
176
+ export function writeCaptureFocus(vaultRoot, specPath, reason, claim, opts = {}) {
177
+ const dir = join(vaultRoot, '.heyspex');
178
+ mkdirSync(dir, { recursive: true });
179
+ // Mirror the pinned capture's frontmatter `kind:` into the pin so the on-deck
180
+ // pin reflects the real kind (idea | bug-fix | clip | reference | question |
181
+ // spec-import) rather than a hardcoded `idea`. Defaults to `idea` for the
182
+ // auto-created empty-scaffold path (a fresh capture is always kind: idea).
183
+ // See [[capture-guardrails]] §Capture-on-deck pin / §Import-origin kind.
184
+ const kind = typeof opts.kind === 'string' && opts.kind.trim() ? opts.kind.trim() : 'idea';
185
+ const lines = [
186
+ '# HeySpex capture-on-deck — MCP /hs-capture ([[idea-capture-guardrails]])',
187
+ `kind: ${kind}`,
188
+ `spec_path: ${specPath}`,
189
+ `pinned_at: ${new Date().toISOString()}`,
190
+ ];
191
+ if (reason) lines.push(`reason: ${JSON.stringify(reason)}`);
192
+ // Silent fuse for empty auto-created scaffolds — see CAPTURE_PIN_FUSE_MS.
193
+ if (opts.expires) lines.push(`expires: ${opts.expires}`);
194
+ if (claim?.claimed_by) lines.push(`claimed_by: ${JSON.stringify(claim.claimed_by)}`);
195
+ if (claim?.claim_session) lines.push(`claim_session: ${JSON.stringify(claim.claim_session)}`);
196
+ writeFileSync(captureFocusPath(vaultRoot), `${lines.join('\n')}\n`, 'utf8');
197
+ }
198
+
199
+ /**
200
+ * Write an existing capture-focus pin again but with refreshed claim info.
201
+ * Preserves spec_path, reason, pinned_at. No-op if no pin file exists.
202
+ */
203
+ export function updateCaptureFocusClaim(vaultRoot, claim) {
204
+ const focus = loadCaptureFocus(vaultRoot);
205
+ if (!focus?.spec_path) return false;
206
+ const dir = join(vaultRoot, '.heyspex');
207
+ mkdirSync(dir, { recursive: true });
208
+ const lines = [
209
+ '# HeySpex capture-on-deck — MCP /hs-capture ([[idea-capture-guardrails]])',
210
+ `kind: ${focus.kind || 'idea'}`,
211
+ `spec_path: ${focus.spec_path}`,
212
+ `pinned_at: ${focus.pinned_at || new Date().toISOString()}`,
213
+ ];
214
+ if (focus.reason) lines.push(`reason: ${JSON.stringify(focus.reason)}`);
215
+ // Preserve the empty-scaffold fuse across claim refreshes — dropping it here
216
+ // would let a still-empty auto-created capture become a permanent zombie. The
217
+ // fuse is cleared only when real content lands (clearCaptureFocusExpiry).
218
+ if (focus.expires) lines.push(`expires: ${focus.expires}`);
219
+ if (claim?.claimed_by) lines.push(`claimed_by: ${JSON.stringify(claim.claimed_by)}`);
220
+ if (claim?.claim_session) lines.push(`claim_session: ${JSON.stringify(claim.claim_session)}`);
221
+ writeFileSync(captureFocusPath(vaultRoot), `${lines.join('\n')}\n`, 'utf8');
222
+ return true;
223
+ }
224
+
225
+ /**
226
+ * Drop the `expires` fuse from the current pin once a capture has real content.
227
+ * An auto-created empty scaffold carries a fuse so it self-retires from on-deck
228
+ * if abandoned (the HS-CAP-SID6 zombie); the moment the user adds content the capture is
229
+ * "real" and must never expire. Rewrites the pin preserving every other field.
230
+ *
231
+ * No-op (returns false) if there is no pin, the pin already has no fuse, or it
232
+ * has already expired (loadCaptureFocus returns null past expiry).
233
+ */
234
+ export function clearCaptureFocusExpiry(vaultRoot) {
235
+ const focus = loadCaptureFocus(vaultRoot);
236
+ if (!focus?.spec_path || !focus.expires) return false;
237
+ const lines = [
238
+ '# HeySpex capture-on-deck — MCP /hs-capture ([[idea-capture-guardrails]])',
239
+ `kind: ${focus.kind || 'idea'}`,
240
+ `spec_path: ${focus.spec_path}`,
241
+ `pinned_at: ${focus.pinned_at || new Date().toISOString()}`,
242
+ ];
243
+ if (focus.reason) lines.push(`reason: ${JSON.stringify(focus.reason)}`);
244
+ if (focus.claimed_by) lines.push(`claimed_by: ${JSON.stringify(focus.claimed_by)}`);
245
+ if (focus.claim_session) lines.push(`claim_session: ${JSON.stringify(focus.claim_session)}`);
246
+ writeFileSync(captureFocusPath(vaultRoot), `${lines.join('\n')}\n`, 'utf8');
247
+ return true;
248
+ }
249
+
250
+ const CAPTURE_ID_PREFIXES = ['HS-CAP-', 'HS-IDEA-'];
251
+
252
+ export function findCaptureSpecFile(vaultRoot, specId) {
253
+ const wanted = String(specId || '').trim();
254
+ if (!wanted) return null;
255
+
256
+ for (const [dir, prefixGuard] of [
257
+ ['captures', 'HS-CAP-'],
258
+ ['ideas', 'HS-IDEA-'],
259
+ ]) {
260
+ const root = join(vaultRoot, 'specs', dir);
261
+ if (!existsSync(root)) continue;
262
+ const direct = join('specs', dir, `${wanted}.md`);
263
+ if (existsSync(join(vaultRoot, direct))) return direct;
264
+ try {
265
+ for (const file of readdirSync(root)) {
266
+ if (!file.endsWith('.md')) continue;
267
+ if (file.startsWith(wanted)) return join('specs', dir, file);
268
+ if (!CAPTURE_ID_PREFIXES.some((p) => wanted.startsWith(p))) continue;
269
+ const full = join(root, file);
270
+ const content = readFileSync(full, 'utf-8');
271
+ const idLine = content.match(/^id:\s*([A-Z0-9-]+)/m);
272
+ const legacyLine = content.match(/^legacy_idea_id:\s*([A-Z0-9-]+)/m);
273
+ if (idLine && idLine[1] === wanted) return join('specs', dir, file);
274
+ if (legacyLine && legacyLine[1] === wanted) return join('specs', dir, file);
275
+ }
276
+ } catch {
277
+ /* ignore and try next bucket */
278
+ }
279
+ void prefixGuard;
280
+ }
281
+ return null;
282
+ }
283
+
284
+ const NOTES_SECTION_RE = /^##\s*Notes\b/im;
285
+
286
+ /** Summary brief — word-based soft/hard + char storage cap ([[HS-TASK-KR2S]]). */
287
+ export const CAPTURE_SUMMARY_SOFT_MAX = CAPTURE_SUMMARY_SOFT_WORDS;
288
+ export const CAPTURE_SUMMARY_HARD_MAX = CAPTURE_SUMMARY_HARD_WORDS;
289
+
290
+ /** @deprecated renamed to CAPTURE_SUMMARY_SOFT_WORDS (HS-EPIC-OED2 → HS-TASK-KR2S). */
291
+ export const CAPTURE_CONCEPT_SOFT_MAX = CAPTURE_SUMMARY_SOFT_WORDS;
292
+ /** @deprecated renamed to CAPTURE_SUMMARY_HARD_WORDS (HS-EPIC-OED2 → HS-TASK-KR2S). */
293
+ export const CAPTURE_CONCEPT_HARD_MAX = CAPTURE_SUMMARY_HARD_WORDS;
294
+
295
+ function countWords(text) {
296
+ const trimmed = String(text ?? '').trim();
297
+ if (!trimmed) return 0;
298
+ return trimmed.split(/\s+/).length;
299
+ }
300
+
301
+ /** Heading text for the agent's deep-dive section (replaces the legacy <details>Details block). */
302
+ export const CAPTURE_EXPANDED_CONCEPT_HEADING = 'Expanded Concept';
303
+
304
+ /** @deprecated v2 <details> summary text — kept only for back-compat reads. */
305
+ export const CAPTURE_DETAILS_SUMMARY = 'Details';
306
+
307
+ /** Legacy summary text (kept for back-compat reads). */
308
+ export const EXPANDED_IDEA_SUMMARY = 'Expanded idea (agent)';
309
+
310
+ // Legacy back-compat read matcher: the pre-OED2 deep-dive lived in a
311
+ // `<details><summary>Details</summary>…</details>` block. New writes use a
312
+ // `## Expanded Concept` markdown section; this regex only recognizes the old
313
+ // form on read. Prefix-match on the summary so customized legacy summaries
314
+ // (e.g. "Expanded idea (agent) — full workflow spec" in HS-CAP-9PKD) still parse.
315
+ const DETAILS_BLOCK_RE =
316
+ /<details>\s*\n<summary>\s*(?:Details|Expanded idea \(agent\))[^<\n]*<\/summary>[\s\S]*?<\/details>/i;
317
+
318
+ /** Back-compat alias — older callers reference this name. */
319
+ export const EXPANDED_IDEA_DETAILS_RE = DETAILS_BLOCK_RE;
320
+
321
+ /**
322
+ * Read the agent deep-dive. Prefers the new `## Expanded Concept` markdown
323
+ * section; falls back to the legacy `<details>Details</details>` block.
324
+ */
325
+ export function extractAgentExpansionFromBody(body) {
326
+ const normalized = body.replace(/\r\n/g, '\n');
327
+ const section = normalized.match(
328
+ /##[ \t]*Expanded[ \t]+Concept[ \t]*\n([\s\S]*?)(?=\n##\s|\n#\s|$)/i,
329
+ );
330
+ if (section) return stripLeadingHtmlComments(section[1]);
331
+ const m = normalized.match(DETAILS_BLOCK_RE);
332
+ if (!m) return '';
333
+ const inner = m[0].match(/<summary>[\s\S]*?<\/summary>\s*\n+([\s\S]*?)<\/details>/i);
334
+ return inner?.[1]?.trim() ?? '';
335
+ }
336
+
337
+ /** Read the `## Expanded Concept` deep-dive (alias of extractAgentExpansionFromBody). */
338
+ export const extractCaptureExpandedConceptFromBody = extractAgentExpansionFromBody;
339
+
340
+ /** @deprecated renamed to extractCaptureExpandedConceptFromBody (HS-EPIC-OED2). */
341
+ export const extractCaptureDetailsFromBody = extractAgentExpansionFromBody;
342
+
343
+ /** Build the `## Expanded Concept` section (replaces the legacy <details> block). */
344
+ export function buildAgentExpansionBlock(content) {
345
+ let inner = String(content ?? '').trim();
346
+ if (!inner) return '';
347
+ // Legacy callers may hand us a raw <details> blob — unwrap to inner content.
348
+ if (/<details>/i.test(inner)) {
349
+ inner = extractAgentExpansionFromBody(inner) || inner;
350
+ }
351
+ return `\n## ${CAPTURE_EXPANDED_CONCEPT_HEADING}\n\n${inner}\n`;
352
+ }
353
+
354
+ export function stripAgentExpansionFromBody(body) {
355
+ return body.replace(/\r\n/g, '\n').replace(DETAILS_BLOCK_RE, '').trimEnd();
356
+ }
357
+
358
+ function stripLeadingHtmlComments(text) {
359
+ return text.replace(/^(?:\s*<!--[\s\S]*?-->\s*\n+)+/, '').trim();
360
+ }
361
+
362
+ export function extractCaptureCoreFromBody(body) {
363
+ const normalized = body.replace(/\r\n/g, '\n');
364
+ // Use `[ \t]*\n` (non-newline whitespace + single newline) so empty sections —
365
+ // bare headers with no body content — match correctly. The original `\s*\n+`
366
+ // form was effectively `\n+` because `\s` already matches `\n`, so it would
367
+ // swallow the blank line between `# Core Idea` and `## Notes`, leaving no `\n`
368
+ // for the `(?=\n##\s)` lookahead and forcing the capture to absorb the next
369
+ // heading. See [[HS-TASK-Z9KG-a1-strip-body-templates-console-mcp]].
370
+ const coreMatch = normalized.match(
371
+ /#[ \t]*Core[ \t]+[Ii]dea[ \t]*\n([\s\S]*?)(?=\n##\s|\n#\s|$)/,
372
+ );
373
+ if (coreMatch) return coreMatch[1].trim();
374
+ const beforeNotes = normalized.split(NOTES_SECTION_RE)[0] ?? normalized;
375
+ return beforeNotes.replace(/^#\s+[^\n]+\n+/m, '').trim();
376
+ }
377
+
378
+ export function extractCaptureNotesFromBody(body) {
379
+ const withoutDetails = stripAgentExpansionFromBody(body);
380
+ const m = withoutDetails.match(/##[ \t]*Notes[ \t]*\n([\s\S]*?)(?=\n##\s|$)/i);
381
+ return m ? stripLeadingHtmlComments(m[1]) : '';
382
+ }
383
+
384
+ /** Reads the `## Summary` section (legacy `## Concept` honored on read). */
385
+ export function extractCaptureSummaryFromBody(body) {
386
+ const withoutDetails = stripAgentExpansionFromBody(body);
387
+ const m =
388
+ withoutDetails.match(/##[ \t]*Summary[ \t]*\n([\s\S]*?)(?=\n##\s|$)/i) ??
389
+ withoutDetails.match(/##[ \t]*Concept[ \t]*\n([\s\S]*?)(?=\n##\s|$)/i);
390
+ return m ? stripLeadingHtmlComments(m[1]) : '';
391
+ }
392
+
393
+ /** @deprecated renamed to extractCaptureSummaryFromBody (HS-EPIC-OED2). */
394
+ export const extractCaptureConceptFromBody = extractCaptureSummaryFromBody;
395
+
396
+ /**
397
+ * Reads the `## Example` section — a real verbatim example (labeled as such) or an
398
+ * AI-authored illustrative one. Slots between Notes and Expanded Concept, so the
399
+ * `(?=\n##\s|$)` lookahead stops it cleanly at `## Expanded Concept`. Mirrors the
400
+ * console reader so an MCP update preserves Example instead of dropping it on
401
+ * rebuild (extract-then-rebuild — [[HS-PR20-EPIC]]).
402
+ */
403
+ export function extractCaptureExampleFromBody(body) {
404
+ const normalized = body.replace(/\r\n/g, '\n');
405
+ const m = normalized.match(/##[ \t]*Example[ \t]*\n([\s\S]*?)(?=\n##\s|$)/i);
406
+ return m ? stripLeadingHtmlComments(m[1]) : '';
407
+ }
408
+
409
+ /**
410
+ * Reads the `## User Stories` section — "As a …, I want …, so that …" statements or
411
+ * free-form user-goal lines. Slots between Example and Expanded Concept. Mirrors the
412
+ * console reader so an MCP update preserves User Stories instead of dropping it on
413
+ * rebuild (owner directive 2026-07-10; entity-type design stays [[HS-USTY-EPIC]]).
414
+ */
415
+ export function extractCaptureUserStoriesFromBody(body) {
416
+ const normalized = body.replace(/\r\n/g, '\n');
417
+ const m = normalized.match(/##[ \t]*User[ \t]+Stories[ \t]*\n([\s\S]*?)(?=\n##\s|$)/i);
418
+ return m ? stripLeadingHtmlComments(m[1]) : '';
419
+ }
420
+
421
+ /**
422
+ * Reads the `## Done When` checklist — how the idea will be verified done. Slots
423
+ * between User Stories and Expanded Concept (mirrors the console materializer).
424
+ * The heyspex_update_capture schema has promised a `done_when` arg since the
425
+ * HS-NR43 gate remediation pointed at it; this reader (plus the writer wiring in
426
+ * executeUpdateCapture / buildCaptureBody) is what actually persists it —
427
+ * [[HS-AYCX-T9T6-TASK-grounding-time-sufficiency-check]].
428
+ */
429
+ export function extractCaptureDoneWhenFromBody(body) {
430
+ const normalized = body.replace(/\r\n/g, '\n');
431
+ const m = normalized.match(/##[ \t]*Done[ \t]+When[ \t]*\n([\s\S]*?)(?=\n##\s|$)/i);
432
+ return m ? stripLeadingHtmlComments(m[1]) : '';
433
+ }
434
+
435
+ /**
436
+ * Reads the `## JTBD (job-to-be-done)` section — the underlying job the user is
437
+ * trying to get done, authored by the `/hs-capture` enhancement pass. Slots
438
+ * between User Stories and Done When (mirrors the console reader/materializer).
439
+ * Replaces the interim `JTBD (job-to-be-done):` leading block inside
440
+ * `## Expanded Concept` per the 2026-08-23 owner ruling —
441
+ * [[HS-TIE7-PR20-TASK-jtbd-job-to-be-done-field-on-captures]] /
442
+ * [[research-capture-protocol]] §"JTBD (job-to-be-done) framing".
443
+ */
444
+ export function extractCaptureJtbdFromBody(body) {
445
+ const normalized = body.replace(/\r\n/g, '\n');
446
+ const m = normalized.match(
447
+ /##[ \t]*JTBD[ \t]*\(job-to-be-done\)[ \t]*\n([\s\S]*?)(?=\n##\s|$)/i,
448
+ );
449
+ return m ? stripLeadingHtmlComments(m[1]) : '';
450
+ }
451
+
452
+ const SOURCE_FENCE_RE = /```source\b[^\n]*\n([\s\S]*?)\n```/i;
453
+
454
+ /**
455
+ * Reads the verbatim content of `## Source Material` — the inner ```source fence
456
+ * (captured external article/transcript/clipboard), or the raw section text when
457
+ * the fence is missing (hand-authored captures). Mirrors the console reader so an
458
+ * MCP update preserves Source Material instead of dropping it on rebuild.
459
+ */
460
+ export function extractSourceMaterialFromBody(body) {
461
+ const withoutDetails = stripAgentExpansionFromBody(body);
462
+ const section = withoutDetails.match(
463
+ /##[ \t]*Source[ \t]+Material[ \t]*\n([\s\S]*?)(?=\n##\s|$)/i,
464
+ );
465
+ if (!section) return '';
466
+ const fence = section[1].match(SOURCE_FENCE_RE);
467
+ return fence ? fence[1] : stripLeadingHtmlComments(section[1]);
468
+ }
469
+
470
+ /**
471
+ * Build the capture body in summaries-first order:
472
+ * **Core Idea → Summary → Notes → Example → User Stories → JTBD → Done When →
473
+ * Expanded Concept → Source Material**. Byte-identical to the console
474
+ * `buildIdeaBody`.
475
+ *
476
+ * Legacy 3-arg signature `(core, notes, agentExpansion)` still works: when the
477
+ * 3rd arg looks like a `<details>` blob, it is routed to the Expanded Concept
478
+ * slot and Summary is left empty. `example` / `userStories` / `doneWhen` /
479
+ * `jtbd` are trailing params so legacy positional calls are unaffected.
480
+ */
481
+ export function buildCaptureBody(
482
+ core,
483
+ notes = '',
484
+ summaryOrAgentExpansion = '',
485
+ agentExpansion = '',
486
+ sourceMaterial = '',
487
+ example = '',
488
+ userStories = '',
489
+ doneWhen = '',
490
+ jtbd = '',
491
+ ) {
492
+ const looksLikeDetails =
493
+ typeof summaryOrAgentExpansion === 'string' && /<details>/i.test(summaryOrAgentExpansion);
494
+ const summary = looksLikeDetails ? '' : summaryOrAgentExpansion;
495
+ const expandedContent = looksLikeDetails ? summaryOrAgentExpansion : agentExpansion;
496
+
497
+ const coreTrim = String(core ?? '').trim();
498
+ const notesTrim = String(notes ?? '').trim();
499
+ const summaryTrim = String(summary ?? '').trim();
500
+ const exampleTrim = String(example ?? '').trim();
501
+ // Source material preserves internal whitespace verbatim — trim only outer newlines.
502
+ const sourceTrim = String(sourceMaterial ?? '')
503
+ .replace(/\r\n/g, '\n')
504
+ .replace(/^\n+|\n+$/g, '');
505
+
506
+ // Empty core → bare `# Core Idea` header with no body. Parallel to the
507
+ // console `buildIdeaBody` fix in [[HS-TASK-4XAN]] — the italic
508
+ // `_Refine via /hs-capture_` placeholder forced fresh raw captures to
509
+ // look expanded to the [[HS-TASK-P2WA]] B3 save-time linter.
510
+ let out = '# Core Idea\n';
511
+ if (coreTrim) out += `\n${coreTrim}\n`;
512
+ out += '\n## Summary\n';
513
+ if (summaryTrim) out += `\n${summaryTrim}\n`;
514
+ out += '\n## Notes\n';
515
+ if (notesTrim) out += `\n${notesTrim}\n`;
516
+
517
+ // Example — conditional (like Expanded Concept/Source Material): emit the
518
+ // header only when populated so existing captures don't gain an empty header
519
+ // on save. A real verbatim example (labeled real) or an AI-authored
520
+ // illustrative one (labeled as such); the "always populated" intent is an
521
+ // expand-time authoring rule, not an always-present empty header.
522
+ if (exampleTrim) out += `\n## Example\n\n${exampleTrim}\n`;
523
+
524
+ // User Stories — conditional (same rule as Example); slots between Example and
525
+ // Expanded Concept. Mirrors the console buildIdeaBody.
526
+ const userStoriesTrim = String(userStories ?? '').trim();
527
+ if (userStoriesTrim) out += `\n## User Stories\n\n${userStoriesTrim}\n`;
528
+
529
+ // JTBD (job-to-be-done) — conditional (same rule as Example/User Stories);
530
+ // slots between User Stories and Done When. Authored by the /hs-capture
531
+ // enhancement pass — replaces the interim Expanded-Concept-leading-block
532
+ // convention (owner ruling 2026-08-23) — [[HS-TIE7-PR20-TASK]].
533
+ const jtbdTrim = String(jtbd ?? '').trim();
534
+ if (jtbdTrim) out += `\n## JTBD (job-to-be-done)\n\n${jtbdTrim}\n`;
535
+
536
+ // Done When — conditional; slots between User Stories and Expanded Concept
537
+ // (mirrors the console materializer). The sufficiency scorer's done-criteria
538
+ // element reads this section.
539
+ const doneWhenTrim = String(doneWhen ?? '').trim();
540
+ if (doneWhenTrim) out += `\n## Done When\n\n${doneWhenTrim}\n`;
541
+
542
+ // Expanded Concept — conditional (opt-in deep dive); unwraps any legacy <details> blob.
543
+ const expandedBlock = buildAgentExpansionBlock(expandedContent);
544
+ if (expandedBlock) out += expandedBlock.startsWith('\n') ? expandedBlock : `\n${expandedBlock}`;
545
+
546
+ // Source Material — conditional, LAST.
547
+ if (sourceTrim) {
548
+ out += `\n## Source Material\n\n\`\`\`source\n${sourceTrim}\n\`\`\`\n`;
549
+ }
550
+ return out;
551
+ }
552
+
553
+ export function validateSummaryLength(summary) {
554
+ const trimmed = String(summary ?? '').trim();
555
+ const len = trimmed.length;
556
+ const words = countWords(trimmed);
557
+ if (len === 0) return { ok: true, summaryLength: 0, summaryWordCount: 0 };
558
+ if (len > CAPTURE_SUMMARY_CHAR_HARD_MAX) {
559
+ return {
560
+ ok: false,
561
+ summaryLength: len,
562
+ summaryWordCount: words,
563
+ error: `Summary is ${len} chars (max ${CAPTURE_SUMMARY_CHAR_HARD_MAX}). The Summary is a brief paragraph — trim or move depth into Expanded Concept.`,
564
+ };
565
+ }
566
+ if (words > CAPTURE_SUMMARY_HARD_WORDS) {
567
+ return {
568
+ ok: false,
569
+ summaryLength: len,
570
+ summaryWordCount: words,
571
+ error: `Summary is ${words} words (max ${CAPTURE_SUMMARY_HARD_WORDS}). The Summary is a brief paragraph — trim or move depth into Expanded Concept.`,
572
+ };
573
+ }
574
+ const warning =
575
+ words > CAPTURE_SUMMARY_SOFT_WORDS
576
+ ? `Summary is ${words} words — aim for ≤${CAPTURE_SUMMARY_SOFT_WORDS} (one paragraph). Depth belongs in Expanded Concept.`
577
+ : undefined;
578
+ return { ok: true, summaryLength: len, summaryWordCount: words, warning };
579
+ }
580
+
581
+ /** @deprecated renamed to validateSummaryLength (HS-EPIC-OED2). */
582
+ export const validateConceptLength = validateSummaryLength;
583
+
584
+ export function formatUserNoteEntry(iso, role, text) {
585
+ const clean = text.trim().replace(/\n+/g, ' ');
586
+ const label = role === 'user' ? 'you' : role;
587
+ return `- **${iso} · ${label}:** "${clean}"`;
588
+ }
589
+
590
+ export function appendCaptureNote(existingNotes, entry) {
591
+ const base = existingNotes.trim();
592
+ if (!base || base.startsWith('_')) {
593
+ return `${entry}\n`;
594
+ }
595
+ return `${base}\n${entry}\n`;
596
+ }
597
+
598
+ export function validateCaptureCore(core) {
599
+ const len = core.trim().length;
600
+ if (len === 0) {
601
+ return { ok: false, error: 'Core idea cannot be empty.' };
602
+ }
603
+ if (len > CAPTURE_CORE_HARD_MAX) {
604
+ return {
605
+ ok: false,
606
+ error: `Core idea is ${len} chars (max ${CAPTURE_CORE_HARD_MAX}). Shorten while keeping the user's phrasing.`,
607
+ };
608
+ }
609
+ const warning =
610
+ len > CAPTURE_CORE_SOFT_MAX
611
+ ? `Core idea is ${len} chars — aim for ≤${CAPTURE_CORE_SOFT_MAX}. Prefer the user's own words.`
612
+ : undefined;
613
+ return { ok: true, warning };
614
+ }
615
+
616
+ export function stringifyCaptureSpec(frontmatter, body) {
617
+ const lines = ['---'];
618
+ for (const [k, v] of Object.entries(frontmatter)) {
619
+ if (v === null || v === undefined) continue;
620
+ if (Array.isArray(v)) {
621
+ lines.push(`${k}:`);
622
+ for (const item of v) lines.push(` - ${JSON.stringify(item)}`);
623
+ } else if (typeof v === 'string') {
624
+ lines.push(`${k}: ${yamlScalar(v)}`);
625
+ } else {
626
+ lines.push(`${k}: ${v}`);
627
+ }
628
+ }
629
+ lines.push('---', '', body.trim(), '');
630
+ return lines.join('\n');
631
+ }
632
+
633
+ export function resolveAgentClaim() {
634
+ const claudeSession = process.env.CLAUDE_SESSION_ID?.trim();
635
+ if (claudeSession) {
636
+ return { claimed_by: 'Claude Code', claim_session: claudeSession };
637
+ }
638
+ const cursorSession =
639
+ process.env.CURSOR_TRACE_ID?.trim() ||
640
+ process.env.CURSOR_SESSION_ID?.trim() ||
641
+ process.env.COMPOSER_SESSION_ID?.trim();
642
+ if (cursorSession) {
643
+ return { claimed_by: 'Cursor', claim_session: cursorSession };
644
+ }
645
+ const explicitAgent = process.env.HEYSPEX_AGENT?.trim();
646
+ const explicitSession =
647
+ process.env.HEYSPEX_SESSION_ID?.trim() || process.env.HEYSPEX_AGENT_SESSION?.trim();
648
+ if (explicitAgent || explicitSession) {
649
+ return {
650
+ claimed_by: explicitAgent || 'External agent',
651
+ claim_session: explicitSession || `mcp-${process.pid}`,
652
+ };
653
+ }
654
+ return { claimed_by: 'External agent', claim_session: `mcp-${process.pid}` };
655
+ }
656
+
657
+ /**
658
+ * Ensure a valid capture pin exists. Auto-creates a fresh HS-CAP-* when none is
659
+ * pinned or the pin points at a missing file. Used by /hs-capture,
660
+ * heyspex_active_capture, and heyspex_update_capture so agents can capture
661
+ * without console pre-setup.
662
+ */
663
+ export function ensureActiveCapturePin(vaultRoot, parseFrontmatter, loadSpec, deps = {}) {
664
+ const {
665
+ allocateRandomVaultId,
666
+ occupiedCaptureIds,
667
+ pinReason = 'Auto-created via MCP',
668
+ claim = null,
669
+ } = deps;
670
+
671
+ let previousError = null;
672
+ let resolved = resolveActiveCapture(vaultRoot, parseFrontmatter, loadSpec);
673
+ if (!resolved.error) {
674
+ if (claim) updateCaptureFocusClaim(vaultRoot, claim);
675
+ return { resolved, fresh: false, auto_created: false };
676
+ }
677
+
678
+ previousError = resolved.error;
679
+ const recoverable =
680
+ resolved.error === 'NO_CAPTURE_PINNED' ||
681
+ resolved.error === 'CAPTURE_NOT_FOUND' ||
682
+ resolved.error === 'CAPTURE_RETIRED';
683
+ if (!recoverable) {
684
+ return { error: resolved.error, message: resolved.message };
685
+ }
686
+ if (!allocateRandomVaultId || !occupiedCaptureIds) {
687
+ return {
688
+ error: 'AUTO_CREATE_UNAVAILABLE',
689
+ message: 'Cannot auto-create capture spec without vault ID allocator.',
690
+ };
691
+ }
692
+
693
+ // HS-KFB2: when the capture being replaced was RECENTLY PROMOTED (not
694
+ // closed/superseded/abandoned), carry its lineage into the fresh capture
695
+ // instead of minting a disconnected orphan — this is exactly the "follow-on
696
+ // to an already-promoted idea" repro from the diagnose.
697
+ const lineage =
698
+ resolved.recentlyPromoted && resolved.promotedTo
699
+ ? { promotedTo: resolved.promotedTo, retiredCaptureBasename: resolved.retiredCaptureBasename }
700
+ : null;
701
+
702
+ const created = createFreshCaptureSpec(vaultRoot, allocateRandomVaultId, occupiedCaptureIds, lineage);
703
+ // Stamp the silent fuse: a freshly auto-created scaffold is empty, so it must
704
+ // self-retire from on-deck if the user never adds content (the SID6 fix). The
705
+ // fuse is dropped the moment real content lands (executeUpdateCapture →
706
+ // clearCaptureFocusExpiry).
707
+ const expires = new Date(Date.now() + CAPTURE_PIN_FUSE_MS).toISOString();
708
+ writeCaptureFocus(vaultRoot, created.spec_path, pinReason, claim, { expires });
709
+ resolved = resolveActiveCapture(vaultRoot, parseFrontmatter, loadSpec);
710
+ if (resolved.error) {
711
+ return {
712
+ error: resolved.error,
713
+ message: resolved.message || 'Auto-create failed after pin write.',
714
+ };
715
+ }
716
+ return {
717
+ resolved,
718
+ fresh: true,
719
+ auto_created: true,
720
+ replaced_stale_pin: previousError === 'CAPTURE_NOT_FOUND',
721
+ // HS-KFB2: surfaced so the calling tool/prompt can tell the agent this new
722
+ // capture continues a just-promoted idea — e.g. "consider whether this
723
+ // belongs appended to <continuation_of> instead of/alongside a new capture"
724
+ // (suggest, never auto-apply — [[HS-5HYS]] elicitation).
725
+ ...(created.continuation_of ? { continuation_of: created.continuation_of } : {}),
726
+ };
727
+ }
728
+
729
+ /**
730
+ * Missing-element key (scorer vocabulary) → the heyspex_update_capture argument
731
+ * that fills it. Mirrors the promote-time gate's how_to_resolve mapping
732
+ * (promote-capture.mjs §1b) so grounding-time and promote-time remediation
733
+ * never disagree.
734
+ */
735
+ export const SUFFICIENCY_MISSING_TO_UPDATE_ARG = {
736
+ 'core-idea': 'core_idea',
737
+ example: 'example',
738
+ 'done-criteria': 'done_when',
739
+ kind: 'kind',
740
+ 'user-stories': 'user_stories',
741
+ };
742
+
743
+ /**
744
+ * Grounding-time sufficiency verdict ([[HS-AYCX-T9T6-TASK-grounding-time-sufficiency-check]]):
745
+ * the SAME scorer and inputs the promote-time gate uses (promote-capture.mjs §1b,
746
+ * [[HS-0CFX-TASK]]), including the already-expanded bypass ([[HS-55YM-DIAG]]) —
747
+ * a populated `## Expanded Concept` means the gate would skip scoring, so it is
748
+ * reported here as a pass with the bypass named instead of re-scoring content
749
+ * the gate would never score. Never throws; a scorer error reports as fail with
750
+ * a `*scorer-error*` marker, matching the gate.
751
+ */
752
+ export function computeCaptureSufficiency(frontmatter, body) {
753
+ const bodyText = String(body ?? '');
754
+ if (extractAgentExpansionFromBody(bodyText).trim().length > 0) {
755
+ return {
756
+ pass: true,
757
+ bypass: 'already-expanded',
758
+ score: 1,
759
+ threshold: SUFFICIENCY_THRESHOLD,
760
+ missing: [],
761
+ };
762
+ }
763
+ let suff;
764
+ try {
765
+ suff = scoreSufficiency({ frontmatter: { kind: frontmatter?.kind ?? undefined }, body: bodyText });
766
+ } catch {
767
+ suff = { pass: false, score: 0, missing: ['*scorer-error*'] };
768
+ }
769
+ const missing = Array.isArray(suff?.missing) ? suff.missing : [];
770
+ const verdict = {
771
+ pass: suff?.pass === true,
772
+ score: suff?.score ?? 0,
773
+ threshold: SUFFICIENCY_THRESHOLD,
774
+ missing,
775
+ };
776
+ if (!verdict.pass) {
777
+ verdict.remediation = missing
778
+ .map((key) => `${key} → ${SUFFICIENCY_MISSING_TO_UPDATE_ARG[key] ?? key}`)
779
+ .join(', ');
780
+ verdict.guidance =
781
+ 'This capture would NOT yet pass the promote sufficiency gate. Elicit the missing elements from the user in THIS pass and write them via heyspex_update_capture (same spec_id): ' +
782
+ verdict.remediation +
783
+ '. Do not offer "promote to research" until the capture passes and the owner confirms satisfaction — never invent content to pass the gate.';
784
+ }
785
+ return verdict;
786
+ }
787
+
788
+ /**
789
+ * @param {string} vaultRoot
790
+ * @param {Function} parseFrontmatter
791
+ * @param {Function} loadSpec
792
+ * @param {object|null} [focusOverride] The capture-on-deck pin to resolve against.
793
+ * Pass the LIVE pin here. The pin moved to the Neon control plane
794
+ * (`capture_focus_pin` + `capture_focus_selection`, read by
795
+ * `readCaptureFocusVendor`); the on-disk `.heyspex/capture-focus.yaml` is now
796
+ * only the offline fallback, written when the console is unreachable. A caller
797
+ * that lets this default to the file read gets whatever the last offline
798
+ * capture left behind — on this repo that was a June pin, already promoted, so
799
+ * every resolve returned CAPTURE_RETIRED and every gate built on it allowed
800
+ * everything. Omit ONLY when there is genuinely no console to ask.
801
+ */
802
+ export function resolveActiveCapture(vaultRoot, parseFrontmatter, loadSpec, focusOverride) {
803
+ const focus = focusOverride === undefined ? loadCaptureFocus(vaultRoot) : focusOverride;
804
+ if (!focus?.spec_path) {
805
+ return {
806
+ error: 'NO_CAPTURE_PINNED',
807
+ message:
808
+ 'No capture on deck. Run /hs-capture or heyspex_update_capture to auto-create, use console + New → Capture, or call heyspex_pin_capture.',
809
+ };
810
+ }
811
+ const spec = loadSpec(vaultRoot, focus.spec_path);
812
+ if (!spec) {
813
+ return { error: 'CAPTURE_NOT_FOUND', message: `Pinned capture missing: ${focus.spec_path}` };
814
+ }
815
+ // Done-status gate: a pinned capture that has been closed/superseded/abandoned
816
+ // or promoted to another spec is no longer on deck. Returning a recoverable
817
+ // error lets ensureActiveCapturePin advance to a fresh capture instead of
818
+ // resurfacing a dead one indefinitely (the materialize re-pin → stuck-pin loop).
819
+ const status = String(spec.frontmatter?.status ?? '').trim().toLowerCase();
820
+ const promotedTo = String(spec.frontmatter?.promoted_to ?? '').trim();
821
+ const isPromoted = promotedTo !== '' && promotedTo !== 'null';
822
+ if (RETIRED_CAPTURE_STATUSES.has(status) || isPromoted) {
823
+ // Lineage carry-over (HS-KFB2): when retirement is because the capture was
824
+ // PROMOTED (not closed/superseded/abandoned), surface enough for the caller
825
+ // to attach a same-session follow-on to that idea's lineage instead of
826
+ // silently minting an orphan. `recentlyPromoted` bounds this to a real
827
+ // continuation, not a stale capture resurfacing weeks later.
828
+ const updatedAt = Date.parse(String(spec.frontmatter?.updated ?? ''));
829
+ const recentlyPromoted =
830
+ isPromoted && Number.isFinite(updatedAt) && Date.now() - updatedAt < RECENT_PROMOTION_LINEAGE_MS;
831
+ return {
832
+ error: 'CAPTURE_RETIRED',
833
+ message: `Pinned capture ${focus.spec_path} is ${
834
+ isPromoted ? `promoted to ${promotedTo}` : status
835
+ } — no longer on deck.`,
836
+ ...(isPromoted
837
+ ? {
838
+ promotedTo,
839
+ recentlyPromoted,
840
+ retiredCaptureId: spec.frontmatter?.id ?? null,
841
+ retiredCaptureBasename: focus.spec_path.replace(/^.*[\\/]/, '').replace(/\.md$/, ''),
842
+ }
843
+ : {}),
844
+ };
845
+ }
846
+ const id = spec.frontmatter?.id;
847
+ const core = extractCaptureCoreFromBody(spec.body);
848
+ const notes = extractCaptureNotesFromBody(spec.body);
849
+ return {
850
+ focus,
851
+ spec_path: focus.spec_path,
852
+ spec_id: id,
853
+ name: spec.frontmatter?.name,
854
+ status: spec.frontmatter?.status,
855
+ core_idea: core,
856
+ notes,
857
+ body: spec.body,
858
+ frontmatter: spec.frontmatter,
859
+ // Grounding-time sufficiency ([[HS-AYCX-T9T6-TASK-grounding-time-sufficiency-check]]):
860
+ // every active-capture read carries the promote gate's verdict so the agent
861
+ // elicits missing elements now instead of discovering them at promote time.
862
+ sufficiency: computeCaptureSufficiency(spec.frontmatter, spec.body),
863
+ };
864
+ }
865
+
866
+ export function buildHsCapturePromptMarkdown(resolved, options = {}) {
867
+ const { spec_id, name, core_idea, notes, spec_path } = resolved;
868
+ const notesBlock = notes.trim() ? notes : '_No notes yet._';
869
+ const isFresh = options.fresh === true;
870
+ // Grounding-time offer gate ([[HS-YPZA-T9T6-TASK-gate-the-promote-to-research-offer-on]]):
871
+ // the promote move is presented as LIVE only when the capture would pass the
872
+ // promote sufficiency gate. Owner intent (HS-3WON, verbatim): "I shouldn't
873
+ // have been prompted to do research if the sufficiency gate hasn't been
874
+ // passed." An explicit owner instruction to promote anyway still works — the
875
+ // gate governs what the SYSTEM offers, never what the owner may command.
876
+ const sufficiency =
877
+ resolved.sufficiency ?? computeCaptureSufficiency(resolved.frontmatter, resolved.body ?? '');
878
+ const sufficiencyPasses = sufficiency?.pass === true;
879
+ const missingList = (sufficiency?.missing ?? [])
880
+ .map((key) => `\`${key}\` (→ \`${SUFFICIENCY_MISSING_TO_UPDATE_ARG[key] ?? key}\`)`)
881
+ .join(', ');
882
+ const promoteMoveLine = sufficiencyPasses
883
+ ? `**promote to research** (\`heyspex_promote_capture\`; \`heyspex_promote_capture_to_diagnose\` for \`kind: bug-fix\`) — offer it ONLY after showing the enhanced capture and the owner confirms they are satisfied with it`
884
+ : `**enhance to readiness** — the capture would NOT yet pass the promote sufficiency gate (missing: ${missingList || 'nothing recorded'}); elicit those from the user and write them via \`heyspex_update_capture\`, and do NOT present promote as a live option until it passes (the owner may still explicitly instruct you to promote anyway)`;
885
+ // [[HS-PG9I-TASK]] — resume branch: when a re-opened capture already carries a
886
+ // Core Idea + Summary, tell the agent this is a RESUME (offer iterate / the
887
+ // gated second move), not a fresh brain-offload. Owner ruling 2026-07-11:
888
+ // grounding and expanding are the same act — the Expanded Concept is
889
+ // auto-written whenever the agent grounds, with no user confirmation, so
890
+ // "expand" is never a resume move to offer.
891
+ const isResume = options.resume === true;
892
+ const freshHeader = isFresh
893
+ ? `_Auto-created \`${spec_id}\` — no prior content. The user's next message will be the first capture._\n\n`
894
+ : isResume
895
+ ? `> **Resume — this capture already has a Core Idea + Summary.** Do **not** re-capture from scratch. ` +
896
+ `Greet the user with what's already here, then offer the two resume moves: ` +
897
+ `**iterate** (refine Core Idea / Summary / Notes with new input, still verbatim in Notes) ` +
898
+ `or ${promoteMoveLine}. ` +
899
+ `Any new words the user types are still captured verbatim into \`## Notes\`. If you ground the idea further, that grounding output is the Expanded Concept — write it to \`agent_expansion\` automatically, no confirmation. There is no separate expand skill and no "want me to expand?" offer — expansion happens automatically whenever you ground._\n\n`
900
+ : '';
901
+ const sufficiencySection = sufficiencyPasses
902
+ ? sufficiency?.bypass === 'already-expanded'
903
+ ? '_Passes (already expanded — the promote gate skips scoring once `## Expanded Concept` is populated)._'
904
+ : '_Passes — promote may be offered once the owner confirms satisfaction with the enhanced capture._'
905
+ : `**Not yet ready** — would bounce at promote. Missing: ${missingList || '_none recorded_'}. Elicit these from the user in this pass and write them via \`heyspex_update_capture\` — never invent content to pass the gate.`;
906
+
907
+ const summaryBlock = extractCaptureSummaryFromBody(resolved.body ?? '');
908
+ const summarySection = summaryBlock.trim()
909
+ ? summaryBlock
910
+ : '_No Summary saved yet._';
911
+
912
+ const expandedBlock = extractCaptureExpandedConceptFromBody(resolved.body ?? '');
913
+ const expandedSection = expandedBlock
914
+ ? `_Expanded Concept saved._\n\n${expandedBlock}`
915
+ : '_No Expanded Concept deep-dive saved yet._';
916
+
917
+ return `# Capture on deck — ${spec_id}
918
+
919
+ **${name || spec_id}** · \`${spec_path}\`
920
+
921
+ ${freshHeader}## Your job — brain-offload capture (not epic/task filing)
922
+
923
+ Works **with or without** a console-pinned capture — auto-creates \`HS-CAP-*\` when none is on deck. Doc order (summaries-first): **# Core Idea** → **## Summary** (brief) → **## Notes** (verbatim) → **## Example** (real or AI-illustrative, labeled) → **## Expanded Concept** (auto-written on grounding) → **## Source Material** ([[HS-EPIC-OED2-capture-model-rewrite-summaries-first-st]]).
924
+
925
+ **Identity:** you are the user's **technical co-founder** — peer-level, honest, knows the codebase and roadmap. Push back when warranted. Keep grounding light and accurate. Anything needing deep verification gets flagged for promote-to-research — **never chased inline**.
926
+
927
+ ### Beat 1 — Capture every turn (confirm-free)
928
+
929
+ Every message the user sends — **including their answers to your own questions** — must be captured verbatim immediately:
930
+
931
+ 1. **Read the current doc first** (below). Never alter words inside an existing quote.
932
+ 2. Call \`heyspex_update_capture\` with \`user_quote\` = verbatim text of this turn. The MCP wraps it as \`- **<ISO> · you:** "verbatim"\`.
933
+ 3. **If Core Idea is empty:** auto-write a 1-sentence Core Idea (≤${CAPTURE_CORE_SOFT_MAX} soft / ${CAPTURE_CORE_HARD_MAX} hard chars) + a brief Summary (≤${CAPTURE_SUMMARY_SOFT_WORDS} soft / ≤${CAPTURE_SUMMARY_HARD_WORDS} hard words, ≤${CAPTURE_SUMMARY_CHAR_HARD_MAX} chars) in the **same \`heyspex_update_capture\` call** — pass \`core_idea\` + \`summary\` alongside \`user_quote\`. **No confirmation gate — just write them.**
934
+ 4. Reply: \`Connected with ${spec_id} — captured.\`
935
+
936
+ ### Beat 2 — Ground = Expand (auto, confirm-free)
937
+
938
+ Grounding and expanding are **the same act** — do not treat them as separate steps. Ground the idea fast and light against the codebase, existing plans, and roadmap, peer tone (push back, be honest), and the moment you do, that grounding output IS the Expanded Concept:
939
+
940
+ - Surface related [[wikilinks]], tensions, fit with current work — in chat, as always.
941
+ - **Immediately** call \`heyspex_update_capture\` with \`agent_expansion\` = that same grounding, written to the \`## Expanded Concept\` section. **No offer, no "want me to expand?" question, no waiting for a yes.** This happens automatically, every time, for any idea given.
942
+ - Deep verification needed? **Flag it for research** — never chase it inline (promote itself stays gated below).
943
+ - **Grounding-time sufficiency gate:** every \`heyspex_update_capture\` response carries a \`sufficiency\` verdict — the same scorer \`heyspex_promote_capture\` runs. While it does **not** pass, present **iterate** plus **enhance to readiness** (elicit the listed missing elements — \`example\` / \`done_when\` / a considered \`kind\` — from the user and write them in this same pass); do **not** present "promote to research" as a live choice. Once it **passes**, show the enhanced capture and ask if the owner is satisfied with it; only after that yes do the two choices become **promote to research** (or promote-to-diagnose for \`kind: bug-fix\`) or **iterate**. Never present "do you want to expand" as a choice — it already happened.
944
+ - **Open-the-doc offer (agent captures only):** if \`heyspex_update_capture\` returned \`offer_open_viewer\` (first materialization of an agent-opened capture), print its \`prompt\` in the same reply. On \`yes\` / \`open\` / \`show\`, run \`node bin/hs.mjs view docs <spec_id>\` immediately — never auto-open, never nag on later iterates. Companion/extension captures auto-open elsewhere; agent captures must ask.
945
+
946
+ ### Beat 3 — Promote to research (the only exit, gated)
947
+
948
+ Research is the **only** downstream action from a capture. Never offer epics, tasks, or plans.
949
+
950
+ - **Confirm-before-offer:** only offer promote once the sufficiency verdict passes (Beat 2 gate). While failing, elicit and write the missing elements instead — the promote-time gate would bounce the call anyway (it remains as defense-in-depth).
951
+ - **Confirm-before-spend:** once passing, show the enhanced capture and ask if the owner is satisfied with it, then offer once: *"Ready to promote to research?"* A promote call is spent only after that yes.
952
+ - Use \`heyspex_promote_capture\` (or console Promote button). For \`kind: bug-fix\`: \`heyspex_promote_capture_to_diagnose\` instead.
953
+ - Do **not** create a \`type: research\` file until the user confirms.
954
+ - An **explicit owner instruction** to promote anyway always wins — pass the elicited fields first when possible; \`force_promote: true\` is reserved for the owner's deliberate override, never a workaround for the gate.
955
+
956
+ ### Sufficiency (grounding-time gate — current verdict)
957
+
958
+ ${sufficiencySection}
959
+
960
+ ### Hard prohibitions
961
+
962
+ - **Never** offer to file epics, tasks, or implementation plans — **at any point**.
963
+ - **Never** create \`type: research\` without beat 3 confirmation.
964
+ - **Never** ask the user whether to expand — Ground=Expand auto-writes \`agent_expansion\` every time; expand is never a separate, user-gated beat.
965
+ - **Never** auto-open a browser / solo window for an agent-originated capture — offer once (\`yes\`/\`open\`/\`show\`) and only then run \`hs view docs <id>\`.
966
+ - **Never** alter words inside a quote in Notes (format-wrapping is fine; word substitution is not).
967
+ - Never add Executive Summary / Findings / Sources to the capture file.
968
+
969
+ ---
970
+
971
+ ## Core Idea (current)
972
+
973
+ ${core_idea || '_Empty — first capture from the user._'}
974
+
975
+ ---
976
+
977
+ ## Summary (brief — ≤${CAPTURE_SUMMARY_HARD_WORDS} words / ${CAPTURE_SUMMARY_CHAR_HARD_MAX} chars)
978
+
979
+ ${summarySection}
980
+
981
+ ---
982
+
983
+ ## Notes (existing — append, do not replace; never alter words inside quotes)
984
+
985
+ ${notesBlock}
986
+
987
+ ---
988
+
989
+ ## Expanded Concept (opt-in deep dive)
990
+
991
+ ${expandedSection}
992
+ `;
993
+ }
994
+
995
+ const RESEARCH_SECTION_RE =
996
+ /^##\s*(Executive Summary|Findings|Sources)\b/im;
997
+
998
+ function rejectResearchSections(text, field) {
999
+ if (!text || !RESEARCH_SECTION_RE.test(String(text))) return null;
1000
+ return {
1001
+ error: 'RESEARCH_SECTION_FORBIDDEN',
1002
+ message: `${field} must not include research sections (Executive Summary, Findings, Sources). Keep captures concise; promote to research in console when ready.`,
1003
+ };
1004
+ }
1005
+
1006
+ /**
1007
+ * The complete, named set of top-level arguments `heyspex_update_capture`
1008
+ * accepts. Mirrors the tool's `inputSchema.properties` keys in cli.mjs — kept
1009
+ * as a standalone list (not derived from the schema) because the schema's
1010
+ * `additionalProperties: false` is never validated at runtime by any layer
1011
+ * (client/server/transport); the only real enforcement is this explicit,
1012
+ * in-code allowlist checked at the handler's first hop —
1013
+ * [[additionalproperties-false-in-an-mcp]].
1014
+ */
1015
+ export const UPDATE_CAPTURE_ACCEPTED_ARGS = [
1016
+ 'spec_id',
1017
+ 'core_idea',
1018
+ 'summary',
1019
+ 'concept',
1020
+ 'user_quote',
1021
+ 'agent_note',
1022
+ 'agent_expansion',
1023
+ 'example',
1024
+ 'user_stories',
1025
+ 'kind',
1026
+ 'done_when',
1027
+ 'jtbd',
1028
+ ];
1029
+
1030
+ /**
1031
+ * Pure verdict on a `heyspex_update_capture` argument bag: refuses an
1032
+ * unrecognized/misspelled key BY NAME instead of letting it silently vanish
1033
+ * into a hand-built payload (the exact failure mode
1034
+ * [[additionalproperties-false-in-an-mcp]] documents — a declared
1035
+ * `additionalProperties: false` inputSchema is decoration, nothing enforces it
1036
+ * at runtime). Call this at the handler's first hop, before any session
1037
+ * resolution or buffer-patch building.
1038
+ */
1039
+ export function updateCaptureArgsVerdict(args = {}) {
1040
+ const unknown = Object.keys(args).filter((k) => !UPDATE_CAPTURE_ACCEPTED_ARGS.includes(k));
1041
+ if (unknown.length > 0) {
1042
+ return {
1043
+ ok: false,
1044
+ error: {
1045
+ error: 'UNKNOWN_FIELD',
1046
+ message: `heyspex_update_capture does not accept: ${unknown.join(', ')}. NOTHING WAS WRITTEN — re-send using only the accepted arguments.`,
1047
+ field: unknown[0],
1048
+ unknown_fields: unknown,
1049
+ accepted_fields: UPDATE_CAPTURE_ACCEPTED_ARGS,
1050
+ },
1051
+ };
1052
+ }
1053
+ return { ok: true };
1054
+ }
1055
+
1056
+ export function executeUpdateCapture(vaultRoot, parseFrontmatter, args = {}, deps = {}) {
1057
+ let specPath = null;
1058
+ let autoCreated = false;
1059
+ let continuationOf = null;
1060
+
1061
+ if (args.spec_id) {
1062
+ specPath = findCaptureSpecFile(vaultRoot, args.spec_id);
1063
+ if (!specPath) {
1064
+ return { error: 'SPEC_NOT_FOUND', message: `Capture ${args.spec_id} not found` };
1065
+ }
1066
+ } else {
1067
+ const ensured = ensureActiveCapturePin(vaultRoot, parseFrontmatter, deps.loadSpec, {
1068
+ allocateRandomVaultId: deps.allocateRandomVaultId,
1069
+ occupiedCaptureIds: deps.occupiedCaptureIds,
1070
+ pinReason: 'Auto-created via heyspex_update_capture',
1071
+ claim: deps.claim ?? null,
1072
+ });
1073
+ if (ensured.error) {
1074
+ return { error: ensured.error, message: ensured.message };
1075
+ }
1076
+ autoCreated = ensured.auto_created === true;
1077
+ continuationOf = ensured.continuation_of ?? null;
1078
+ specPath = ensured.resolved.spec_path;
1079
+ }
1080
+
1081
+ const fullPath = join(vaultRoot, specPath);
1082
+ if (!existsSync(fullPath)) {
1083
+ return { error: 'CAPTURE_FILE_MISSING', message: specPath };
1084
+ }
1085
+
1086
+ // `summary` is the canonical input; `concept` is the deprecated alias (HS-EPIC-OED2).
1087
+ const summaryArg = args.summary ?? args.concept;
1088
+
1089
+ for (const [field, value] of [
1090
+ ['core_idea', args.core_idea],
1091
+ ['user_quote', args.user_quote],
1092
+ ['agent_note', args.agent_note],
1093
+ ['summary', summaryArg],
1094
+ ['example', args.example],
1095
+ ['done_when', args.done_when],
1096
+ ['jtbd', args.jtbd],
1097
+ ]) {
1098
+ const blocked = rejectResearchSections(value, field);
1099
+ if (blocked) return blocked;
1100
+ }
1101
+
1102
+ const content = readFileSync(fullPath, 'utf-8');
1103
+ const { frontmatter, body } = parseFrontmatter(content);
1104
+ let core = extractCaptureCoreFromBody(body);
1105
+ let notes = extractCaptureNotesFromBody(body);
1106
+ let summary = extractCaptureSummaryFromBody(body);
1107
+ let example = extractCaptureExampleFromBody(body);
1108
+ let doneWhen = extractCaptureDoneWhenFromBody(body);
1109
+ let jtbd = extractCaptureJtbdFromBody(body);
1110
+ const iso = new Date().toISOString();
1111
+
1112
+ // Track whether this update writes to any of the "expansion" surfaces —
1113
+ // core_idea / summary / agent_expansion. When true, capture_status flips
1114
+ // raw → expanded after persistence. Note-only and quote-only writes do NOT
1115
+ // flip the field — those layers can be appended in the raw single-textarea
1116
+ // flow without promoting the capture out of raw state.
1117
+ let didExpand = false;
1118
+
1119
+ if (args.user_quote && String(args.user_quote).trim()) {
1120
+ const entry = formatUserNoteEntry(iso, 'you', String(args.user_quote));
1121
+ notes = appendCaptureNote(notes, entry);
1122
+ }
1123
+
1124
+ if (args.agent_note && String(args.agent_note).trim()) {
1125
+ const entry = formatUserNoteEntry(iso, 'agent', String(args.agent_note));
1126
+ notes = appendCaptureNote(notes, entry);
1127
+ }
1128
+
1129
+ if (args.core_idea !== undefined && String(args.core_idea).trim()) {
1130
+ const nextCore = String(args.core_idea).trim();
1131
+ const check = validateCaptureCore(nextCore);
1132
+ if (!check.ok) return { error: 'CORE_TOO_LONG', message: check.error };
1133
+ core = nextCore;
1134
+ didExpand = true;
1135
+ }
1136
+
1137
+ let summaryWarning;
1138
+ if (summaryArg !== undefined && String(summaryArg).trim()) {
1139
+ const nextSummary = String(summaryArg).trim();
1140
+ const check = validateSummaryLength(nextSummary);
1141
+ if (!check.ok) return { error: 'SUMMARY_TOO_LONG', message: check.error };
1142
+ if (check.warning) summaryWarning = check.warning;
1143
+ summary = nextSummary;
1144
+ didExpand = true;
1145
+ }
1146
+
1147
+ let agentExpansion = extractAgentExpansionFromBody(body);
1148
+ if (args.agent_expansion !== undefined && String(args.agent_expansion).trim()) {
1149
+ const blocked = rejectResearchSections(args.agent_expansion, 'agent_expansion');
1150
+ if (blocked) return blocked;
1151
+ agentExpansion = String(args.agent_expansion).trim();
1152
+ didExpand = true;
1153
+ }
1154
+
1155
+ if (args.example !== undefined && String(args.example).trim()) {
1156
+ example = String(args.example).trim();
1157
+ didExpand = true;
1158
+ }
1159
+
1160
+ let userStories = extractCaptureUserStoriesFromBody(body);
1161
+ if (args.user_stories !== undefined && String(args.user_stories).trim()) {
1162
+ userStories = String(args.user_stories).trim();
1163
+ didExpand = true;
1164
+ }
1165
+
1166
+ // `done_when` — the schema has promised this arg since the HS-NR43 gate
1167
+ // remediation pointed at it, but it was silently dropped until
1168
+ // [[HS-AYCX-T9T6-TASK-grounding-time-sufficiency-check]] wired it through.
1169
+ if (args.done_when !== undefined && String(args.done_when).trim()) {
1170
+ doneWhen = String(args.done_when).trim();
1171
+ didExpand = true;
1172
+ }
1173
+
1174
+ // `jtbd` — authored by the /hs-capture enhancement pass, persisted across
1175
+ // updates like `example`/`user_stories`/`done_when`
1176
+ // ([[HS-TIE7-PR20-TASK-jtbd-job-to-be-done-field-on-captures]]).
1177
+ if (args.jtbd !== undefined && String(args.jtbd).trim()) {
1178
+ jtbd = String(args.jtbd).trim();
1179
+ didExpand = true;
1180
+ }
1181
+
1182
+ // HS-RZQO elicitation: user-confirmed kind re-classification. Only canonical
1183
+ // agent-settable values are accepted (spec-import is importer-stamped, never
1184
+ // agent-set); an unknown value is ignored, never a throw.
1185
+ const AGENT_SETTABLE_KINDS = ['idea', 'bug-fix', 'clip', 'reference', 'question'];
1186
+ let kindUpdate = null;
1187
+ if (args.kind !== undefined && AGENT_SETTABLE_KINDS.includes(String(args.kind).trim())) {
1188
+ kindUpdate = String(args.kind).trim();
1189
+ }
1190
+
1191
+ // Preserve any existing `## Source Material` across the rebuild (MCP args don't
1192
+ // carry source material, but it must survive an update — it used to be dropped).
1193
+ const sourceMaterial = extractSourceMaterialFromBody(body);
1194
+ const newBody = buildCaptureBody(core, notes, summary, agentExpansion, sourceMaterial, example, userStories, doneWhen, jtbd);
1195
+ const updated = new Date().toISOString().slice(0, 10);
1196
+ const fm = { ...frontmatter, updated };
1197
+ // SV2K: assert type: capture going forward; legacy type: idea is tolerated until E2.
1198
+ if (fm.type === 'idea') fm.type = 'capture';
1199
+ if (!fm.kind) fm.kind = 'idea';
1200
+ if (kindUpdate) fm.kind = kindUpdate;
1201
+ // P2WA: flip capture_status to 'expanded' on any write to core/summary/expansion.
1202
+ // Backstop: if the body already has Core Idea content (e.g. from a prior
1203
+ // session writing only notes), surface that to the field. Never demote.
1204
+ if (didExpand || core.trim().length > 0) {
1205
+ fm.capture_status = 'expanded';
1206
+ } else if (fm.capture_status !== 'expanded' && fm.capture_status !== 'raw') {
1207
+ fm.capture_status = 'raw';
1208
+ }
1209
+ if (core.trim()) {
1210
+ const firstLine = core.split('\n').map((l) => l.trim()).find(Boolean);
1211
+ if (firstLine) fm.name = firstLine.replace(/^[-*>\s"]+|["]+$/g, '').slice(0, 80);
1212
+ }
1213
+
1214
+ writeFileSync(fullPath, stringifyCaptureSpec(fm, newBody), 'utf8');
1215
+
1216
+ // Real content just landed → drop the empty-scaffold fuse so this capture is
1217
+ // no longer treated as an abandonable zombie. Runs regardless of whether a
1218
+ // claim refresh follows (a content write without a claim must still defuse).
1219
+ const hasContent =
1220
+ core.trim() !== '' ||
1221
+ notes.trim() !== '' ||
1222
+ summary.trim() !== '' ||
1223
+ example.trim() !== '' ||
1224
+ doneWhen.trim() !== '' ||
1225
+ String(agentExpansion ?? '').trim() !== '';
1226
+ if (hasContent) {
1227
+ clearCaptureFocusExpiry(vaultRoot);
1228
+ }
1229
+
1230
+ if (deps.claim) {
1231
+ updateCaptureFocusClaim(vaultRoot, deps.claim);
1232
+ }
1233
+
1234
+ const result = {
1235
+ success: true,
1236
+ spec_id: fm.id,
1237
+ spec_path: specPath,
1238
+ core_idea: core,
1239
+ notes_length: notes.length,
1240
+ auto_created: autoCreated,
1241
+ message: autoCreated
1242
+ ? 'Fresh capture auto-created. Core and notes stay in sync for /hs-capture.'
1243
+ : 'Capture file updated. Core and notes stay in sync for /hs-capture.',
1244
+ };
1245
+ if (continuationOf) {
1246
+ // HS-KFB2: this fresh capture continues a recently-promoted idea's
1247
+ // lineage (stamped `continues:` + a body note by createFreshCaptureSpec).
1248
+ // Surface it so the calling agent can suggest — never auto-apply — that
1249
+ // this content might belong appended to the research doc instead.
1250
+ result.continuation_of = continuationOf;
1251
+ result.continuation_note = `This capture was auto-created as a continuation of a recently-promoted idea (${continuationOf}). Consider telling the user this looks like a follow-on to that idea, and offering to append it to ${continuationOf} instead of (or in addition to) this new capture.`;
1252
+ }
1253
+ if (args.core_idea) {
1254
+ const check = validateCaptureCore(core);
1255
+ if (check.warning) result.warning = check.warning;
1256
+ }
1257
+ if (summaryWarning) {
1258
+ result.summary_warning = summaryWarning;
1259
+ // Back-compat: older callers read `concept_warning`.
1260
+ result.concept_warning = summaryWarning;
1261
+ }
1262
+ // Grounding-time sufficiency ([[HS-AYCX-T9T6-TASK-grounding-time-sufficiency-check]]):
1263
+ // score the state just written with the same scorer the promote gate uses, so
1264
+ // the agent elicits missing elements in this pass instead of bouncing at
1265
+ // promote time. Read-only — never blocks the update or triggers a promote.
1266
+ result.sufficiency = computeCaptureSufficiency(fm, newBody);
1267
+ return result;
1268
+ }
1269
+
1270
+ /**
1271
+ * Auto-create a fresh capture spec when no capture is pinned. Returns
1272
+ * { spec_id, spec_path } using POSIX-style spec_path for the focus YAML.
1273
+ */
1274
+ /** Strip an existing `[[...]]` wrapper (promoted_to is stored pre-wrapped) so a
1275
+ * wikilink target can be re-wrapped exactly once, regardless of whether the
1276
+ * caller passed a bare id or an already-bracketed link. */
1277
+ function unwrapWikilink(s) {
1278
+ const trimmed = String(s ?? '').trim();
1279
+ const m = trimmed.match(/^\[\[(.+)\]\]$/);
1280
+ return m ? m[1] : trimmed;
1281
+ }
1282
+
1283
+ export function createFreshCaptureSpec(vaultRoot, allocateRandomVaultId, occupiedCaptureIds, lineage = null) {
1284
+ // Era-3 file-key-first ([[naming-v3-7-rulings-hs-namespace-kept]]): the
1285
+ // FAMILY token still comes from the legacy allocator's `HS-<family>-CAP`
1286
+ // draw (parentless capture — no family to inherit); `id:` keys onto the
1287
+ // file key alone.
1288
+ const allocatedFamilyId = allocateRandomVaultId('capture', occupiedCaptureIds);
1289
+ const familyKey = parseSpecId(allocatedFamilyId)?.familyKey ?? mintFamilyKey(vaultRoot);
1290
+ // Per-file FILE KEY (owner ruling 2026-07-10) — filename + `file_id:` SSOT,
1291
+ // and (V3) the anchor for `id:` itself.
1292
+ const fileKey = mintFileKey(vaultRoot);
1293
+ const newId = `HS-${fileKey}`;
1294
+ const today = new Date().toISOString().slice(0, 10);
1295
+ const slug = `idea-${today}`;
1296
+ const filename = `${newId}-${familyKey}-CAP-${slug}.md`;
1297
+ const dir = join(vaultRoot, 'specs', 'captures');
1298
+ mkdirSync(dir, { recursive: true });
1299
+ const iso = new Date().toISOString();
1300
+ const frontmatter = {
1301
+ id: newId,
1302
+ file_id: fileKey,
1303
+ family: familyKey,
1304
+ name: `Capture ${today}`,
1305
+ type: 'capture',
1306
+ kind: 'idea',
1307
+ status: 'open',
1308
+ owner: 'pwher',
1309
+ created: iso,
1310
+ updated: iso,
1311
+ tags: ['idea', 'capture'],
1312
+ promoted_to: null,
1313
+ capture_status: 'raw',
1314
+ // HS-KFB2: lineage carry-over — a same-session follow-on to a recently-
1315
+ // promoted idea is no longer a disconnected orphan; it links back to the
1316
+ // idea it continues (the research it was promoted into, and the retired
1317
+ // source capture) so the vault stays traceable even though this is still a
1318
+ // fresh capture object (the promoted source capture rejects new writes —
1319
+ // see the diagnose's interim-remediation note).
1320
+ ...(lineage?.promotedTo ? { continues: `[[${unwrapWikilink(lineage.promotedTo)}]]` } : {}),
1321
+ };
1322
+ const body = lineage?.promotedTo
1323
+ ? CAPTURE_BODY_TEMPLATE.replace(
1324
+ /^# Core Idea\n/,
1325
+ `# Core Idea\n\n> Continuation of a recently-promoted idea — see [[${unwrapWikilink(lineage.promotedTo)}]]` +
1326
+ (lineage.retiredCaptureBasename
1327
+ ? ` (originally [[${unwrapWikilink(lineage.retiredCaptureBasename)}]]).`
1328
+ : '.') +
1329
+ '\n',
1330
+ )
1331
+ : CAPTURE_BODY_TEMPLATE;
1332
+ writeFileSync(join(dir, filename), stringifyCaptureSpec(frontmatter, body), 'utf8');
1333
+ return {
1334
+ spec_id: newId,
1335
+ spec_path: `specs/captures/${filename}`,
1336
+ ...(lineage?.promotedTo ? { continuation_of: unwrapWikilink(lineage.promotedTo) } : {}),
1337
+ };
1338
+ }
1339
+
1340
+ export function executePinCapture(vaultRoot, parseFrontmatter, loadSpec, args = {}) {
1341
+ const specId = args.spec_id;
1342
+ if (!specId) {
1343
+ return { error: 'MISSING_SPEC_ID', message: 'spec_id is required' };
1344
+ }
1345
+ const specPath = findCaptureSpecFile(vaultRoot, specId);
1346
+ if (!specPath) {
1347
+ return { error: 'SPEC_NOT_FOUND', message: `Capture ${specId} not found` };
1348
+ }
1349
+ const spec = loadSpec(vaultRoot, specPath);
1350
+ const fmType = spec?.frontmatter?.type;
1351
+ if (!spec || (fmType !== 'capture' && fmType !== 'idea')) {
1352
+ return { error: 'NOT_A_CAPTURE', message: `${specId} is not type: capture` };
1353
+ }
1354
+ // Mirror the capture's real kind into the pin (spec-import / bug-fix / … not a
1355
+ // hardcoded idea) — [[capture-guardrails]] §Capture-on-deck pin.
1356
+ const pinnedKind = spec.frontmatter?.kind;
1357
+ writeCaptureFocus(vaultRoot, specPath, args.reason || 'Pinned via MCP', null, {
1358
+ kind: typeof pinnedKind === 'string' ? pinnedKind : undefined,
1359
+ });
1360
+ return {
1361
+ success: true,
1362
+ spec_id: specId,
1363
+ spec_path: specPath,
1364
+ message: `Capture ${specId} is now on deck for /hs-capture`,
1365
+ };
1366
+ }