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,121 @@
1
+ /**
2
+ * Lexical capture-content → suggested `kind` classifier — [[HS-QOBQ-S4JZ-DIAG-capture-kind-is-never-classified-every]].
3
+ *
4
+ * Every capture materializes at `kind: idea` (the schema default) and nothing
5
+ * ever inspected the content to check whether that default is right. This is
6
+ * the cheapest possible rung (regex/lexical, no model call) for the two call
7
+ * sites the diagnose named:
8
+ * 1. Capture time — `scripts/claude-hooks/_intent-classifier-router.mjs`
9
+ * (already running on every UserPromptSubmit) appends a "this reads
10
+ * like <kind>" hint to the capture-on-idea directive.
11
+ * 2. Promote time — `promote-capture.mjs`'s proposal phase re-checks the
12
+ * capture's stored `kind:` against this same guess and surfaces a
13
+ * mismatch instead of silently promoting a miskinded capture.
14
+ *
15
+ * Deliberately suggest-only ([[HS-5HYS-elicitation]]: suggest, never
16
+ * auto-apply) — nothing here writes frontmatter. Pure, dependency-free,
17
+ * never throws; an unrecognized/ambiguous body returns `kind: null` (no
18
+ * suggestion — leave the existing/default kind alone).
19
+ *
20
+ * Kept intentionally narrow: bug-fix is the strong, well-evidenced lexical
21
+ * signal the diagnose calls out ("error, stacktrace, broken, regression,
22
+ * 'this is itself a bug'"). clip/question are cheap, low-risk bonus signals
23
+ * (a bare URL body; a single trailing-`?` sentence). reference/feedback have
24
+ * no reliable lexical tell and are left to agent judgment, same as today.
25
+ */
26
+
27
+ import { fileURLToPath } from 'node:url';
28
+
29
+ /** Bug-report language — the diagnose's own named signal set, plus common
30
+ * JS/TS runtime-error shapes so a pasted stack trace is caught too. */
31
+ const BUG_FIX_RE =
32
+ /\b(stack ?trace|traceback|regression|silently (?:fails?|broke|breaks?)|doesn'?t work|does not work|no longer works|fails? to|null pointer|undefined is not a function|cannot read propert(?:y|ies) of (?:null|undefined)|this is (?:itself )?a bug|reproduc(?:e|es|ed|ible)|(?:type|reference|syntax)error\b|unhandled exception|threw an exception)\b/i;
33
+ // "error"/"exception"/"broken"/"crash" alone are common in non-bug prose
34
+ // ("error handling", "crash course") — require them paired with another
35
+ // defect-shaped word, OR appearing next to code-ish punctuation (a stack
36
+ // frame / file:line / exception class), to keep the false-positive rate low.
37
+ const BUG_FIX_WEAK_RE = /\b(error|exception|broken|crash(?:es|ed|ing)?|bug)\b/i;
38
+ const CODE_SHAPE_RE = /(\bat\s+\S+\(.*:\d+:\d+\)|\.(?:mjs|ts|tsx|js|jsx|cs|py):\d+|Traceback \(most recent call last\)|Exception in thread)/;
39
+
40
+ /** A body that is essentially just a link — nothing to synthesize, just save it. */
41
+ const URL_ONLY_RE = /^\s*<?(https?:\/\/\S+)>?\s*$/i;
42
+
43
+ /** A single short question — no other substantive paragraph. */
44
+ const QUESTION_ONLY_RE = /^\s*(?:why|how|what|when|where|who|which|should|is|are|does|do|can)\b[^\n]{0,240}\?\s*$/i;
45
+
46
+ /**
47
+ * @param {string|null|undefined} text — the capture body (frontmatter stripped).
48
+ * @returns {{ kind: 'bug-fix'|'clip'|'question'|null, matched: string|null }}
49
+ */
50
+ export function guessCaptureKindFromText(text) {
51
+ const body = typeof text === 'string' ? text.trim() : '';
52
+ if (!body) return { kind: null, matched: null };
53
+
54
+ const bugMatch = body.match(BUG_FIX_RE);
55
+ if (bugMatch) return { kind: 'bug-fix', matched: bugMatch[0] };
56
+ if (BUG_FIX_WEAK_RE.test(body) && CODE_SHAPE_RE.test(body)) {
57
+ const weak = body.match(BUG_FIX_WEAK_RE);
58
+ return { kind: 'bug-fix', matched: weak ? weak[0] : 'error-shaped content' };
59
+ }
60
+
61
+ if (URL_ONLY_RE.test(body)) return { kind: 'clip', matched: body.match(URL_ONLY_RE)[1] };
62
+
63
+ if (QUESTION_ONLY_RE.test(body) && !body.includes('\n\n')) {
64
+ return { kind: 'question', matched: body.slice(0, 80) };
65
+ }
66
+
67
+ return { kind: null, matched: null };
68
+ }
69
+
70
+ /**
71
+ * Compare a stored `kind:` against the content-implied guess.
72
+ * @param {unknown} storedKind
73
+ * @param {string|null|undefined} bodyText
74
+ * @returns {{ mismatch: boolean, storedKind: string|null, suggestedKind: string|null, matched: string|null }}
75
+ */
76
+ export function checkCaptureKindMismatch(storedKind, bodyText) {
77
+ const normalizedStored = typeof storedKind === 'string' && storedKind ? storedKind : 'idea';
78
+ const guess = guessCaptureKindFromText(bodyText);
79
+ const mismatch = guess.kind !== null && guess.kind !== normalizedStored;
80
+ return { mismatch, storedKind: normalizedStored, suggestedKind: guess.kind, matched: guess.matched };
81
+ }
82
+
83
+ async function runSelfTest() {
84
+ let ok = true;
85
+ const check = (name, cond) => {
86
+ console.log(` ${cond ? '✓' : '✗'} ${name}`);
87
+ if (!cond) ok = false;
88
+ };
89
+ console.log('== capture-kind-suggest.mjs self-test ==\n');
90
+
91
+ check(
92
+ 'bug language → bug-fix',
93
+ guessCaptureKindFromText('Getting a TypeError: cannot read property of undefined when I load the page, this is a bug.').kind === 'bug-fix',
94
+ );
95
+ check(
96
+ 'stacktrace-shaped body → bug-fix',
97
+ guessCaptureKindFromText('It crashes.\n at handleClick (app.tsx:42:10)\n at onClick (app.tsx:12:3)').kind === 'bug-fix',
98
+ );
99
+ check('bare URL body → clip', guessCaptureKindFromText('https://example.com/some/article').kind === 'clip');
100
+ check('single question → question', guessCaptureKindFromText('How does the claim lease renewal actually work?').kind === 'question');
101
+ check('plain idea prose → null (no suggestion)', guessCaptureKindFromText('It would be nice if the dashboard showed a sparkline here.').kind === null);
102
+ check('empty body → null', guessCaptureKindFromText('').kind === null);
103
+ check('"error handling" alone (no code shape) → null, not a false-positive bug-fix', guessCaptureKindFromText('We should improve error handling in the settings form.').kind === null);
104
+
105
+ const mismatch = checkCaptureKindMismatch('idea', 'TypeError: x is not a function — this is itself a bug, reproducible every time.');
106
+ check('mismatch detection: idea + bug body → mismatch true, suggestedKind bug-fix', mismatch.mismatch === true && mismatch.suggestedKind === 'bug-fix');
107
+ const noMismatch = checkCaptureKindMismatch('bug-fix', 'TypeError: x is not a function.');
108
+ check('no mismatch when stored kind already matches', noMismatch.mismatch === false);
109
+
110
+ console.log(`\n${ok ? '✓ self-test PASS' : '✗ self-test FAIL'}`);
111
+ return ok ? 0 : 1;
112
+ }
113
+
114
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
115
+ runSelfTest()
116
+ .then((code) => process.exit(code))
117
+ .catch((e) => {
118
+ console.error(e);
119
+ process.exit(1);
120
+ });
121
+ }
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Capture offline buffer — loss-proof scratch for when console/Neon is unreachable.
3
+ *
4
+ * Per [[heyspex-is-online-only-no-offline]]: capture is online-only. When the
5
+ * console API is unreachable the MCP capture tools REFUSE (hard error) instead of
6
+ * silently minting a divergent file scaffold + pin (the [[HS-CAP-SID6]] zombie
7
+ * source — see [[capture-pin-had-no-clear-logic]]). But the user's loss-aversion
8
+ * is non-negotiable ("capture must always work"), so the verbatim input is stashed
9
+ * here as plain text and drained into Neon automatically on reconnect.
10
+ *
11
+ * This is NOT control-plane state and does NOT violate [[control-plane-belongs-in-neon]]:
12
+ * - It is write-only and transient — never read AS state by /hs-next, the
13
+ * supervisor, or any tool. It is a dumb buffer of raw user words.
14
+ * - It is never a pin, never a `capture_sessions` row, never source of truth.
15
+ * - It is the exact "thoughts written into a notes file, captured on reconnect"
16
+ * escape hatch the online-only decision blesses — just automated.
17
+ * The decision's bar for a new `.heyspex/*` file is "Neon is genuinely unavailable
18
+ * for this case" — which is literally this case.
19
+ *
20
+ * Layout: `.heyspex/capture-outbox/<ISO>__<NN>__<kind>.txt`, one file per stashed
21
+ * field. The body is the verbatim words (human-readable: open the file = your
22
+ * words). Metadata lives in the filename so the file stays plain text.
23
+ */
24
+
25
+ import {
26
+ readdirSync,
27
+ readFileSync,
28
+ writeFileSync,
29
+ mkdirSync,
30
+ existsSync,
31
+ renameSync,
32
+ rmSync,
33
+ } from 'node:fs';
34
+ import { basename, join } from 'node:path';
35
+
36
+ import { buildCaptureBufferPatches } from './capture-buffer-patches.mjs';
37
+
38
+ /**
39
+ * Capture args that carry content worth preserving, in chronological write order.
40
+ * Covers EVERY content-bearing `heyspex_update_capture` argument ([[HS-K27H-DIAG-0G86]]
41
+ * AC4: the "your exact words are saved" message may only be shown when the words
42
+ * really are in the outbox — before this list included example/user_stories/kind/
43
+ * done_when, those args were silently dropped while the message claimed otherwise).
44
+ */
45
+ export const OUTBOX_KINDS = [
46
+ 'user_quote',
47
+ 'agent_note',
48
+ 'core_idea',
49
+ 'summary',
50
+ 'concept',
51
+ 'agent_expansion',
52
+ 'example',
53
+ 'user_stories',
54
+ 'kind',
55
+ 'done_when',
56
+ ];
57
+
58
+ /**
59
+ * Process-monotonic sequence, zero-padded into the filename as the within-
60
+ * millisecond tiebreaker. The ISO timestamp alone collides when two stashes land
61
+ * in the same ms — without this, lexical sort would fall through to `kind` and
62
+ * reorder entries (e.g. core_idea before an earlier user_quote). Sort key is
63
+ * therefore (timestamp, seq) = strict creation order.
64
+ */
65
+ let stashSeq = 0;
66
+
67
+ export function captureOutboxDir(vaultRoot) {
68
+ return join(vaultRoot, '.heyspex', 'capture-outbox');
69
+ }
70
+
71
+ /**
72
+ * Directory for entries that cannot self-heal — moved here by `quarantineOutboxEntry`
73
+ * when the console structurally refuses them (HTTP 4xx). Kept for audit / debugging;
74
+ * never replayed automatically. The active outbox can always drain because 4xx
75
+ * entries no longer head-of-line-block it ([[HS-EPIC-CPMR]]).
76
+ */
77
+ export function captureOutboxQuarantineDir(vaultRoot) {
78
+ return join(vaultRoot, '.heyspex', 'capture-outbox', 'quarantine');
79
+ }
80
+
81
+ /**
82
+ * Stash the content-bearing fields of a capture-update args object as plaintext,
83
+ * one file per field. Returns the written entries (empty array if nothing to stash).
84
+ */
85
+ export function stashCaptureArgs(vaultRoot, args = {}) {
86
+ const entries = [];
87
+ for (const kind of OUTBOX_KINDS) {
88
+ const raw = args[kind];
89
+ if (raw !== undefined && String(raw).trim()) {
90
+ entries.push({ kind, text: String(raw).trim() });
91
+ }
92
+ }
93
+ if (entries.length === 0) return [];
94
+
95
+ const dir = captureOutboxDir(vaultRoot);
96
+ mkdirSync(dir, { recursive: true });
97
+ const stampBase = new Date().toISOString().replace(/[:.]/g, '-');
98
+ const written = [];
99
+ for (const e of entries) {
100
+ const seq = String(stashSeq++).padStart(6, '0');
101
+ const fname = `${stampBase}__${seq}__${e.kind}.txt`;
102
+ const path = join(dir, fname);
103
+ writeFileSync(path, `${e.text}\n`, 'utf8');
104
+ written.push({ ...e, path, file: fname });
105
+ }
106
+ return written;
107
+ }
108
+
109
+ /** List stashed entries oldest-first (filenames sort chronologically). */
110
+ export function listOutbox(vaultRoot) {
111
+ const dir = captureOutboxDir(vaultRoot);
112
+ if (!existsSync(dir)) return [];
113
+ return readdirSync(dir, { withFileTypes: true })
114
+ .filter((d) => d.isFile() && d.name.endsWith('.txt'))
115
+ .map((d) => d.name)
116
+ .sort()
117
+ .map((f) => {
118
+ const m = f.match(/__\d+__([a-z_]+)\.txt$/);
119
+ const kind = m ? m[1] : 'user_quote';
120
+ let text = '';
121
+ try {
122
+ text = readFileSync(join(dir, f), 'utf8').replace(/\n+$/, '');
123
+ } catch {
124
+ /* skip unreadable */
125
+ }
126
+ return { file: f, path: join(dir, f), kind, text };
127
+ })
128
+ .filter((e) => e.text.length > 0);
129
+ }
130
+
131
+ export function outboxCount(vaultRoot) {
132
+ return listOutbox(vaultRoot).length;
133
+ }
134
+
135
+ /** Remove a stashed entry after it has been durably filed to Neon. */
136
+ export function removeOutboxEntry(path) {
137
+ try {
138
+ rmSync(path);
139
+ return true;
140
+ } catch {
141
+ return false;
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Quarantine a stashed entry that the console structurally refused (HTTP 4xx).
147
+ * Moves the file to `.heyspex/capture-outbox/quarantine/` and writes a
148
+ * sidecar `<name>.reason.json` with `{ reason, status, consoleMessage,
149
+ * quarantined_at }` for post-hoc inspection. Returns true on success.
150
+ *
151
+ * Per [[HS-EPIC-CPMR]] — the active outbox is for transient retry-worthy
152
+ * failures only. 4xx-class responses will fail identically on retry, so they
153
+ * don't belong there. Removing them (vs. discarding) keeps an audit trail
154
+ * without head-of-line-blocking the queue.
155
+ */
156
+ export function quarantineOutboxEntry(vaultRoot, entryPath, metadata = {}) {
157
+ try {
158
+ const qdir = captureOutboxQuarantineDir(vaultRoot);
159
+ mkdirSync(qdir, { recursive: true });
160
+ const name = basename(entryPath);
161
+ const dest = join(qdir, name);
162
+ renameSync(entryPath, dest);
163
+ const sidecarPath = join(qdir, `${name}.reason.json`);
164
+ const sidecar = {
165
+ reason: metadata.reason || 'console_refused',
166
+ status: metadata.status ?? null,
167
+ consoleMessage: metadata.consoleMessage ?? null,
168
+ quarantined_at: new Date().toISOString(),
169
+ };
170
+ writeFileSync(sidecarPath, `${JSON.stringify(sidecar, null, 2)}\n`, 'utf8');
171
+ return true;
172
+ } catch {
173
+ return false;
174
+ }
175
+ }
176
+
177
+ /**
178
+ * List quarantined entries oldest-first. Read-only — used for inspection /
179
+ * debugging, never auto-replayed.
180
+ */
181
+ export function listQuarantinedOutbox(vaultRoot) {
182
+ const dir = captureOutboxQuarantineDir(vaultRoot);
183
+ if (!existsSync(dir)) return [];
184
+ return readdirSync(dir, { withFileTypes: true })
185
+ .filter((d) => d.isFile() && d.name.endsWith('.txt'))
186
+ .map((d) => d.name)
187
+ .sort()
188
+ .map((f) => {
189
+ const m = f.match(/__\d+__([a-z_]+)\.txt$/);
190
+ const kind = m ? m[1] : 'user_quote';
191
+ let text = '';
192
+ try {
193
+ text = readFileSync(join(dir, f), 'utf8').replace(/\n+$/, '');
194
+ } catch {
195
+ /* skip unreadable */
196
+ }
197
+ let reason = null;
198
+ try {
199
+ const raw = readFileSync(join(dir, `${f}.reason.json`), 'utf8');
200
+ reason = JSON.parse(raw);
201
+ } catch {
202
+ /* sidecar may be absent */
203
+ }
204
+ return { file: f, path: join(dir, f), kind, text, reason };
205
+ });
206
+ }
207
+
208
+ export function quarantineCount(vaultRoot) {
209
+ return listQuarantinedOutbox(vaultRoot).length;
210
+ }
211
+
212
+ /**
213
+ * Map a stashed entry to the `capture_sessions` PATCH it should replay on
214
+ * reconnect. Delegates to `buildCaptureBufferPatches` — the SAME builder the
215
+ * live `heyspex_update_capture` path uses — so a drained entry is genuinely
216
+ * indistinguishable from one filed live: canonical STRUCTURED shapes
217
+ * (userQuotes: Array<{at,text}>, coreIdea/summary/details/example/userStories/
218
+ * doneWhen: {at,text,confirmed}), camelCase paths.
219
+ *
220
+ * Pre-[[HS-K27H-DIAG-0G86]] this hand-rolled FLAT string patches
221
+ * (`user_quotes: "- **iso · you:** …"`, `core_idea: "text"`) — the exact
222
+ * legacy shapes every downstream reader rejects ([[HS-EPIC-CPMR]]), so the
223
+ * "will sync automatically" promise replayed content into a form that never
224
+ * rendered. That is AC4's "drain path capable of replaying it" violation.
225
+ *
226
+ * @param {object} entry - { kind, text } from listOutbox.
227
+ * @param {string} iso - timestamp to stamp on the replayed entry.
228
+ * @param {object} [currentBuffer] - the session's current content_buffer, so
229
+ * append-shaped sections (userQuotes/chatTurns) merge instead of clobbering.
230
+ * The drain loop threads its locally-merged buffer through here.
231
+ * @returns {{path: string, value: unknown} | null}
232
+ */
233
+ export function outboxEntryToPatch(entry, iso, currentBuffer = {}) {
234
+ const text = String(entry.text ?? '').trim();
235
+ if (!text) return null;
236
+ if (!OUTBOX_KINDS.includes(entry.kind)) return null;
237
+ const patches = buildCaptureBufferPatches({ [entry.kind]: text }, currentBuffer, iso);
238
+ return patches[0] ?? null;
239
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * The capture-stage rule: an open capture must not be turned into an epic, task
3
+ * or milestone. Its sanctioned exit is promote-to-research.
4
+ *
5
+ * WHY THIS LIVES IN THE TOOL AND NOT ONLY IN A HOOK
6
+ *
7
+ * The rule shipped first as a Claude Code PreToolUse hook
8
+ * (scripts/claude-hooks/capture-workflow-gate.mjs). That hook was registered,
9
+ * built, and marked done, and it never once refused anything, for three separate
10
+ * reasons stacked on top of each other:
11
+ *
12
+ * 1. It returned early unless `CLAUDE_AGENT === 'true'`, which interactive
13
+ * Claude Code sessions do not set. Silent in every session a human reads.
14
+ * 2. Worse, and independent of 1: it resolved the capture-on-deck pin from
15
+ * `.heyspex/capture-focus.yaml`. That pin moved to the Neon control plane —
16
+ * `/hs-capture` writes it there and only falls back to the file when the
17
+ * console is unreachable. The file kept a June pin whose capture was already
18
+ * promoted, so the lookup returned CAPTURE_RETIRED and the gate allowed
19
+ * everything, in unattended sessions too. A hook cannot fix this: hooks are
20
+ * dependency-free by design and have no database access.
21
+ * 3. Hooks fail open, and one agent-writable file (`.heyspex/mode.json` set to
22
+ * "off") disarms every one of them but the env-vault guard.
23
+ *
24
+ * The tool has none of those problems. It reads the live pin, it runs in every
25
+ * session, and it cannot be switched off by writing a file. Same reasoning that
26
+ * moved the preflight gate into the tools (preflight-gate.mjs).
27
+ *
28
+ * This module is PURE — no filesystem, no network, no env reads beyond the
29
+ * documented switches. Resolving which capture is on deck is the caller's job,
30
+ * so the decision can be tested without a console.
31
+ *
32
+ * Env:
33
+ * HEYSPEX_CAPTURE_STAGE_GATE_OFF=1 Break-glass. Allows everything.
34
+ * HEYSPEX_CAPTURE_STAGE_GATE=warn Drop from refusing to a warning that
35
+ * rides along on the successful response.
36
+ */
37
+
38
+ /** file_spec types that are the premature-promotion case we refuse. */
39
+ export const BLOCKED_FILE_SPEC_TYPES = new Set(['epic', 'task', 'milestone']);
40
+
41
+ /**
42
+ * file_spec types that ARE the sanctioned capture exits, or are unrelated to the
43
+ * capture pipeline. Never refused.
44
+ */
45
+ export const ALLOWED_FILE_SPEC_TYPES = new Set([
46
+ 'research',
47
+ 'diagnose',
48
+ 'memory-lesson',
49
+ 'memory-decision',
50
+ 'memory-reference',
51
+ 'memory-ui-pattern',
52
+ 'review',
53
+ 'pillar',
54
+ ]);
55
+
56
+ /**
57
+ * Frontmatter fields a capture-derived spec uses to point back at its capture.
58
+ * Kept in sync with the hook by both importing this module.
59
+ */
60
+ export const REFERENCE_FIELDS = [
61
+ 'source',
62
+ 'related',
63
+ 'promoted_from',
64
+ 'parent',
65
+ 'informed_by',
66
+ 'sources_captures',
67
+ ];
68
+
69
+ export const CAPTURE_STAGE_GATE_OFF_ENV = 'HEYSPEX_CAPTURE_STAGE_GATE_OFF';
70
+ export const CAPTURE_STAGE_GATE_RUNG_ENV = 'HEYSPEX_CAPTURE_STAGE_GATE';
71
+
72
+ /**
73
+ * Does this filing point back at `captureId`?
74
+ *
75
+ * Scans the known linkage fields plus summary/body as a backstop. A wikilink
76
+ * like `[[HS-CAP-VDPB-some-idea]]` starts with the id, so a substring test is
77
+ * enough and avoids brittle wikilink parsing.
78
+ *
79
+ * Deliberately narrow, and it UNDER-matches: an agent could file an epic
80
+ * carrying the idea's content with no backlink at all, and nothing here would
81
+ * see it. That is accepted. Widening this to "a capture is open, so refuse every
82
+ * epic" would fire on essentially every filing for the rest of a session,
83
+ * because a capture's pin is permanent until it is promoted or closed.
84
+ */
85
+ export function referencesCapture(toolArgs, captureId) {
86
+ if (!captureId) return false;
87
+ const fm = toolArgs?.frontmatter;
88
+ if (fm && typeof fm === 'object') {
89
+ for (const field of REFERENCE_FIELDS) {
90
+ const v = fm[field];
91
+ if (v == null) continue;
92
+ const hay = Array.isArray(v) ? v.join('\n') : String(v);
93
+ if (hay.includes(captureId)) return true;
94
+ }
95
+ }
96
+ for (const key of ['summary', 'body']) {
97
+ const v = toolArgs?.[key];
98
+ if (typeof v === 'string' && v.includes(captureId)) return true;
99
+ }
100
+ return false;
101
+ }
102
+
103
+ /** Which rung is the gate on? `refuse` (default) or `warn`. */
104
+ export function captureStageRung(env = process.env) {
105
+ const raw = String(env?.[CAPTURE_STAGE_GATE_RUNG_ENV] ?? '').trim().toLowerCase();
106
+ return raw === 'warn' ? 'warn' : 'refuse';
107
+ }
108
+
109
+ export function captureStageMessage(captureId, type) {
110
+ return (
111
+ `This ${type} derives from the open capture ${captureId}, and a capture must not ` +
112
+ `become an epic, task or milestone. Promote it to research first — that is the ` +
113
+ `capture's sanctioned exit (the promote-to-research offer, or /hs-res). File ` +
114
+ `implementation specs later, from the research, not from the raw capture. ` +
115
+ `(Deliberate exception: set ${CAPTURE_STAGE_GATE_OFF_ENV}=1.)`
116
+ );
117
+ }
118
+
119
+ /**
120
+ * The decision.
121
+ *
122
+ * @param {object} input
123
+ * @param {object} input.toolArgs heyspex_file_spec arguments.
124
+ * @param {object|null} input.activeCapture The resolved capture on deck, or null
125
+ * when nothing is genuinely open. Must carry `spec_id`. A resolver error
126
+ * (retired / not found / no pin) is a null here — the caller decides that.
127
+ * @param {object} [input.env]
128
+ * @returns {{ decision: 'allow' } | { decision: 'refuse'|'warn', captureId, type, message }}
129
+ */
130
+ export function decideCaptureStageGate({ toolArgs = {}, activeCapture = null, env = process.env } = {}) {
131
+ if (env?.[CAPTURE_STAGE_GATE_OFF_ENV] === '1') return { decision: 'allow' };
132
+
133
+ const type = String(toolArgs?.type ?? '').trim().toLowerCase();
134
+ if (!type) return { decision: 'allow' };
135
+ if (ALLOWED_FILE_SPEC_TYPES.has(type)) return { decision: 'allow' };
136
+ if (!BLOCKED_FILE_SPEC_TYPES.has(type)) return { decision: 'allow' };
137
+
138
+ const captureId = activeCapture?.spec_id;
139
+ if (!captureId) return { decision: 'allow' };
140
+ if (!referencesCapture(toolArgs, captureId)) return { decision: 'allow' };
141
+
142
+ return {
143
+ decision: captureStageRung(env),
144
+ captureId,
145
+ type,
146
+ message: captureStageMessage(captureId, type),
147
+ };
148
+ }
149
+
150
+ /** The MCP error payload for a refusal. */
151
+ export function buildCaptureStageRefusal(verdict) {
152
+ return {
153
+ error: 'CAPTURE_STAGE_VIOLATION',
154
+ message: verdict.message,
155
+ capture_id: verdict.captureId,
156
+ attempted_type: verdict.type,
157
+ do_instead: 'Promote the capture to research (heyspex_promote_capture or /hs-res), then file from the research.',
158
+ break_glass: `${CAPTURE_STAGE_GATE_OFF_ENV}=1`,
159
+ };
160
+ }