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