heyspex 0.2.0 → 0.2.2
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/.claude-plugin/marketplace.json +14 -0
- package/bin/hs.mjs +181 -15
- package/dist/cli/hs-check.mjs +88 -46
- package/dist/cli/hs-claim.mjs +84 -39
- package/dist/cli/hs-connect.mjs +583 -129
- package/dist/cli/hs-defer.mjs +251 -30
- package/dist/cli/hs-dev.mjs +20 -8
- package/dist/cli/hs-env.mjs +62 -34
- package/dist/cli/hs-index-rebuild.mjs +288 -83
- package/dist/cli/hs-index-reconcile.mjs +397 -111
- package/dist/cli/hs-init.mjs +2143 -91
- package/dist/cli/hs-install.mjs +628 -106
- package/dist/cli/hs-join.mjs +686 -236
- package/dist/cli/hs-vault-pull.mjs +3189 -179
- package/dist/cli/hs-vault-push.mjs +6433 -0
- package/dist/cli/hs-vault.mjs +396 -178
- package/dist/cli/hs-workspace.mjs +289 -151
- package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -28
- package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -62
- package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -29
- package/dist/cli-templates/harness-commands/claude/hs-ground.md +30 -28
- package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -29
- package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -70
- package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -35
- package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -27
- package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -23
- package/dist/cli-templates/harness-commands/claude/hs-review.md +23 -23
- package/dist/cli-templates/harness-commands/claude/hs-view.md +28 -26
- package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -32
- package/dist/cli-templates/harness-commands/claude/prototype.md +91 -91
- package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +22 -22
- package/dist/cli-templates/harness-commands/cursor/hs-capture.md +56 -56
- package/dist/cli-templates/harness-commands/cursor/hs-defer.md +23 -23
- package/dist/cli-templates/harness-commands/cursor/hs-ground.md +24 -22
- package/dist/cli-templates/harness-commands/cursor/hs-next.md +23 -23
- package/dist/cli-templates/harness-commands/cursor/hs-promote.md +64 -64
- package/dist/cli-templates/harness-commands/cursor/hs-push.md +29 -29
- package/dist/cli-templates/harness-commands/cursor/hs-qc.md +21 -21
- package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +17 -17
- package/dist/cli-templates/harness-commands/cursor/hs-review.md +17 -17
- package/dist/cli-templates/harness-commands/cursor/hs-view.md +24 -22
- package/dist/cli-templates/harness-commands/cursor/kill-ports.md +26 -26
- package/dist/cli-templates/harness-commands/cursor/prototype.md +86 -86
- package/docs/copilot-plugin-install.md +177 -0
- package/docs/driving-heyspex-from-copilot.md +165 -0
- package/package.json +21 -2
- package/plugins/heyspex/.claude-plugin/plugin.json +12 -12
- package/plugins/heyspex/.cursor-plugin/plugin.json +15 -15
- package/plugins/heyspex/README.md +312 -0
- package/plugins/heyspex/bin/run-hook.mjs +20 -2
- package/plugins/heyspex/hooks/cursor-hooks.json +81 -0
- package/plugins/heyspex/hooks/hooks.json +94 -37
- package/scripts/claude-hooks/_intent-classifier-router.mjs +456 -410
- package/scripts/claude-hooks/_intent-classifier-semantic.mjs +5 -0
- package/scripts/claude-hooks/_run.mjs +316 -23
- package/scripts/claude-hooks/capture-execution-gate.mjs +51 -0
- package/scripts/claude-hooks/claim-liveness-shared.mjs +2 -0
- package/scripts/claude-hooks/diagnose-capture-reminder.mjs +11 -0
- package/scripts/claude-hooks/directive-obligation-gate.mjs +11 -0
- package/scripts/claude-hooks/inject-communication-style.mjs +20 -0
- package/scripts/claude-hooks/intent-classifier-trigger.mjs +279 -247
- package/scripts/claude-hooks/session-end.mjs +2 -0
- package/scripts/claude-hooks/vault-mirror-catchup.mjs +11 -0
- package/scripts/claude-hooks/vault-write-tier-guard.mjs +11 -0
- package/scripts/claude-hooks/wip-checkpoint.mjs +5 -1
- package/scripts/cursor-hooks/manifest.mjs +46 -0
- package/scripts/heyspex-mode.mjs +20 -0
- package/scripts/hs-mode.mjs +1 -1
- package/scripts/hs-view.mjs +180 -17
- package/scripts/install-claude-code-hooks.mjs +3 -1
- package/scripts/install-copilot-hooks.mjs +288 -0
- package/scripts/lib/console-url-resolver.mjs +3 -2
- package/scripts/lib/customer-kit.mjs +327 -10
- package/scripts/lib/dev-mode.mjs +7 -6
- package/scripts/lib/directive-obligations.mjs +349 -0
- package/scripts/lib/hook-dispositions.mjs +210 -0
- package/scripts/lib/hook-vault-header.mjs +30 -0
- package/scripts/lib/hs-source-branch.mjs +196 -0
- package/scripts/lib/hs-source-log.mjs +2 -1
- package/scripts/lib/hs-source-preflight.mjs +18 -4
- package/scripts/lib/hs-source-push.mjs +386 -356
- package/scripts/lib/window-placement.mjs +109 -0
- package/spexharness/src/adapters/claude-code.mjs +11 -3
- package/spexharness/src/adapters/copilot.mjs +236 -0
- package/spexharness/src/adapters/detect-harness.mjs +34 -0
- package/spexharness/src/gates/_shell-write-targets.mjs +77 -0
- package/spexharness/src/gates/deep-research-cost-gate.mjs +4 -3
- package/spexharness/src/gates/diagnose-capture-reminder.mjs +112 -0
- package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -0
- package/spexharness/src/gates/directive-obligation-gate.mjs +183 -0
- package/spexharness/src/gates/vault-mirror-catchup.mjs +124 -0
- package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +125 -0
- package/spexharness/src/gates/vault-write-tier-guard.mjs +42 -0
- package/spexharness/src/intent-classifier.mjs +4 -2
- package/spexharness/src/intent-directive.mjs +626 -541
- package/spexharness/src/rule-table.mjs +31 -1
- package/vendor/heyspex-mcp/account-switch.mjs +6 -5
- package/vendor/heyspex-mcp/capture-buffer-patches.mjs +31 -3
- package/vendor/heyspex-mcp/capture-console-client.mjs +37 -2
- package/vendor/heyspex-mcp/capture-focus.mjs +26 -2
- package/vendor/heyspex-mcp/capture-outbox.mjs +10 -4
- package/vendor/heyspex-mcp/capture-sources.mjs +195 -0
- package/vendor/heyspex-mcp/capture-tools.mjs +56 -11
- package/vendor/heyspex-mcp/claim-tools.mjs +25 -2
- package/vendor/heyspex-mcp/cli-verbs.mjs +19 -21
- package/vendor/heyspex-mcp/cli.mjs +7 -1
- package/vendor/heyspex-mcp/close-branch-report.mjs +102 -0
- package/vendor/heyspex-mcp/cloud-vault-writes.mjs +197 -7
- package/vendor/heyspex-mcp/console-fetch.mjs +192 -28
- package/vendor/heyspex-mcp/console-transport.mjs +7 -1
- package/vendor/heyspex-mcp/credentials.mjs +9 -8
- package/vendor/heyspex-mcp/dev-tools.mjs +7 -0
- package/vendor/heyspex-mcp/filing-tools.mjs +35 -0
- package/vendor/heyspex-mcp/fleet-queue-tools.mjs +2 -1
- package/vendor/heyspex-mcp/folder-trust-store.mjs +2 -1
- package/vendor/heyspex-mcp/http-transport.mjs +2 -0
- package/vendor/heyspex-mcp/list-tools.mjs +1 -1
- package/vendor/heyspex-mcp/mcp-config.mjs +14 -1
- package/vendor/heyspex-mcp/memory-tools.mjs +523 -523
- package/vendor/heyspex-mcp/obligation-backstop.mjs +103 -0
- package/vendor/heyspex-mcp/occupied-keys.mjs +1 -0
- package/vendor/heyspex-mcp/on-deck-brief.mjs +19 -2
- package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -1708
- package/vendor/heyspex-mcp/plan-push.mjs +3 -2
- package/vendor/heyspex-mcp/preflight-gate.mjs +1 -0
- package/vendor/heyspex-mcp/procedure-handlers.mjs +327 -325
- package/vendor/heyspex-mcp/procedure-tools.mjs +1181 -1159
- package/vendor/heyspex-mcp/promote-capture.mjs +2 -0
- package/vendor/heyspex-mcp/promote-research.mjs +6 -0
- package/vendor/heyspex-mcp/read-path.mjs +162 -6
- package/vendor/heyspex-mcp/request-context.mjs +25 -1
- package/vendor/heyspex-mcp/research-handlers.mjs +6 -4
- package/vendor/heyspex-mcp/roadmap-default.mjs +70 -0
- package/vendor/heyspex-mcp/session-owner.mjs +3 -2
- package/vendor/heyspex-mcp/session-tools.mjs +10 -0
- package/vendor/heyspex-mcp/spec-edit-tools.mjs +8 -1
- package/vendor/heyspex-mcp/split-research.mjs +3 -0
- package/vendor/heyspex-mcp/submit-for-review.mjs +18 -1
- package/vendor/heyspex-mcp/summary-write-tools.mjs +5 -1
- package/vendor/heyspex-mcp/test-helpers.mjs +18 -3
- package/vendor/heyspex-mcp/tool-catalog.mjs +7 -1
- package/vendor/heyspex-mcp/tool-dispatch.mjs +18 -0
- package/vendor/heyspex-mcp/vault-io.mjs +720 -724
- package/vendor/heyspex-mcp/vault-resolve.mjs +197 -9
- package/vendor/heyspex-mcp/whoami.mjs +2 -1
- package/vendor/heyspex-mcp/write-outbox.mjs +512 -0
|
@@ -59,7 +59,28 @@
|
|
|
59
59
|
* The endpoints in {@link VAULT_LESS_ENDPOINTS} (pairing, session identity,
|
|
60
60
|
* creating/listing vaults and workspaces, dev-mode) are exempt: headers are
|
|
61
61
|
* attached when they resolve, and nothing refuses when they do not.
|
|
62
|
+
*
|
|
63
|
+
* NO PIN, NO POINTER (diagnose "unpinned repo silently follows the
|
|
64
|
+
* account-wide active-vault pointer"; client half of
|
|
65
|
+
* [[HS-W3JF-PEGM-DIAG-one-account-wide-active-vault-pointer]] fix 2, "fail
|
|
66
|
+
* closed on a missing binding"): a repo that names no vault — no heyspex.yaml,
|
|
67
|
+
* no specs/, no satellite pin in its MCP manifest, no HEYSPEX_VAULT_ROOT/DIR —
|
|
68
|
+
* resolves at tier 4 to the registry's `active_vault_id` (`source:
|
|
69
|
+
* 'account-pointer'`), which mirrors whatever the owner last clicked in the
|
|
70
|
+
* console's vault switcher. On 2026-10-10 a session in the code repo resolved
|
|
71
|
+
* that way to a throwaway test vault, and every claim/file/submit would have
|
|
72
|
+
* landed there silently. So a pointer-sourced result is a THIRD local
|
|
73
|
+
* refusal, `VAULT_NOT_PINNED` ({@link VaultNotPinnedError}): the message names
|
|
74
|
+
* the vault the pointer would have used and the runnable pin (`hs install
|
|
75
|
+
* --attach-vault <id> --target <repo>` for a code repo, `hs connect` for a
|
|
76
|
+
* vault). The resolver's tier ORDER is untouched (decision memory
|
|
77
|
+
* `vault-resolver-precedence`) — only what the transport does with the
|
|
78
|
+
* result changes. Deliberate pointer use is the env hatch
|
|
79
|
+
* `HEYSPEX_ALLOW_POINTER_VAULT=1` ({@link pointerVaultAllowed}), which
|
|
80
|
+
* restores the old behaviour and announces the vault once per process on
|
|
81
|
+
* stderr ({@link announcePointerVaultOnce}).
|
|
62
82
|
*/
|
|
83
|
+
import { heyspexHome } from '../../scripts/lib/heyspex-home.mjs';
|
|
63
84
|
import { readFileSync } from 'node:fs';
|
|
64
85
|
import { homedir } from 'node:os';
|
|
65
86
|
import { join } from 'node:path';
|
|
@@ -77,7 +98,8 @@ export { isVaultLessEndpoint, VAULT_LESS_ENDPOINTS };
|
|
|
77
98
|
// checkout `npx hs` installs the unrelated public package hs@2.0.1
|
|
78
99
|
// ([[HS-I6HT-EKYU-DIAG-every-message-tells-the-user-to-run-hs]]).
|
|
79
100
|
import { hsCommand } from '../../scripts/lib/hs-command.mjs';
|
|
80
|
-
import {
|
|
101
|
+
import { maybeDrainOnFirstSuccess } from './write-outbox.mjs';
|
|
102
|
+
import { getActiveConfig, registryAccountIdForRoot, UNREGISTERED_VAULT_ID } from './vault-resolve.mjs';
|
|
81
103
|
|
|
82
104
|
export const DEFAULT_CONSOLE_TIMEOUT_MS = 30000;
|
|
83
105
|
|
|
@@ -224,14 +246,19 @@ export function readMarkerAccount(repoRootAbs, { readFile } = {}) {
|
|
|
224
246
|
* applies, unchanged).
|
|
225
247
|
* @returns {string | null}
|
|
226
248
|
*/
|
|
227
|
-
export function getRepoAccountId() {
|
|
249
|
+
export function getRepoAccountId({ homeDir } = {}) {
|
|
228
250
|
let resolved;
|
|
229
251
|
try {
|
|
230
252
|
resolved = getActiveConfig();
|
|
231
253
|
} catch {
|
|
232
254
|
return null;
|
|
233
255
|
}
|
|
234
|
-
|
|
256
|
+
// Machine-local registry row, NOT the committed marker: a legacy
|
|
257
|
+
// `account:` line in heyspex.yaml is ignored (owner ruling 2026-10-16).
|
|
258
|
+
return registryAccountIdForRoot(
|
|
259
|
+
resolved?.repoRootAbs,
|
|
260
|
+
homeDir ? { registryDir: join(homeDir, '.heyspex') } : {},
|
|
261
|
+
);
|
|
235
262
|
}
|
|
236
263
|
|
|
237
264
|
/**
|
|
@@ -251,8 +278,8 @@ export function getRepoAccountId() {
|
|
|
251
278
|
* credentials file is just "no token".
|
|
252
279
|
* @param {{ env?: NodeJS.ProcessEnv | Record<string, string | undefined>, homeDir?: string, repoAccountId?: string | null }} [opts]
|
|
253
280
|
*/
|
|
254
|
-
export function getAuthHeader({ env = process.env, homeDir =
|
|
255
|
-
const accountId = repoAccountId === undefined ? getRepoAccountId() : repoAccountId;
|
|
281
|
+
export function getAuthHeader({ env = process.env, homeDir = heyspexHome(), repoAccountId } = {}) {
|
|
282
|
+
const accountId = repoAccountId === undefined ? getRepoAccountId({ homeDir }) : repoAccountId;
|
|
256
283
|
const { token } = readBearer({ env, homeDir, accountId });
|
|
257
284
|
if (!token && accountId) throw new AccountTokenMissingError(accountId);
|
|
258
285
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
|
@@ -266,14 +293,19 @@ export function getAuthHeader({ env = process.env, homeDir = homedir(), repoAcco
|
|
|
266
293
|
*
|
|
267
294
|
* Contract (agreed with the console side, revised by
|
|
268
295
|
* [[HS-MVG4-WW9R-TASK-mcp-and-hs-name-account-vault-on-every]]): send
|
|
269
|
-
* `x-heyspex-vault-id: <id>` for EVERY registered id
|
|
270
|
-
*
|
|
271
|
-
* `
|
|
272
|
-
* pointer (`account-pointer`)
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
296
|
+
* `x-heyspex-vault-id: <id>` for EVERY registered id a REPO-DERIVED tier
|
|
297
|
+
* produced (`explicit`, `env-vault-root`, `env-vault-dir`, `satellite-pin`,
|
|
298
|
+
* `cwd-marker`, `marker-registry-mismatch`, `cwd-heuristic`). The registry's
|
|
299
|
+
* active pointer (`account-pointer`) used to send nothing so the cloud console
|
|
300
|
+
* could fall back to ITS pointer; HS-MVG4 then put the pointer's id on the
|
|
301
|
+
* wire too, because the two pointers drift and a silent fallback is exactly
|
|
302
|
+
* what served repo A's session from vault B. Both shapes share the same
|
|
303
|
+
* defect — the repo never said which vault it meant — so a pointer-sourced
|
|
304
|
+
* result is now REFUSED locally ({@link VaultNotPinnedError}) unless the
|
|
305
|
+
* `HEYSPEX_ALLOW_POINTER_VAULT=1` hatch is set, in which case the HS-MVG4
|
|
306
|
+
* behaviour (pointer id on the wire) applies. An UNREGISTERED root is not a
|
|
307
|
+
* "no header" case either: it throws {@link VaultNotRegisteredError} so the
|
|
308
|
+
* transport refuses before any fetch.
|
|
277
309
|
*/
|
|
278
310
|
export const VAULT_ID_HEADER = 'x-heyspex-vault-id';
|
|
279
311
|
/** The account the client believes it acts as; the console refuses a mismatch with 409 `ACCOUNT_MISMATCH`. */
|
|
@@ -283,6 +315,106 @@ export const ACCOUNT_ID_HEADER = 'x-heyspex-account-id';
|
|
|
283
315
|
export const VAULT_NOT_REGISTERED = 'VAULT_NOT_REGISTERED';
|
|
284
316
|
/** Local refusal: this machine has no configured account (`hs login` never ran). */
|
|
285
317
|
export const ACCOUNT_NOT_CONFIGURED = 'ACCOUNT_NOT_CONFIGURED';
|
|
318
|
+
/** Local refusal: the repo names no vault, so only the account-wide pointer answered. */
|
|
319
|
+
export const VAULT_NOT_PINNED = 'VAULT_NOT_PINNED';
|
|
320
|
+
|
|
321
|
+
/** The resolver tier that reads the registry's account-wide `active_vault_id` (vault-resolve.mjs tier 4). */
|
|
322
|
+
export const POINTER_VAULT_SOURCE = 'account-pointer';
|
|
323
|
+
/** Env hatch: `1`/`true` lets a pointer-sourced vault go on the wire (old behaviour), announced once per process. */
|
|
324
|
+
export const ALLOW_POINTER_VAULT_ENV = 'HEYSPEX_ALLOW_POINTER_VAULT';
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Is the deliberate-pointer hatch set? Pure over `env`.
|
|
328
|
+
* @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
|
|
329
|
+
*/
|
|
330
|
+
export function pointerVaultAllowed(env = process.env) {
|
|
331
|
+
const raw = env?.[ALLOW_POINTER_VAULT_ENV];
|
|
332
|
+
if (typeof raw !== 'string') return false;
|
|
333
|
+
const v = raw.trim().toLowerCase();
|
|
334
|
+
return v === '1' || v === 'true' || v === 'yes';
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Thrown by {@link vaultIdHeaderFor} / {@link getVaultIdHeader} when the
|
|
339
|
+
* resolver answered from the account-wide pointer (`source:
|
|
340
|
+
* 'account-pointer'`) and the hatch is off: the repo the session runs in
|
|
341
|
+
* names no vault, so the only thing that chose one was whatever the owner
|
|
342
|
+
* last selected in the console. `code` is stable for machine matching; the
|
|
343
|
+
* message names the vault the pointer WOULD have used (id + root), the repo
|
|
344
|
+
* that needs a pin, and the runnable pin for each repo kind.
|
|
345
|
+
*/
|
|
346
|
+
export class VaultNotPinnedError extends Error {
|
|
347
|
+
/**
|
|
348
|
+
* @param {{ vaultId?: string | null, repoRootAbs?: string | null, source?: string | null } | null | undefined} resolved
|
|
349
|
+
* the pointer-sourced resolver result (its `repoRootAbs` is the POINTER vault's root)
|
|
350
|
+
* @param {{ cwd?: string | null }} [detail] `cwd`: the unpinned repo the session runs in
|
|
351
|
+
*/
|
|
352
|
+
constructor(resolved, { cwd = null } = {}) {
|
|
353
|
+
const clean = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
|
|
354
|
+
const vaultId = clean(resolved?.vaultId);
|
|
355
|
+
const vaultRoot = clean(resolved?.repoRootAbs);
|
|
356
|
+
const repo = clean(cwd);
|
|
357
|
+
const here = repo ? ` (${repo})` : '';
|
|
358
|
+
const wouldUse = vaultId
|
|
359
|
+
? `vault ${vaultId}${vaultRoot ? ` (${vaultRoot})` : ''}`
|
|
360
|
+
: 'an unknown vault';
|
|
361
|
+
// Both commands render through `hsCommand` so they run on the machine
|
|
362
|
+
// reading them ([[HS-I6HT]]); `<repo>` stands in when the cwd is unknown.
|
|
363
|
+
const attach = hsCommand(`install --attach-vault ${vaultId ?? '<vault-id>'} --target ${repo ?? '<repo>'}`);
|
|
364
|
+
const connect = hsCommand('connect');
|
|
365
|
+
super(
|
|
366
|
+
`This repo${here} names no HeySpex vault — no heyspex.yaml, no specs/ dir, and no HEYSPEX_VAULT_ROOT pin in its .mcp.json — `
|
|
367
|
+
+ 'so the console call was refused before it was sent. Without a pin the session would silently follow the '
|
|
368
|
+
+ 'account-wide active-vault pointer (whatever vault was last selected in the console or by `hs vault use`), '
|
|
369
|
+
+ `which right now names ${wouldUse}. `
|
|
370
|
+
+ 'Pin a vault, then restart the MCP server: '
|
|
371
|
+
+ `for a code repo run \`${attach}\`; for a vault repo run \`${connect}\` inside it. `
|
|
372
|
+
+ `To follow the pointer on purpose, start the server with ${ALLOW_POINTER_VAULT_ENV}=1. Nothing was sent.`,
|
|
373
|
+
);
|
|
374
|
+
this.name = 'VaultNotPinnedError';
|
|
375
|
+
this.code = VAULT_NOT_PINNED;
|
|
376
|
+
this.vaultId = vaultId;
|
|
377
|
+
this.vaultRootAbs = vaultRoot;
|
|
378
|
+
this.repoRootAbs = repo;
|
|
379
|
+
this.source = clean(resolved?.source);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** One line for stderr naming the pointer vault the hatch admitted. Pure. */
|
|
384
|
+
export function pointerVaultNotice(resolved) {
|
|
385
|
+
const id = typeof resolved?.vaultId === 'string' && resolved.vaultId.trim() ? resolved.vaultId.trim() : 'unknown';
|
|
386
|
+
const root = typeof resolved?.repoRootAbs === 'string' && resolved.repoRootAbs.trim() ? ` (${resolved.repoRootAbs.trim()})` : '';
|
|
387
|
+
return `[heyspex-mcp] ${ALLOW_POINTER_VAULT_ENV} is set: this repo names no vault, so console calls follow the account-wide active-vault pointer — vault ${id}${root}. Every claim/file/submit this session lands there.\n`;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
let _pointerVaultAnnounced = false;
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Write {@link pointerVaultNotice} to stderr ONCE per process, and only when
|
|
394
|
+
* `resolved` is pointer-sourced AND the hatch is on. Called at stdio-server
|
|
395
|
+
* start (cli.mjs) and lazily by {@link vaultIdHeaderFor} when the hatch
|
|
396
|
+
* admits a pointer header (covers the `cli.mjs call …` subprocesses), so
|
|
397
|
+
* every process that acts on the pointer says so exactly once. Returns
|
|
398
|
+
* `true` iff it wrote. Never throws.
|
|
399
|
+
* @param {{ vaultId?: string, repoRootAbs?: string, source?: string } | null | undefined} resolved
|
|
400
|
+
* @param {{ env?: NodeJS.ProcessEnv | Record<string, string | undefined>, write?: (line: string) => unknown }} [opts]
|
|
401
|
+
*/
|
|
402
|
+
export function announcePointerVaultOnce(resolved, { env = process.env, write } = {}) {
|
|
403
|
+
if (_pointerVaultAnnounced) return false;
|
|
404
|
+
if (resolved?.source !== POINTER_VAULT_SOURCE || !pointerVaultAllowed(env)) return false;
|
|
405
|
+
_pointerVaultAnnounced = true;
|
|
406
|
+
try {
|
|
407
|
+
(write ?? ((line) => process.stderr.write(line)))(pointerVaultNotice(resolved));
|
|
408
|
+
} catch {
|
|
409
|
+
/* a stderr failure never becomes a transport failure */
|
|
410
|
+
}
|
|
411
|
+
return true;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Test-only: forget that the pointer notice was written, so the next process-start path writes it again. */
|
|
415
|
+
export function resetPointerVaultNoticeForTests() {
|
|
416
|
+
_pointerVaultAnnounced = false;
|
|
417
|
+
}
|
|
286
418
|
|
|
287
419
|
/**
|
|
288
420
|
* Thrown by {@link vaultIdHeaderFor} / {@link getVaultIdHeader} when the
|
|
@@ -392,19 +524,30 @@ export class AccountNotConfiguredError extends Error {
|
|
|
392
524
|
}
|
|
393
525
|
}
|
|
394
526
|
|
|
395
|
-
/** True for the
|
|
527
|
+
/** True for the three local scope refusals this module throws. */
|
|
396
528
|
export function isLocalScopeRefusal(err) {
|
|
397
|
-
return
|
|
529
|
+
return (
|
|
530
|
+
err instanceof VaultNotRegisteredError
|
|
531
|
+
|| err instanceof AccountNotConfiguredError
|
|
532
|
+
|| err instanceof VaultNotPinnedError
|
|
533
|
+
);
|
|
398
534
|
}
|
|
399
535
|
|
|
400
536
|
/**
|
|
401
|
-
* Gate: the vault header for a resolver result. A registered id
|
|
402
|
-
* naming the CLOUD id ({@link cloudVaultIdFor})
|
|
403
|
-
* `unregistered` sentinel → throws {@link VaultNotRegisteredError};
|
|
404
|
-
*
|
|
405
|
-
*
|
|
537
|
+
* Gate: the vault header for a resolver result. A registered id from a
|
|
538
|
+
* REPO-DERIVED tier → the header naming the CLOUD id ({@link cloudVaultIdFor});
|
|
539
|
+
* the `unregistered` sentinel → throws {@link VaultNotRegisteredError}; a
|
|
540
|
+
* pointer-sourced result (`source: 'account-pointer'`) → throws
|
|
541
|
+
* {@link VaultNotPinnedError} unless the hatch is on ({@link pointerVaultAllowed}
|
|
542
|
+
* over `opts.env`), in which case the pointer's id goes on the wire and the
|
|
543
|
+
* process announces it once ({@link announcePointerVaultOnce}); no result /
|
|
544
|
+
* blank id → `{}` (nothing to name — the resolver itself failed, which is not
|
|
545
|
+
* the same as "unregistered"). Pure given `opts.readMarkerVaultId`, `opts.env`
|
|
546
|
+
* and `opts.write`.
|
|
406
547
|
* @param {{ vaultId?: string, source?: string, repoRootAbs?: string } | null | undefined} resolved
|
|
407
|
-
* @param {{ readMarkerVaultId?: (repoRootAbs: string) => string | null }} [opts]
|
|
548
|
+
* @param {{ readMarkerVaultId?: (repoRootAbs: string) => string | null, env?: NodeJS.ProcessEnv | Record<string, string | undefined>, cwd?: string | null, write?: (line: string) => unknown }} [opts]
|
|
549
|
+
* `cwd`: the unpinned repo named in the refusal (default: the resolver's own
|
|
550
|
+
* cwd base, `CLAUDE_PROJECT_DIR` then `process.cwd()`).
|
|
408
551
|
*/
|
|
409
552
|
export function vaultIdHeaderFor(resolved, opts = {}) {
|
|
410
553
|
const vaultId = typeof resolved?.vaultId === 'string' ? resolved.vaultId.trim() : '';
|
|
@@ -412,15 +555,32 @@ export function vaultIdHeaderFor(resolved, opts = {}) {
|
|
|
412
555
|
if (vaultId === UNREGISTERED_VAULT_ID) {
|
|
413
556
|
throw new VaultNotRegisteredError(resolved?.repoRootAbs, resolved?.source);
|
|
414
557
|
}
|
|
558
|
+
if (resolved?.source === POINTER_VAULT_SOURCE) {
|
|
559
|
+
const env = opts.env ?? process.env;
|
|
560
|
+
if (!pointerVaultAllowed(env)) {
|
|
561
|
+
throw new VaultNotPinnedError(resolved, { cwd: opts.cwd === undefined ? resolverCwdBase() : opts.cwd });
|
|
562
|
+
}
|
|
563
|
+
announcePointerVaultOnce(resolved, { env, write: opts.write });
|
|
564
|
+
}
|
|
415
565
|
return { [VAULT_ID_HEADER]: cloudVaultIdFor({ ...resolved, vaultId }, opts) };
|
|
416
566
|
}
|
|
417
567
|
|
|
568
|
+
/**
|
|
569
|
+
* The folder the resolver's walk started from — the repo that needs a pin.
|
|
570
|
+
* Mirrors vault-resolve.mjs `cwdBase` (`CLAUDE_PROJECT_DIR`, then `process.cwd()`).
|
|
571
|
+
*/
|
|
572
|
+
function resolverCwdBase() {
|
|
573
|
+
const fromEnv = process.env.CLAUDE_PROJECT_DIR;
|
|
574
|
+
return typeof fromEnv === 'string' && fromEnv.trim() ? fromEnv.trim() : process.cwd();
|
|
575
|
+
}
|
|
576
|
+
|
|
418
577
|
/**
|
|
419
578
|
* The vault-id header for THIS process, from the memoized resolver
|
|
420
579
|
* (`getActiveConfig`, resolved once per process; tests reset it via
|
|
421
580
|
* `resetActiveConfigCache`). A resolver FAILURE is "no header" (`{}`); an
|
|
422
|
-
* `unregistered` RESULT throws {@link VaultNotRegisteredError}
|
|
423
|
-
*
|
|
581
|
+
* `unregistered` RESULT throws {@link VaultNotRegisteredError} and a
|
|
582
|
+
* pointer-sourced one throws {@link VaultNotPinnedError} (hatch off) so the
|
|
583
|
+
* caller refuses before any fetch.
|
|
424
584
|
*/
|
|
425
585
|
export function getVaultIdHeader() {
|
|
426
586
|
let resolved;
|
|
@@ -455,7 +615,7 @@ export function accountIdHeaderFor(accountId) {
|
|
|
455
615
|
* @param {{ env?: NodeJS.ProcessEnv | Record<string, string | undefined>, homeDir?: string, repoAccountId?: string | null }} [opts]
|
|
456
616
|
*/
|
|
457
617
|
export function getAccountIdHeader({ repoAccountId, ...opts } = {}) {
|
|
458
|
-
const recorded = repoAccountId === undefined ? getRepoAccountId() : repoAccountId;
|
|
618
|
+
const recorded = repoAccountId === undefined ? getRepoAccountId({ homeDir: opts.homeDir }) : repoAccountId;
|
|
459
619
|
return accountIdHeaderFor(recorded || resolveSessionOwnerUncached(opts));
|
|
460
620
|
}
|
|
461
621
|
|
|
@@ -464,7 +624,7 @@ export function getAccountIdHeader({ repoAccountId, ...opts } = {}) {
|
|
|
464
624
|
* `consoleFetch` already handles (`ok: false`, `status: 0`), with the code on
|
|
465
625
|
* `refusal` AND on `authProblem` so `consoleErrorCode` / the never-buffer
|
|
466
626
|
* contract pick it up with no per-caller change. Pure.
|
|
467
|
-
* @param {VaultNotRegisteredError | AccountNotConfiguredError} err
|
|
627
|
+
* @param {VaultNotRegisteredError | AccountNotConfiguredError | VaultNotPinnedError | AccountTokenMissingError} err
|
|
468
628
|
* @param {string} url the request that was NOT sent
|
|
469
629
|
*/
|
|
470
630
|
export function scopeRefusalResult(err, url) {
|
|
@@ -545,7 +705,7 @@ async function loadDevModeCore() {
|
|
|
545
705
|
* `resolveDevMode` (env → machine-local record → default prod/cloud) when the
|
|
546
706
|
* core exports it, else `readLocalDevModeRecord` + `deriveSemantics`.
|
|
547
707
|
*/
|
|
548
|
-
export async function resolveConsoleReliance({ env = process.env, homeDir =
|
|
708
|
+
export async function resolveConsoleReliance({ env = process.env, homeDir = heyspexHome() } = {}) {
|
|
549
709
|
const core = await loadDevModeCore();
|
|
550
710
|
if (!core) return null;
|
|
551
711
|
try {
|
|
@@ -587,7 +747,7 @@ function headerHasToken(header) {
|
|
|
587
747
|
* @param {number} [deps.defaultTimeoutMs]
|
|
588
748
|
* @param {(failedResult: object, ctx: { url: string, init: object, timeoutMs: number }) => Promise<{ retryUrl?: string, reason?: string, healedUrl?: string, discoveryHealed?: boolean, devServerStatus?: object } | null | undefined>} [deps.recoverFromTransportFailure]
|
|
589
749
|
* @param {() => object} [deps.getAuthHeader] Resolves the outbound Authorization header (default: {@link getAuthHeader}).
|
|
590
|
-
* @param {() => object} [deps.getVaultIdHeader] Resolves the outbound `x-heyspex-vault-id` header (default: {@link getVaultIdHeader}); throws {@link VaultNotRegisteredError} for an unregistered root.
|
|
750
|
+
* @param {() => object} [deps.getVaultIdHeader] Resolves the outbound `x-heyspex-vault-id` header (default: {@link getVaultIdHeader}); throws {@link VaultNotRegisteredError} for an unregistered root, {@link VaultNotPinnedError} for a pointer-sourced one.
|
|
591
751
|
* @param {() => object} [deps.getAccountIdHeader] Resolves the outbound `x-heyspex-account-id` header (default: {@link getAccountIdHeader}); throws {@link AccountNotConfiguredError} when no account is configured.
|
|
592
752
|
* @param {() => Promise<{ reliance: string|null, hasToken: boolean }>} [deps.getConsoleAuthState] Resolves the reliance + token state used to tell "not signed in" from "server down" (default: dev-mode core + {@link getAuthHeader}).
|
|
593
753
|
*/
|
|
@@ -881,7 +1041,11 @@ export function makeConsoleFetch({
|
|
|
881
1041
|
}
|
|
882
1042
|
const timeoutMs = options.timeout || defaultTimeoutMs;
|
|
883
1043
|
const retryOnTimeout = options.retryOnTimeout === false ? isLoopbackUrl(url) : true;
|
|
884
|
-
|
|
1044
|
+
const outcome = await withRetry(url, init, timeoutMs, { retryOnTimeout });
|
|
1045
|
+
// Degrade-don't-die: the first successful console call of the process kicks
|
|
1046
|
+
// a background replay of any queued writes (non-blocking, failure-safe).
|
|
1047
|
+
if (outcome?.ok) maybeDrainOnFirstSuccess(consoleFetch);
|
|
1048
|
+
return outcome;
|
|
885
1049
|
}
|
|
886
1050
|
|
|
887
1051
|
return { postClaimEndpoint, consoleFetch };
|
|
@@ -6,13 +6,15 @@
|
|
|
6
6
|
import {
|
|
7
7
|
consoleErrorCode,
|
|
8
8
|
consoleErrorMessage,
|
|
9
|
+
getAccountIdHeader,
|
|
9
10
|
getAuthHeader,
|
|
11
|
+
getVaultIdHeader,
|
|
10
12
|
makeConsoleFetch,
|
|
11
13
|
resolveConsoleReliance,
|
|
12
14
|
rewriteUrlOrigin,
|
|
13
15
|
} from './console-fetch.mjs';
|
|
14
16
|
import { effectiveConsoleUrl, getConfig } from './mcp-config.mjs';
|
|
15
|
-
import { contextAuthHeader } from './request-context.mjs';
|
|
17
|
+
import { contextAccountIdHeader, contextAuthHeader, contextVaultIdHeader } from './request-context.mjs';
|
|
16
18
|
import { pathToFileURL } from 'node:url';
|
|
17
19
|
import { join } from 'node:path';
|
|
18
20
|
import { existsSync } from 'node:fs';
|
|
@@ -145,6 +147,10 @@ export async function enrichConsoleFailurePayloadAsync(result, base) {
|
|
|
145
147
|
export const { postClaimEndpoint, consoleFetch } = makeConsoleFetch({
|
|
146
148
|
getConsoleUrl: effectiveConsoleUrl,
|
|
147
149
|
getAuthHeader: () => contextAuthHeader() ?? getAuthHeader(),
|
|
150
|
+
// Remote endpoint: the vault is the CALLER's `x-heyspex-vault-id`, never the
|
|
151
|
+
// server process's own resolution (P1/P3).
|
|
152
|
+
getVaultIdHeader: () => contextVaultIdHeader() ?? getVaultIdHeader(),
|
|
153
|
+
getAccountIdHeader: () => contextAccountIdHeader() ?? getAccountIdHeader(),
|
|
148
154
|
recoverFromTransportFailure: recoverConsoleTransportFailure,
|
|
149
155
|
});
|
|
150
156
|
|
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
// `hs login` / `hs login --migrate` (an explicit verb, with `--dry-run`)
|
|
57
57
|
// re-keys the legacy token under the account the console confirms for it.
|
|
58
58
|
|
|
59
|
+
import { heyspexHome } from '../../scripts/lib/heyspex-home.mjs';
|
|
59
60
|
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
60
61
|
import os, { homedir } from 'node:os';
|
|
61
62
|
import { dirname, join, resolve } from 'node:path';
|
|
@@ -69,7 +70,7 @@ export const MCP_TOKEN_ENV = 'HEYSPEX_MCP_TOKEN';
|
|
|
69
70
|
*/
|
|
70
71
|
|
|
71
72
|
/** `~/.heyspex/credentials.yaml` for `homeDir`. */
|
|
72
|
-
export function credentialsFilePath(homeDir =
|
|
73
|
+
export function credentialsFilePath(homeDir = heyspexHome()) {
|
|
73
74
|
return join(homeDir, '.heyspex', 'credentials.yaml');
|
|
74
75
|
}
|
|
75
76
|
|
|
@@ -284,7 +285,7 @@ function readLegacyConfigAccountId(read, homeDir) {
|
|
|
284
285
|
* @param {{ env?: NodeJS.ProcessEnv | Record<string, string | undefined>, homeDir?: string, readFile?: (p: string) => string | null, accountId?: string | null }} [opts]
|
|
285
286
|
* @returns {Bearer}
|
|
286
287
|
*/
|
|
287
|
-
export function readBearer({ env = process.env, homeDir =
|
|
288
|
+
export function readBearer({ env = process.env, homeDir = heyspexHome(), readFile, accountId } = {}) {
|
|
288
289
|
const read = readFile ?? ((p) => readFileSync(p, 'utf8'));
|
|
289
290
|
const credentialsText = readTextOrNull(read, credentialsFilePath(homeDir));
|
|
290
291
|
const named = typeof accountId === 'string' && accountId.trim() ? accountId.trim() : null;
|
|
@@ -302,7 +303,7 @@ export function readBearer({ env = process.env, homeDir = homedir(), readFile, a
|
|
|
302
303
|
* @param {{ homeDir?: string, readFile?: (p: string) => string | null }} [opts]
|
|
303
304
|
* @returns {CredentialsStore & { legacyAccountId: string | null }}
|
|
304
305
|
*/
|
|
305
|
-
export function readCredentialsStore({ homeDir =
|
|
306
|
+
export function readCredentialsStore({ homeDir = heyspexHome(), readFile } = {}) {
|
|
306
307
|
const read = readFile ?? ((p) => readFileSync(p, 'utf8'));
|
|
307
308
|
const store = parseCredentialsStore(readTextOrNull(read, credentialsFilePath(homeDir)));
|
|
308
309
|
const legacyAccountId = store.schemaVersion === 1 ? readLegacyConfigAccountId(read, homeDir) : null;
|
|
@@ -320,7 +321,7 @@ export function readCredentialsStore({ homeDir = homedir(), readFile } = {}) {
|
|
|
320
321
|
* @param {{ homeDir?: string, readFile?: (p: string) => string | null }} [opts]
|
|
321
322
|
* @returns {string | null}
|
|
322
323
|
*/
|
|
323
|
-
export function readCurrentAccountId({ homeDir =
|
|
324
|
+
export function readCurrentAccountId({ homeDir = heyspexHome(), readFile } = {}) {
|
|
324
325
|
const read = readFile ?? ((p) => readFileSync(p, 'utf8'));
|
|
325
326
|
const store = parseCredentialsStore(readTextOrNull(read, credentialsFilePath(homeDir)));
|
|
326
327
|
if (store.schemaVersion === 2 && store.currentAccountId && store.currentAccountId !== LEGACY_ACCOUNT_ID) {
|
|
@@ -462,7 +463,7 @@ function requireAccountId(fn, accountId) {
|
|
|
462
463
|
* @param {{ homeDir?: string, accountId: string, mcpToken: string, consoleUrl?: string | null, email?: string | null, pairedAt?: string | null, makeCurrent?: boolean, rekeyLegacyAs?: string | null }} input
|
|
463
464
|
*/
|
|
464
465
|
export function writeAccountCredentials({
|
|
465
|
-
homeDir =
|
|
466
|
+
homeDir = heyspexHome(),
|
|
466
467
|
accountId,
|
|
467
468
|
mcpToken,
|
|
468
469
|
consoleUrl = null,
|
|
@@ -504,7 +505,7 @@ function rekeyLegacy(store, rekeyAs) {
|
|
|
504
505
|
* @param {{ homeDir?: string, confirmedAccountId: string, email?: string | null, apply?: boolean }} input
|
|
505
506
|
* @returns {{ action: 'rekey' | 'already-v2' | 'nothing-to-migrate', accountId: string | null, path: string }}
|
|
506
507
|
*/
|
|
507
|
-
export function migrateLegacyCredentials({ homeDir =
|
|
508
|
+
export function migrateLegacyCredentials({ homeDir = heyspexHome(), confirmedAccountId, email = null, apply = false }) {
|
|
508
509
|
const path = credentialsFilePath(homeDir);
|
|
509
510
|
const { store } = loadForWrite(homeDir);
|
|
510
511
|
if (store.schemaVersion === 2) return { action: 'already-v2', accountId: store.currentAccountId, path };
|
|
@@ -527,7 +528,7 @@ export function migrateLegacyCredentials({ homeDir = homedir(), confirmedAccount
|
|
|
527
528
|
* pairs). Returns the file path.
|
|
528
529
|
* @param {{ homeDir?: string, accountId: string }} input
|
|
529
530
|
*/
|
|
530
|
-
export function setCurrentAccount({ homeDir =
|
|
531
|
+
export function setCurrentAccount({ homeDir = heyspexHome(), accountId }) {
|
|
531
532
|
requireAccountId('setCurrentAccount', accountId);
|
|
532
533
|
const { store } = loadForWrite(homeDir);
|
|
533
534
|
if (!store.accounts[accountId]?.mcp_token) {
|
|
@@ -545,7 +546,7 @@ export function setCurrentAccount({ homeDir = homedir(), accountId }) {
|
|
|
545
546
|
* @param {{ homeDir?: string, accountId?: string | null }} [input]
|
|
546
547
|
* @returns {{ removed: string | null, wasCurrent: boolean, currentAccountId: string | null, path: string }}
|
|
547
548
|
*/
|
|
548
|
-
export function removeAccount({ homeDir =
|
|
549
|
+
export function removeAccount({ homeDir = heyspexHome(), accountId = null } = {}) {
|
|
549
550
|
const path = credentialsFilePath(homeDir);
|
|
550
551
|
const { store } = loadForWrite(homeDir);
|
|
551
552
|
const target = accountId ?? store.currentAccountId;
|
|
@@ -5,6 +5,7 @@ import { pathToFileURL } from 'node:url';
|
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { existsSync } from 'node:fs';
|
|
7
7
|
import { PACKAGE_ROOT } from './package-root.mjs';
|
|
8
|
+
import { listQuarantinedOutbox, outboxCount } from './write-outbox.mjs';
|
|
8
9
|
import { resolveCodeRoot } from '../../scripts/lib/shared-path-helpers.mjs';
|
|
9
10
|
|
|
10
11
|
// scripts/dev-server-status.mjs and scripts/lib/dev-mode.mjs are CODE-repo
|
|
@@ -26,6 +27,12 @@ export async function handleDevServerStatus(vaultRoot) {
|
|
|
26
27
|
const coreUrl = pathToFileURL(scriptPath).href;
|
|
27
28
|
const mod = await import(coreUrl);
|
|
28
29
|
const status = await mod.getDevServerStatus({ vaultRoot });
|
|
30
|
+
// Degrade-don't-die visibility: queued / quarantined cloud writes.
|
|
31
|
+
try {
|
|
32
|
+
status.write_outbox = { pending: outboxCount(vaultRoot), quarantined: listQuarantinedOutbox(vaultRoot).length };
|
|
33
|
+
} catch {
|
|
34
|
+
/* best effort */
|
|
35
|
+
}
|
|
29
36
|
return { content: [{ type: 'text', text: JSON.stringify(status, null, 2) }] };
|
|
30
37
|
} catch (err) {
|
|
31
38
|
return {
|
|
@@ -18,6 +18,13 @@ import { randomUUID } from 'node:crypto';
|
|
|
18
18
|
import { consoleFetch, enrichConsoleFailurePayloadAsync } from './console-transport.mjs';
|
|
19
19
|
import { consoleErrorCode, consoleErrorMessage } from './console-fetch.mjs';
|
|
20
20
|
import { resolveReadTier } from './read-path.mjs';
|
|
21
|
+
import {
|
|
22
|
+
buildDeferredPayload,
|
|
23
|
+
deferredToolResponse,
|
|
24
|
+
drainAndSummarize,
|
|
25
|
+
isOutageClassResult,
|
|
26
|
+
stashIfOutage,
|
|
27
|
+
} from './write-outbox.mjs';
|
|
21
28
|
import { join } from 'node:path';
|
|
22
29
|
import { statSync, existsSync } from 'node:fs';
|
|
23
30
|
import { importDevModeCore } from './dev-mode-core.mjs';
|
|
@@ -456,6 +463,9 @@ export async function handleFileSpec(args = {}, vaultRoot = null) {
|
|
|
456
463
|
// shared `init`), so a slow-console retry replays the first create server-side
|
|
457
464
|
// instead of double-allocating a second byte-identical spec.
|
|
458
465
|
const idempotencyKey = randomUUID();
|
|
466
|
+
// Degrade-don't-die: replay anything queued by an earlier outage first (the
|
|
467
|
+
// drain never stashes, and is failure-safe).
|
|
468
|
+
const outboxNote = vaultRoot ? await drainAndSummarize(vaultRoot, consoleFetch) : {};
|
|
459
469
|
const result = await consoleFetch('/api/specs', {
|
|
460
470
|
method: 'POST',
|
|
461
471
|
body,
|
|
@@ -469,6 +479,30 @@ export async function handleFileSpec(args = {}, vaultRoot = null) {
|
|
|
469
479
|
retryOnTimeout: false,
|
|
470
480
|
});
|
|
471
481
|
if (!result.ok) {
|
|
482
|
+
// Outage class (status 0 / timeout / 5xx) in CLOUD reliance: queue the create
|
|
483
|
+
// with its idempotency key and answer truthfully "deferred" — never isError,
|
|
484
|
+
// never a claim it landed. 4xx / 401 / sign-in problems keep their loud envelope.
|
|
485
|
+
if (vaultRoot && isOutageClassResult(result) && (await resolveReadTier()) === 'neon') {
|
|
486
|
+
const stashed = stashIfOutage(
|
|
487
|
+
result,
|
|
488
|
+
{
|
|
489
|
+
op: 'create',
|
|
490
|
+
route: '/api/specs',
|
|
491
|
+
method: 'POST',
|
|
492
|
+
request_body: body,
|
|
493
|
+
idempotency_key: idempotencyKey,
|
|
494
|
+
acting_agent: actingAgent,
|
|
495
|
+
session_id: args.session_id ?? null,
|
|
496
|
+
},
|
|
497
|
+
vaultRoot,
|
|
498
|
+
);
|
|
499
|
+
if (stashed) {
|
|
500
|
+
return deferredToolResponse({
|
|
501
|
+
...buildDeferredPayload(stashed, vaultRoot, { attempted: { type: args.type, name: args.name } }),
|
|
502
|
+
...(outboxNote.drained ? { drained: outboxNote.drained } : {}),
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
}
|
|
472
506
|
const payload = await enrichConsoleFailurePayloadAsync(result, {
|
|
473
507
|
error: consoleErrorCode(result, 'FILE_SPEC_FAILED'),
|
|
474
508
|
message: consoleErrorMessage(result, `POST /api/specs returned ${result.status}`),
|
|
@@ -523,6 +557,7 @@ export async function handleFileSpec(args = {}, vaultRoot = null) {
|
|
|
523
557
|
landed,
|
|
524
558
|
spec,
|
|
525
559
|
console_response: result.body,
|
|
560
|
+
...outboxNote,
|
|
526
561
|
};
|
|
527
562
|
if (landingError) {
|
|
528
563
|
return {
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
* call these tools with no extra setup.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
+
import { heyspexHome } from '../../scripts/lib/heyspex-home.mjs';
|
|
27
28
|
import { execFileSync } from 'node:child_process';
|
|
28
29
|
import { readFileSync } from 'node:fs';
|
|
29
30
|
import { homedir, platform } from 'node:os';
|
|
@@ -40,7 +41,7 @@ export function resolveFleetToken() {
|
|
|
40
41
|
const env = process.env.HEYSPEX_FLEET_TOKEN;
|
|
41
42
|
if (env && env.trim()) return env.trim();
|
|
42
43
|
try {
|
|
43
|
-
const yaml = readFileSync(join(
|
|
44
|
+
const yaml = readFileSync(join(heyspexHome(), '.heyspex', 'credentials.yaml'), 'utf8');
|
|
44
45
|
const clerk = yaml.match(/^clerk:\s*$([\s\S]*?)(?=^\S|\s*$(?![\s\S]))/m)?.[1] ?? yaml;
|
|
45
46
|
const m = clerk.match(/^\s*companion_token:\s*["']?([^"'\r\n#]+)/m);
|
|
46
47
|
if (m?.[1]?.trim()) return m[1].trim();
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* own header; this file only carries the read/write/list primitives, not the trust decision
|
|
18
18
|
* logic itself.
|
|
19
19
|
*/
|
|
20
|
+
import { heyspexHome } from '../../scripts/lib/heyspex-home.mjs';
|
|
20
21
|
import fs from 'node:fs';
|
|
21
22
|
import os from 'node:os';
|
|
22
23
|
import path from 'node:path';
|
|
@@ -25,7 +26,7 @@ import path from 'node:path';
|
|
|
25
26
|
export const TRUST_STORE_REL = path.join('.heyspex', 'trusted-folders.json');
|
|
26
27
|
|
|
27
28
|
/** Resolve the real trust-store path for a given home directory (defaults to `os.homedir()`). */
|
|
28
|
-
export function trustStorePath(home =
|
|
29
|
+
export function trustStorePath(home = heyspexHome()) {
|
|
29
30
|
return path.join(home, TRUST_STORE_REL);
|
|
30
31
|
}
|
|
31
32
|
|
|
@@ -125,6 +125,7 @@ export function toolNotAvailableRemotelyError(id, name) {
|
|
|
125
125
|
* @property {string|null} [sessionId] The client's `Mcp-Session-Id`, if any.
|
|
126
126
|
* @property {string} [consoleUrl] Origin the in-process handlers should call back to (the deployment itself).
|
|
127
127
|
* @property {Record<string, string>} [authHeader] The caller's own credential to forward on those calls.
|
|
128
|
+
* @property {string|null} [vaultId] The caller's own `x-heyspex-vault-id`, forwarded so the call is scoped to the vault the CALLER named.
|
|
128
129
|
* @property {string} [agentName] Default `agent` for tools whose schema declares one.
|
|
129
130
|
*/
|
|
130
131
|
|
|
@@ -204,6 +205,7 @@ export function createRemoteMcpHandler(deps) {
|
|
|
204
205
|
const requestContext = {
|
|
205
206
|
...(ctx?.consoleUrl ? { consoleUrl: ctx.consoleUrl } : {}),
|
|
206
207
|
...(ctx?.authHeader ? { authHeader: ctx.authHeader } : {}),
|
|
208
|
+
...(ctx?.vaultId ? { vaultId: ctx.vaultId } : {}),
|
|
207
209
|
};
|
|
208
210
|
try {
|
|
209
211
|
const result = await runWithRequestContext(requestContext, () => dispatch(name, args, vaultRoot, config));
|
|
@@ -245,7 +245,7 @@ export async function walkSpecListPages(consoleFetch, path, { limit = 500, maxPa
|
|
|
245
245
|
const pagePath = `${url.pathname}${url.search}`;
|
|
246
246
|
const res = await consoleFetch(pagePath);
|
|
247
247
|
if (!res.ok) {
|
|
248
|
-
throw new Error(`GET ${pagePath} returned ${res.status}: ${res.error || JSON.stringify(res.body ?? {})}`);
|
|
248
|
+
throw Object.assign(new Error(`GET ${pagePath} returned ${res.status}: ${res.error || JSON.stringify(res.body ?? {})}`), { status: res.status, errorKind: res.errorKind, authProblem: res.authProblem || res.refusal ? true : undefined });
|
|
249
249
|
}
|
|
250
250
|
const specs = Array.isArray(res.body?.specs) ? res.body.specs : [];
|
|
251
251
|
rows.push(...specs);
|
|
@@ -4,8 +4,11 @@
|
|
|
4
4
|
|
|
5
5
|
import { contextConsoleUrl } from './request-context.mjs';
|
|
6
6
|
import { getActiveConfig } from './vault-resolve.mjs';
|
|
7
|
+
import { resolveDefaultRoadmap } from './roadmap-default.mjs';
|
|
7
8
|
import { resolveConsoleUrl } from '../../scripts/lib/console-url-resolver.mjs';
|
|
8
9
|
|
|
10
|
+
const warnedMultiRoadmap = new Set();
|
|
11
|
+
|
|
9
12
|
export function getConfig() {
|
|
10
13
|
// Vault resolution is delegated to the shared 5-tier resolver
|
|
11
14
|
// (vault-resolve.mjs) so the MCP face agrees with the console face on which
|
|
@@ -19,7 +22,17 @@ export function getConfig() {
|
|
|
19
22
|
const { repoRootAbs, vaultId, source: vaultSource } = getActiveConfig();
|
|
20
23
|
const vaultRoot = repoRootAbs;
|
|
21
24
|
|
|
22
|
-
|
|
25
|
+
// Per-vault default roadmap (env > the vault's own `type: roadmap` spec >
|
|
26
|
+
// platform constant) — never the platform id for a customer vault.
|
|
27
|
+
const roadmap = resolveDefaultRoadmap(vaultRoot);
|
|
28
|
+
if (roadmap.source === 'vault-multiple' && !warnedMultiRoadmap.has(vaultRoot)) {
|
|
29
|
+
warnedMultiRoadmap.add(vaultRoot);
|
|
30
|
+
process.stderr.write?.(
|
|
31
|
+
`[heyspex-mcp] vault ${vaultRoot} has ${roadmap.candidates.length} roadmaps (${roadmap.candidates.join(', ')}); defaulting to ${roadmap.id}. Pass roadmap_id to choose.
|
|
32
|
+
`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
const defaultRoadmapId = roadmap.id;
|
|
23
36
|
|
|
24
37
|
return { vaultRoot, vaultId, vaultSource, defaultRoadmapId };
|
|
25
38
|
}
|