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,438 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser hand-off primitives for `hs login`
|
|
3
|
+
* ([[HS-IFQM-IZQ7-EPIC-hs-login-browser-hand-off-for-cli-and]] on
|
|
4
|
+
* milestone [[HS-O6UA-IZQ7-MIL-seamless-companion-onboarding]], research
|
|
5
|
+
* [[HS-Z38G-IZQ7-RES-seamless-companion-onboarding-one-click-]]).
|
|
6
|
+
*
|
|
7
|
+
* This is the OAuth-for-native-apps loopback pattern (RFC 8252) that gcloud,
|
|
8
|
+
* `gh auth login --web` and Claude Code login already use: the CLI opens the
|
|
9
|
+
* system browser against the signed-in console, the console mints the
|
|
10
|
+
* single-use pairing code server-side and redirects it to a one-shot
|
|
11
|
+
* 127.0.0.1 listener, and the CLI redeems it silently. The typed-code page
|
|
12
|
+
* (`hs mcp pair <code>`, RFC 8628 device flow) stays as the cross-machine
|
|
13
|
+
* fallback for the one case loopback cannot cover.
|
|
14
|
+
*
|
|
15
|
+
* Everything here is deliberately transport-only and dependency-free so the
|
|
16
|
+
* CLI verb (vendor/heyspex-mcp/cli.mjs `runLoginCli`) can stay thin and the
|
|
17
|
+
* unit tests can drive it without a browser or a console.
|
|
18
|
+
*
|
|
19
|
+
* SECURITY invariants (not suggestions):
|
|
20
|
+
* - the listener binds 127.0.0.1 ONLY, never 0.0.0.0;
|
|
21
|
+
* - it serves exactly ONE exchange and then closes (or closes on timeout);
|
|
22
|
+
* - the `state` nonce must match EXACTLY what this process generated;
|
|
23
|
+
* - the pairing code is never printed, logged, or echoed back into the page.
|
|
24
|
+
*/
|
|
25
|
+
import { createServer } from 'node:http';
|
|
26
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
27
|
+
import { spawn } from 'node:child_process';
|
|
28
|
+
|
|
29
|
+
/** Loopback host. Never make this configurable to a non-loopback address. */
|
|
30
|
+
export const LOOPBACK_HOST = '127.0.0.1';
|
|
31
|
+
|
|
32
|
+
/** Path the console redirects the browser to. */
|
|
33
|
+
export const LOGIN_CALLBACK_PATH = '/callback';
|
|
34
|
+
|
|
35
|
+
/** Console page that performs the mint + redirect (built in parallel). */
|
|
36
|
+
export const LOGIN_CONNECT_PATH = '/companion/connect';
|
|
37
|
+
|
|
38
|
+
/** ~3 minutes, per the shared hand-off contract. */
|
|
39
|
+
export const DEFAULT_LOGIN_TIMEOUT_MS = 180_000;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The console rejects a callback port below 1024 (privileged range), so an
|
|
43
|
+
* ephemeral port the OS hands us must clear that bar. Every platform's
|
|
44
|
+
* ephemeral range already sits far above it; this is a fail-closed guard, not
|
|
45
|
+
* an expected branch.
|
|
46
|
+
*/
|
|
47
|
+
export const MIN_CALLBACK_PORT = 1024;
|
|
48
|
+
|
|
49
|
+
/** The console rejects a state longer than this. Ours is 43 chars. */
|
|
50
|
+
export const MAX_STATE_LENGTH = 512;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* This epic's client identifier. The console validates it exactly — it is not
|
|
54
|
+
* a CLI flag on purpose.
|
|
55
|
+
*/
|
|
56
|
+
export const LOGIN_CLIENT = 'cli';
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Opaque, single-use nonce binding the browser round trip to THIS process.
|
|
60
|
+
* 32 bytes of CSPRNG output (the contract floor is 16).
|
|
61
|
+
*
|
|
62
|
+
* base64URL — never plain base64 — is load-bearing, not cosmetic: the console
|
|
63
|
+
* percent-encodes the value on the way back, but `URLSearchParams` decodes a
|
|
64
|
+
* literal `+` as a SPACE. A nonce containing `+` or `/` could therefore come
|
|
65
|
+
* back byte-different and fail its own exact-match check. base64url's alphabet
|
|
66
|
+
* ([A-Za-z0-9_-]) is query-safe in both directions.
|
|
67
|
+
*/
|
|
68
|
+
export function generateLoginState(byteLength = 32) {
|
|
69
|
+
if (!Number.isInteger(byteLength) || byteLength < 16) {
|
|
70
|
+
throw new Error('generateLoginState: byteLength must be an integer >= 16');
|
|
71
|
+
}
|
|
72
|
+
const state = randomBytes(byteLength).toString('base64url');
|
|
73
|
+
if (state.length > MAX_STATE_LENGTH) {
|
|
74
|
+
throw new Error('generateLoginState: nonce exceeds the console state limit');
|
|
75
|
+
}
|
|
76
|
+
return state;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Constant-time nonce comparison. Length mismatch short-circuits (that is not
|
|
81
|
+
* a secret — the length of our own nonce is fixed and public).
|
|
82
|
+
*/
|
|
83
|
+
export function statesMatch(received, expected) {
|
|
84
|
+
if (typeof received !== 'string' || typeof expected !== 'string') return false;
|
|
85
|
+
if (received.length === 0 || received.length !== expected.length) return false;
|
|
86
|
+
const a = Buffer.from(received, 'utf8');
|
|
87
|
+
const b = Buffer.from(expected, 'utf8');
|
|
88
|
+
if (a.length !== b.length) return false;
|
|
89
|
+
return timingSafeEqual(a, b);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Build the console URL the browser is sent to. `consoleUrl` MUST come from
|
|
94
|
+
* the shared resolver (scripts/lib/console-url-resolver.mjs) — never a
|
|
95
|
+
* hardcoded origin.
|
|
96
|
+
*
|
|
97
|
+
* <consoleUrl>/companion/connect?port=<port>&state=<nonce>&client=cli[&label=<text>]
|
|
98
|
+
*/
|
|
99
|
+
export function buildConnectUrl({ consoleUrl, port, state, client = LOGIN_CLIENT, label } = {}) {
|
|
100
|
+
if (typeof consoleUrl !== 'string' || !consoleUrl.trim()) {
|
|
101
|
+
throw new Error('buildConnectUrl: consoleUrl is required');
|
|
102
|
+
}
|
|
103
|
+
// The console validates 1024–65535 and rejects anything outside it.
|
|
104
|
+
if (!Number.isInteger(port) || port < MIN_CALLBACK_PORT || port > 65535) {
|
|
105
|
+
throw new Error(`buildConnectUrl: port must be ${MIN_CALLBACK_PORT}-65535`);
|
|
106
|
+
}
|
|
107
|
+
if (typeof state !== 'string' || !state) {
|
|
108
|
+
throw new Error('buildConnectUrl: state is required');
|
|
109
|
+
}
|
|
110
|
+
if (state.length > MAX_STATE_LENGTH) {
|
|
111
|
+
throw new Error(`buildConnectUrl: state must be <= ${MAX_STATE_LENGTH} chars`);
|
|
112
|
+
}
|
|
113
|
+
const base = consoleUrl.trim().replace(/\/+$/, '');
|
|
114
|
+
const url = new URL(`${base}${LOGIN_CONNECT_PATH}`);
|
|
115
|
+
url.searchParams.set('port', String(port));
|
|
116
|
+
url.searchParams.set('state', state);
|
|
117
|
+
url.searchParams.set('client', client || LOGIN_CLIENT);
|
|
118
|
+
if (typeof label === 'string' && label.trim()) url.searchParams.set('label', label.trim());
|
|
119
|
+
return url.toString();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Reasons arrive from a browser redirect, i.e. from outside this process.
|
|
124
|
+
* Clamp them to a short opaque token before they reach a terminal, a log, or
|
|
125
|
+
* an HTML page.
|
|
126
|
+
*/
|
|
127
|
+
export function sanitizeReason(raw) {
|
|
128
|
+
if (typeof raw !== 'string') return 'unknown';
|
|
129
|
+
const cleaned = raw.trim().toLowerCase().replace(/[^a-z0-9_.-]+/g, '_').slice(0, 64);
|
|
130
|
+
return cleaned || 'unknown';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Plain-language wording for a failed hand-off. Never leaks the raw payload.
|
|
135
|
+
*
|
|
136
|
+
* The console's `error` vocabulary (missing_state, invalid_state,
|
|
137
|
+
* missing_client, invalid_client, not_authenticated, mint_failed) is an OPEN
|
|
138
|
+
* set — an unrecognized reason must degrade to a generic message, never throw.
|
|
139
|
+
*/
|
|
140
|
+
export function describeCallbackFailure(reason) {
|
|
141
|
+
switch (reason) {
|
|
142
|
+
// locally detected
|
|
143
|
+
case 'timeout':
|
|
144
|
+
return 'timed out waiting for the browser to come back';
|
|
145
|
+
case 'state_mismatch':
|
|
146
|
+
return 'the browser response did not match this sign-in attempt (rejected)';
|
|
147
|
+
case 'missing_code':
|
|
148
|
+
return 'the console came back without a pairing code';
|
|
149
|
+
case 'aborted':
|
|
150
|
+
return 'the sign-in was cancelled';
|
|
151
|
+
// console vocabulary
|
|
152
|
+
case 'not_authenticated':
|
|
153
|
+
return 'that browser is not signed in to the console — sign in there, then run hs login again';
|
|
154
|
+
case 'mint_failed':
|
|
155
|
+
return 'the console could not mint a pairing code — try again shortly';
|
|
156
|
+
case 'missing_state':
|
|
157
|
+
case 'invalid_state':
|
|
158
|
+
return 'the console rejected this sign-in attempt as malformed (bad state) — run hs login again';
|
|
159
|
+
case 'missing_client':
|
|
160
|
+
case 'invalid_client':
|
|
161
|
+
return 'the console rejected this client — your `hs` CLI may be out of date';
|
|
162
|
+
// legacy / open set
|
|
163
|
+
case 'signed_out':
|
|
164
|
+
return 'the console session was signed out';
|
|
165
|
+
case 'access_denied':
|
|
166
|
+
return 'the console denied the pairing request';
|
|
167
|
+
default:
|
|
168
|
+
return `the console refused the hand-off (${reason})`;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Plain-language wording for a redeem (POST /api/mcp/pair) rejection. The
|
|
174
|
+
* status codes are meaningful and each one has a different user fix.
|
|
175
|
+
*/
|
|
176
|
+
export function describeRedeemFailure(status, errorCode) {
|
|
177
|
+
switch (status) {
|
|
178
|
+
case 404:
|
|
179
|
+
return 'that pairing code is not recognized (it may never have been minted)';
|
|
180
|
+
case 409:
|
|
181
|
+
return 'that pairing code was already used — start a fresh sign-in';
|
|
182
|
+
case 410:
|
|
183
|
+
return 'that pairing code expired — start a fresh sign-in';
|
|
184
|
+
case 403:
|
|
185
|
+
return 'pairing is not permitted for this account (revoked)';
|
|
186
|
+
case 503:
|
|
187
|
+
return 'the console database is unavailable right now — try again shortly';
|
|
188
|
+
default:
|
|
189
|
+
return typeof errorCode === 'string' && errorCode.trim()
|
|
190
|
+
? errorCode.trim()
|
|
191
|
+
: `HTTP ${status}`;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const PAGE_STYLE =
|
|
196
|
+
'font:15px/1.5 ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif;' +
|
|
197
|
+
'margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;' +
|
|
198
|
+
'background:#0b0d10;color:#e8eaed;text-align:center';
|
|
199
|
+
|
|
200
|
+
function htmlPage(title, body) {
|
|
201
|
+
const safeTitle = String(title).replace(/[<>&]/g, '');
|
|
202
|
+
const safeBody = String(body).replace(/[<>&]/g, '');
|
|
203
|
+
return (
|
|
204
|
+
'<!doctype html><html lang="en"><head><meta charset="utf-8">' +
|
|
205
|
+
'<meta name="viewport" content="width=device-width,initial-scale=1">' +
|
|
206
|
+
`<title>${safeTitle}</title></head>` +
|
|
207
|
+
`<body style="${PAGE_STYLE}"><main style="max-width:32rem;padding:2rem">` +
|
|
208
|
+
`<h1 style="font-size:1.4rem;margin:0 0 .5rem">${safeTitle}</h1>` +
|
|
209
|
+
`<p style="margin:0;opacity:.75">${safeBody}</p>` +
|
|
210
|
+
'</main></body></html>'
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function respond(res, status, html) {
|
|
215
|
+
try {
|
|
216
|
+
res.writeHead(status, {
|
|
217
|
+
'content-type': 'text/html; charset=utf-8',
|
|
218
|
+
'cache-control': 'no-store',
|
|
219
|
+
connection: 'close',
|
|
220
|
+
});
|
|
221
|
+
res.end(html);
|
|
222
|
+
} catch {
|
|
223
|
+
/* the browser hung up; the outcome is already settled */
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Start the one-shot loopback listener.
|
|
229
|
+
*
|
|
230
|
+
* Resolves once the socket is bound, with:
|
|
231
|
+
* { port, callbackUrl, waitForCallback(), close() }
|
|
232
|
+
*
|
|
233
|
+
* `waitForCallback()` resolves EXACTLY once with
|
|
234
|
+
* { ok: true, code } | { ok: false, reason }
|
|
235
|
+
* and the server is closed either way. Nothing is retried, nothing is
|
|
236
|
+
* accepted after the first exchange.
|
|
237
|
+
*/
|
|
238
|
+
export function startCallbackListener({
|
|
239
|
+
expectedState,
|
|
240
|
+
timeoutMs = DEFAULT_LOGIN_TIMEOUT_MS,
|
|
241
|
+
} = {}) {
|
|
242
|
+
if (typeof expectedState !== 'string' || !expectedState) {
|
|
243
|
+
throw new Error('startCallbackListener: expectedState is required');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
let settled = false;
|
|
247
|
+
let resolveResult;
|
|
248
|
+
const resultPromise = new Promise((resolve) => {
|
|
249
|
+
resolveResult = resolve;
|
|
250
|
+
});
|
|
251
|
+
let timer = null;
|
|
252
|
+
|
|
253
|
+
const closeServer = () => {
|
|
254
|
+
try {
|
|
255
|
+
server.closeAllConnections?.();
|
|
256
|
+
} catch {
|
|
257
|
+
/* older node */
|
|
258
|
+
}
|
|
259
|
+
try {
|
|
260
|
+
server.close();
|
|
261
|
+
} catch {
|
|
262
|
+
/* already closing */
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const finish = (outcome, res) => {
|
|
267
|
+
if (settled) return;
|
|
268
|
+
settled = true;
|
|
269
|
+
if (timer) {
|
|
270
|
+
clearTimeout(timer);
|
|
271
|
+
timer = null;
|
|
272
|
+
}
|
|
273
|
+
resolveResult(outcome);
|
|
274
|
+
if (res) {
|
|
275
|
+
// Let the response flush before we tear the socket down, otherwise the
|
|
276
|
+
// browser shows a connection reset instead of the "you can close this
|
|
277
|
+
// tab" page.
|
|
278
|
+
res.on('finish', closeServer);
|
|
279
|
+
res.on('close', closeServer);
|
|
280
|
+
setTimeout(closeServer, 250).unref?.();
|
|
281
|
+
} else {
|
|
282
|
+
closeServer();
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const server = createServer((req, res) => {
|
|
287
|
+
let parsed = null;
|
|
288
|
+
try {
|
|
289
|
+
parsed = new URL(req.url ?? '/', `http://${LOOPBACK_HOST}`);
|
|
290
|
+
} catch {
|
|
291
|
+
parsed = null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (!parsed || parsed.pathname !== LOGIN_CALLBACK_PATH) {
|
|
295
|
+
// Keep listening — a favicon probe must not burn the single exchange.
|
|
296
|
+
respond(res, 404, htmlPage('Not found', 'This is the HeySpex CLI sign-in listener.'));
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (settled) {
|
|
301
|
+
respond(
|
|
302
|
+
res,
|
|
303
|
+
410,
|
|
304
|
+
htmlPage('Already handled', 'This sign-in was already completed. You can close this tab.'),
|
|
305
|
+
);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const stateParam = parsed.searchParams.get('state');
|
|
310
|
+
const error = parsed.searchParams.get('error');
|
|
311
|
+
|
|
312
|
+
// The console omits `state` entirely when the STATE ITSELF was what it
|
|
313
|
+
// rejected (missing/absurd length) — there is nothing to echo back. That
|
|
314
|
+
// shape is a failed attempt reported honestly, NOT a nonce mismatch, and
|
|
315
|
+
// it must not crash the listener. Nothing is redeemed either way; the only
|
|
316
|
+
// thing at stake is which message the user reads.
|
|
317
|
+
if (error && stateParam === null) {
|
|
318
|
+
const reason = sanitizeReason(error);
|
|
319
|
+
respond(
|
|
320
|
+
res,
|
|
321
|
+
400,
|
|
322
|
+
htmlPage("Sign-in didn't complete", 'You can close this tab and check the terminal.'),
|
|
323
|
+
);
|
|
324
|
+
finish({ ok: false, reason }, res);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const state = stateParam ?? '';
|
|
329
|
+
if (!statesMatch(state, expectedState)) {
|
|
330
|
+
// Never echo either nonce back into the page.
|
|
331
|
+
respond(
|
|
332
|
+
res,
|
|
333
|
+
400,
|
|
334
|
+
htmlPage(
|
|
335
|
+
"Sign-in didn't complete",
|
|
336
|
+
'That response did not match this sign-in attempt, so it was rejected. Nothing was stored. Run hs login again.',
|
|
337
|
+
),
|
|
338
|
+
);
|
|
339
|
+
finish({ ok: false, reason: 'state_mismatch' }, res);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (error) {
|
|
344
|
+
const reason = sanitizeReason(error);
|
|
345
|
+
respond(
|
|
346
|
+
res,
|
|
347
|
+
400,
|
|
348
|
+
htmlPage("Sign-in didn't complete", 'You can close this tab and check the terminal.'),
|
|
349
|
+
);
|
|
350
|
+
finish({ ok: false, reason }, res);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const code = parsed.searchParams.get('code');
|
|
355
|
+
if (!code) {
|
|
356
|
+
respond(
|
|
357
|
+
res,
|
|
358
|
+
400,
|
|
359
|
+
htmlPage("Sign-in didn't complete", 'You can close this tab and check the terminal.'),
|
|
360
|
+
);
|
|
361
|
+
finish({ ok: false, reason: 'missing_code' }, res);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
respond(
|
|
366
|
+
res,
|
|
367
|
+
200,
|
|
368
|
+
htmlPage("You're signed in", 'You can close this tab and return to your terminal.'),
|
|
369
|
+
);
|
|
370
|
+
finish({ ok: true, code }, res);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
server.on('error', (err) => {
|
|
374
|
+
finish({ ok: false, reason: sanitizeReason(err?.code ?? 'listen_failed') });
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
return new Promise((resolve, reject) => {
|
|
378
|
+
server.once('error', reject);
|
|
379
|
+
// Port 0 = let the OS pick an ephemeral port. 127.0.0.1 ONLY.
|
|
380
|
+
server.listen(0, LOOPBACK_HOST, () => {
|
|
381
|
+
server.removeListener('error', reject);
|
|
382
|
+
const address = server.address();
|
|
383
|
+
const port = typeof address === 'object' && address ? address.port : 0;
|
|
384
|
+
// The console refuses to redirect to a privileged port, so a listener
|
|
385
|
+
// there is useless — fail closed rather than open a browser that will
|
|
386
|
+
// bounce. Every platform's ephemeral range is far above 1024, so this
|
|
387
|
+
// is a guard, not an expected branch.
|
|
388
|
+
if (port < MIN_CALLBACK_PORT) {
|
|
389
|
+
closeServer();
|
|
390
|
+
reject(
|
|
391
|
+
new Error(
|
|
392
|
+
`loopback listener got port ${port}, below the console's ${MIN_CALLBACK_PORT} minimum`,
|
|
393
|
+
),
|
|
394
|
+
);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (timeoutMs > 0) {
|
|
398
|
+
timer = setTimeout(() => finish({ ok: false, reason: 'timeout' }), timeoutMs);
|
|
399
|
+
}
|
|
400
|
+
resolve({
|
|
401
|
+
port,
|
|
402
|
+
callbackUrl: `http://${LOOPBACK_HOST}:${port}${LOGIN_CALLBACK_PATH}`,
|
|
403
|
+
waitForCallback: () => resultPromise,
|
|
404
|
+
close: () => finish({ ok: false, reason: 'aborted' }),
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Open `url` in the user's default browser, detached. Returns false if the
|
|
412
|
+
* launcher could not even be spawned — the caller always prints the URL too,
|
|
413
|
+
* so a headless box can paste it by hand.
|
|
414
|
+
*
|
|
415
|
+
* Windows uses rundll32's FileProtocolHandler rather than `cmd /c start`
|
|
416
|
+
* because the connect URL contains `&` query separators, which cmd.exe treats
|
|
417
|
+
* as command separators; rundll32 involves no shell parsing at all.
|
|
418
|
+
*/
|
|
419
|
+
export function openBrowser(url, { platform = process.platform, spawnFn = spawn } = {}) {
|
|
420
|
+
const opts = { detached: true, stdio: 'ignore', windowsHide: true };
|
|
421
|
+
try {
|
|
422
|
+
let child;
|
|
423
|
+
if (platform === 'win32') {
|
|
424
|
+
child = spawnFn('rundll32.exe', ['url.dll,FileProtocolHandler', url], opts);
|
|
425
|
+
} else if (platform === 'darwin') {
|
|
426
|
+
child = spawnFn('open', [url], opts);
|
|
427
|
+
} else {
|
|
428
|
+
child = spawnFn('xdg-open', [url], opts);
|
|
429
|
+
}
|
|
430
|
+
child?.on?.('error', () => {
|
|
431
|
+
/* no browser on this box — the printed URL is the fallback */
|
|
432
|
+
});
|
|
433
|
+
child?.unref?.();
|
|
434
|
+
return true;
|
|
435
|
+
} catch {
|
|
436
|
+
return false;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fresh-process delegation for the long-lived MCP stdio server.
|
|
3
|
+
*
|
|
4
|
+
* Cursor/Claude spawn `cli.mjs` once per agent session and keep it alive.
|
|
5
|
+
* Node caches ESM modules in that parent, so in-process `handleToolsCall` can
|
|
6
|
+
* run stale code after disk edits. Each tools/call (and tools/list) spawns a
|
|
7
|
+
* short-lived `node cli.mjs call|export …` child instead — same pattern as
|
|
8
|
+
* `hs mcp call`, without requiring an MCP reconnect.
|
|
9
|
+
*
|
|
10
|
+
* Opt out (debug only): HEYSPEX_MCP_INLINE_CALLS=1
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { spawn } from 'node:child_process';
|
|
14
|
+
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
|
15
|
+
import { tmpdir } from 'node:os';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
|
|
18
|
+
/** @type {boolean} */
|
|
19
|
+
export const MCP_SUBPROCESS_TOOLS = process.env.HEYSPEX_MCP_INLINE_CALLS !== '1';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {string} stdout
|
|
23
|
+
* @param {string} stderr
|
|
24
|
+
* @param {number} code
|
|
25
|
+
* @returns {{ content: Array<{ type: string, text: string }>, isError?: boolean }}
|
|
26
|
+
*/
|
|
27
|
+
export function parseCallCliStdoutToMcpResult(stdout, stderr, code) {
|
|
28
|
+
const trimmed = stdout.trim();
|
|
29
|
+
if (!trimmed) {
|
|
30
|
+
return {
|
|
31
|
+
isError: true,
|
|
32
|
+
content: [{
|
|
33
|
+
type: 'text',
|
|
34
|
+
text: JSON.stringify({
|
|
35
|
+
error: 'SUBPROCESS_EMPTY',
|
|
36
|
+
message: stderr.trim() || `child exit ${code}`,
|
|
37
|
+
}),
|
|
38
|
+
}],
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const parsed = JSON.parse(trimmed);
|
|
43
|
+
if (parsed && Array.isArray(parsed.content)) {
|
|
44
|
+
return parsed;
|
|
45
|
+
}
|
|
46
|
+
const isError = code !== 0 || parsed?.error != null || parsed?.isError === true;
|
|
47
|
+
return {
|
|
48
|
+
...(isError ? { isError: true } : {}),
|
|
49
|
+
content: [{ type: 'text', text: JSON.stringify(parsed) }],
|
|
50
|
+
};
|
|
51
|
+
} catch {
|
|
52
|
+
return {
|
|
53
|
+
isError: code !== 0,
|
|
54
|
+
content: [{ type: 'text', text: trimmed }],
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {string} cliPath absolute path to cli.mjs
|
|
61
|
+
* @param {string[]} args argv after `node cli.mjs`
|
|
62
|
+
* @returns {Promise<{ stdout: string, stderr: string, code: number }>}
|
|
63
|
+
*/
|
|
64
|
+
export function spawnMcpChild(cliPath, args) {
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
const child = spawn(process.execPath, [cliPath, ...args], {
|
|
67
|
+
env: {
|
|
68
|
+
...process.env,
|
|
69
|
+
HEYSPEX_DISABLE_STDIO_BOOTSTRAP: '1',
|
|
70
|
+
// Prevent infinite subprocess recursion if the child ever hit stdio bootstrap.
|
|
71
|
+
HEYSPEX_MCP_INLINE_CALLS: '1',
|
|
72
|
+
},
|
|
73
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
74
|
+
windowsHide: true,
|
|
75
|
+
});
|
|
76
|
+
let stdout = '';
|
|
77
|
+
let stderr = '';
|
|
78
|
+
child.stdout.on('data', (chunk) => {
|
|
79
|
+
stdout += chunk;
|
|
80
|
+
});
|
|
81
|
+
child.stderr.on('data', (chunk) => {
|
|
82
|
+
stderr += chunk;
|
|
83
|
+
});
|
|
84
|
+
child.on('error', reject);
|
|
85
|
+
child.on('close', (exitCode) => {
|
|
86
|
+
resolve({ stdout, stderr, code: exitCode ?? 1 });
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* @param {string} cliPath
|
|
93
|
+
* @param {{ name: string, arguments?: Record<string, unknown> }} params
|
|
94
|
+
*/
|
|
95
|
+
export async function invokeFreshToolsCall(cliPath, params) {
|
|
96
|
+
const { name, arguments: toolArgs = {} } = params;
|
|
97
|
+
const tmpDir = mkdtempSync(join(tmpdir(), 'heyspex-mcp-call-'));
|
|
98
|
+
const argsFile = join(tmpDir, 'args.json');
|
|
99
|
+
try {
|
|
100
|
+
writeFileSync(argsFile, JSON.stringify(toolArgs), 'utf8');
|
|
101
|
+
const { stdout, stderr, code } = await spawnMcpChild(cliPath, [
|
|
102
|
+
'call',
|
|
103
|
+
name,
|
|
104
|
+
'--args-file',
|
|
105
|
+
argsFile,
|
|
106
|
+
'--json',
|
|
107
|
+
]);
|
|
108
|
+
return parseCallCliStdoutToMcpResult(stdout, stderr, code);
|
|
109
|
+
} finally {
|
|
110
|
+
try {
|
|
111
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
112
|
+
} catch {
|
|
113
|
+
/* ignore */
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @param {string} cliPath
|
|
120
|
+
* @returns {Promise<{ tools: unknown[] }>}
|
|
121
|
+
*/
|
|
122
|
+
export async function invokeFreshToolsList(cliPath) {
|
|
123
|
+
const { stdout, stderr, code } = await spawnMcpChild(cliPath, [
|
|
124
|
+
'export',
|
|
125
|
+
'tools-list',
|
|
126
|
+
'--json',
|
|
127
|
+
]);
|
|
128
|
+
if (code !== 0) {
|
|
129
|
+
throw new Error(stderr.trim() || `export tools-list exit ${code}`);
|
|
130
|
+
}
|
|
131
|
+
const parsed = JSON.parse(stdout.trim());
|
|
132
|
+
if (!parsed || !Array.isArray(parsed.tools)) {
|
|
133
|
+
throw new Error('export tools-list returned invalid payload');
|
|
134
|
+
}
|
|
135
|
+
return parsed;
|
|
136
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MVP Mode reader for MCP callers — [[HS-EPIC-MRDR]] §AC #7 / [[HS-MIL-MVMD]].
|
|
3
|
+
*
|
|
4
|
+
* A thin, filesystem-only mirror of the console's `readMvpModeState`
|
|
5
|
+
* (`spex-console/src/lib/vault.ts`). No cache: an MCP invocation is short-lived, so
|
|
6
|
+
* each call reads the file once. Kept dependency-free (no js-yaml) — the
|
|
7
|
+
* `mvp_mode:` block is shallow and predictable, so a targeted block parse is
|
|
8
|
+
* enough and avoids pulling console code into the vendor tree.
|
|
9
|
+
*
|
|
10
|
+
* `cli.mjs` / `next-review.mjs` use this so an agent can branch on whether the
|
|
11
|
+
* relax-review-to-milestone regime is active (e.g. skip the per-task HS-REV
|
|
12
|
+
* close-out gate while ON).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFileSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
|
|
18
|
+
export function configYamlPath(vaultRoot) {
|
|
19
|
+
return join(vaultRoot, '.heyspex', 'config.yaml');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function stripInlineComment(value) {
|
|
23
|
+
// Drop a trailing ` # comment` that isn't inside quotes. The config uses
|
|
24
|
+
// unquoted scalars, so a simple split on ` #` is sufficient and safe.
|
|
25
|
+
const idx = value.indexOf(' #');
|
|
26
|
+
const v = idx === -1 ? value : value.slice(0, idx);
|
|
27
|
+
return v.trim().replace(/^["']|["']$/g, '');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Parse the `mvp_mode:` block out of a raw config.yaml string. Returns the
|
|
32
|
+
* recognized fields, or `null` when the block is absent. `enabled` is true only
|
|
33
|
+
* when the literal value is `true` (mirrors the console's failsafe parse).
|
|
34
|
+
*/
|
|
35
|
+
export function parseMvpModeBlock(raw) {
|
|
36
|
+
const lines = String(raw).split(/\r?\n/);
|
|
37
|
+
const blockIdx = lines.findIndex((l) => /^mvp_mode\s*:/.test(l));
|
|
38
|
+
if (blockIdx === -1) return null;
|
|
39
|
+
|
|
40
|
+
// `review_backlog_never_blocks` defaults ON — see the case below.
|
|
41
|
+
const state = { enabled: false, review_backlog_never_blocks: true };
|
|
42
|
+
for (let i = blockIdx + 1; i < lines.length; i++) {
|
|
43
|
+
const line = lines[i];
|
|
44
|
+
// A non-indented, non-blank, non-comment line ends the block.
|
|
45
|
+
if (/^\S/.test(line) && line.trim() !== '' && !line.trimStart().startsWith('#')) {
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
const trimmed = line.trim();
|
|
49
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
50
|
+
const colon = trimmed.indexOf(':');
|
|
51
|
+
if (colon === -1) continue;
|
|
52
|
+
const key = trimmed.slice(0, colon).trim();
|
|
53
|
+
const val = stripInlineComment(trimmed.slice(colon + 1));
|
|
54
|
+
switch (key) {
|
|
55
|
+
case 'enabled':
|
|
56
|
+
state.enabled = val.toLowerCase() === 'true';
|
|
57
|
+
break;
|
|
58
|
+
case 'activated':
|
|
59
|
+
if (val) state.activated = val;
|
|
60
|
+
break;
|
|
61
|
+
case 'reason':
|
|
62
|
+
if (val) state.reason = val;
|
|
63
|
+
break;
|
|
64
|
+
case 'review_grain':
|
|
65
|
+
// task | epic | milestone — epic is steady-state Standard grain ([[HS-EPIC-7CWZ]]).
|
|
66
|
+
if (val === 'task' || val === 'epic' || val === 'milestone')
|
|
67
|
+
state.review_grain = val;
|
|
68
|
+
break;
|
|
69
|
+
case 'review_backlog_never_blocks':
|
|
70
|
+
// Opt-OUT only: absent ⇒ true. A batched review grain implies a standing
|
|
71
|
+
// review backlog, so "specs awaiting review" can never gate the next
|
|
72
|
+
// claim (owner ruling 2026-08-14). Set it to `false` explicitly to
|
|
73
|
+
// restore drain-the-queue-first behavior.
|
|
74
|
+
state.review_backlog_never_blocks = val.toLowerCase() !== 'false';
|
|
75
|
+
break;
|
|
76
|
+
case 'flip_off_when':
|
|
77
|
+
if (val) state.flip_off_when = val;
|
|
78
|
+
break;
|
|
79
|
+
default:
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return state;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* One-shot read of the effective MVP Mode state. Returns the state object when
|
|
88
|
+
* the regime is ON, else `null` (file missing, block absent, or `enabled`
|
|
89
|
+
* falsy) — same contract as the console's `readMvpModeState`.
|
|
90
|
+
*/
|
|
91
|
+
export function readMvpModeState(vaultRoot) {
|
|
92
|
+
let raw;
|
|
93
|
+
try {
|
|
94
|
+
raw = readFileSync(configYamlPath(vaultRoot), 'utf-8');
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
const block = parseMvpModeBlock(raw);
|
|
99
|
+
if (!block || !block.enabled) return null;
|
|
100
|
+
return block;
|
|
101
|
+
}
|