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