heyspex 0.2.1 → 0.2.3
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 +10 -0
- package/bin/hs-vault-binding.mjs +157 -0
- package/bin/hs.mjs +99 -81
- package/bin/spexcode.mjs +91 -0
- package/dev-factory/engine/worktree.mjs +36 -7
- package/dev-factory/lib/epic-detect.mjs +2 -2
- package/dev-factory/lib/fleet-readiness.mjs +3 -5
- package/dev-factory/lib/fleet-state.mjs +2 -2
- package/dev-factory/lib/sufficiency-score.mjs +3 -5
- package/dist/cli/hs-check.mjs +76 -46
- package/dist/cli/hs-claim.mjs +63 -33
- package/dist/cli/hs-connect.mjs +520 -214
- package/dist/cli/hs-defer.mjs +161 -19
- package/dist/cli/hs-dev.mjs +11 -5
- package/dist/cli/hs-env.mjs +187 -161
- package/dist/cli/hs-index-rebuild.mjs +123 -78
- package/dist/cli/hs-index-reconcile.mjs +203 -107
- package/dist/cli/hs-init.mjs +426 -179
- package/dist/cli/hs-install.mjs +759 -437
- package/dist/cli/hs-join.mjs +590 -383
- package/dist/cli/hs-vault-pull.mjs +359 -254
- package/dist/cli/hs-vault-push.mjs +151 -113
- package/dist/cli/hs-vault.mjs +271 -104
- package/dist/cli/hs-workspace.mjs +90 -57
- package/dist/cli-templates/customer-vault/specs/protocols/session-close-protocol.md +178 -91
- package/docs/copilot-plugin-install.md +189 -162
- package/docs/driving-heyspex-from-copilot.md +174 -165
- package/docs/mcp-client-config.md +93 -25
- package/package.json +255 -249
- package/plugins/heyspex/.mcp.json +3 -4
- package/plugins/heyspex/README.md +323 -309
- package/plugins/heyspex/bin/launch-mcp.mjs +21 -7
- package/plugins/heyspex/mcp.json +12 -13
- package/scripts/check-mcp-freshness.mjs +6 -4
- package/scripts/claude-hooks/_intent-classifier-router.mjs +518 -456
- package/scripts/claude-hooks/intent-classifier-trigger.mjs +305 -279
- package/scripts/claude-hooks/research-method-guard.mjs +2 -2
- package/scripts/claude-hooks/wip-checkpoint.mjs +33 -9
- package/scripts/claude-statusline/fonts/HeySpexIcons.ttf +0 -0
- package/scripts/claude-statusline/statusline-command.mjs +1058 -0
- package/scripts/dev-server-status.mjs +3 -2
- package/scripts/dev-server-stop.mjs +3 -2
- package/scripts/hs-capture-cli.mjs +2 -2
- package/scripts/hs-settings.mjs +2 -2
- package/scripts/hs-view.mjs +3 -2
- package/scripts/install-copilot-hooks.mjs +318 -0
- package/scripts/lib/copilot-cli.mjs +108 -0
- package/scripts/lib/customer-kit.mjs +223 -30
- package/scripts/lib/dev-mode.mjs +28 -2
- package/scripts/lib/shared-path-helpers.mjs +32 -0
- package/scripts/lib/standalone-doctor.mjs +131 -32
- package/scripts/wip-ledger-health.mjs +3 -2
- package/spexharness/src/gates/vault-mirror-catchup.mjs +113 -24
- package/spexharness/src/gates/vault-mirror-catchup.runner.mjs +52 -0
- package/spexharness/src/intent-directive.mjs +839 -626
- package/spexharness/src/rule-table.mjs +1 -1
- package/vendor/heyspex-mcp/cli-verbs.mjs +16 -2
- package/vendor/heyspex-mcp/cli.mjs +20 -4
- package/vendor/heyspex-mcp/console-fetch.mjs +15 -3
- package/vendor/heyspex-mcp/dev-tools.mjs +11 -4
- package/vendor/heyspex-mcp/events-tools.mjs +1 -1
- package/vendor/heyspex-mcp/factory-run-status.mjs +42 -0
- package/vendor/heyspex-mcp/mcp-config.mjs +4 -2
- package/vendor/heyspex-mcp/spec-edit-tools.mjs +26 -1
- package/vendor/heyspex-mcp/test-helpers.mjs +5 -2
- package/vendor/heyspex-mcp/tool-catalog.mjs +12 -0
- package/vendor/heyspex-mcp/tool-dispatch.mjs +59 -23
- package/vendor/heyspex-mcp/usable-dir-env.mjs +82 -0
- package/vendor/heyspex-mcp/vault-binding.mjs +839 -0
- package/vendor/heyspex-mcp/vault-resolve.mjs +353 -245
|
@@ -172,7 +172,7 @@ export const BUILT_IN_RULES = Object.freeze(
|
|
|
172
172
|
"configKey": "agent_behaviors.coo_delegation_guard.enabled",
|
|
173
173
|
"ignoreInjectedContent": false,
|
|
174
174
|
"patterns": [],
|
|
175
|
-
"inject": "DEV-FACTORY DELEGATION GUARD (dev-factory-delegation-routing) — as COO you do not do a\nphase's reasoning in your own context. Before the breakdown phase for the on-deck epic,\nthe guard checks the seat-audit record: if that phase has NOT been delegated to the seat\nthe routing table names for it, the exact delegation command is injected and you run it\nfirst. Delegation is table-driven — consult dev-factory/routing-table.json and route to\nthe calibrated seat model (build →
|
|
175
|
+
"inject": "DEV-FACTORY DELEGATION GUARD (dev-factory-delegation-routing) — as COO you do not do a\nphase's reasoning in your own context. Before the breakdown phase for the on-deck epic,\nthe guard checks the seat-audit record: if that phase has NOT been delegated to the seat\nthe routing table names for it, the exact delegation command is injected and you run it\nfirst. Delegation is table-driven — consult dev-factory/routing-table.json and route to\nthe calibrated seat model (build → cloud DeepSeek builder via controller.mjs, local\nfleet paused by owner ruling 2026-10-15; breakdown/critic/synthesis → DeepSeek;\ncloud/Opus is escalation-only). \"Delegate-first\" NEVER means spawning a\nsubagent on the session's own tier. If you have already delegated this breakdown,\nproceed. The guard fails open: a missing or unreadable seat registry / audit record\nnever blocks session start or a claim.\n",
|
|
176
176
|
"naming": null
|
|
177
177
|
},
|
|
178
178
|
{
|
|
@@ -17,10 +17,14 @@ import { heyspexHome } from '../../scripts/lib/heyspex-home.mjs';
|
|
|
17
17
|
import { DEFAULT_LOGIN_TIMEOUT_MS, buildConnectUrl, describeCallbackFailure, describeRedeemFailure, generateLoginState, openBrowser as openBrowserDefault, startCallbackListener as startCallbackListenerDefault } from './login-handoff.mjs';
|
|
18
18
|
import { getConfig, getConsoleUrl } from './mcp-config.mjs';
|
|
19
19
|
import { homedir, hostname } from 'node:os';
|
|
20
|
-
import { join } from 'node:path';
|
|
20
|
+
import { dirname, join } from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
21
22
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
22
23
|
import { handleToolsCall, handleToolsList } from './tool-dispatch.mjs';
|
|
23
24
|
import { writeBuildRecord } from '../../scripts/check-mcp-freshness.mjs';
|
|
25
|
+
|
|
26
|
+
/** This server's own code directory (vendor/heyspex-mcp of the checkout it runs from). */
|
|
27
|
+
const SERVER_CODE_DIR = dirname(fileURLToPath(import.meta.url));
|
|
24
28
|
import { registerSpec } from './register-spec.mjs';
|
|
25
29
|
import { validateLinkage } from './validate-linkage.mjs';
|
|
26
30
|
import { execFileSync } from 'node:child_process';
|
|
@@ -1615,9 +1619,19 @@ stdio parent so tool schemas stay current without reconnecting Cursor).
|
|
|
1615
1619
|
*/
|
|
1616
1620
|
export function stampMcpBuildRecord() {
|
|
1617
1621
|
try {
|
|
1618
|
-
const { vaultRoot: fingerprintVaultRoot } = getConfig();
|
|
1622
|
+
const { vaultRoot: fingerprintVaultRoot, vaultSource } = getConfig();
|
|
1623
|
+
// Unbound ([[HS-TYJA]]): vaultRoot is only the launch folder, not a
|
|
1624
|
+
// vault - never create .heyspex/ there.
|
|
1625
|
+
if (vaultSource === 'unbound') return;
|
|
1626
|
+
// HS-C0NW: never mkdir a .heyspex/ into a folder that is not there (a host that passed an
|
|
1627
|
+
// unexpanded `${CLAUDE_PROJECT_DIR}` used to leave a junk directory by that name).
|
|
1628
|
+
if (!existsSync(fingerprintVaultRoot)) return;
|
|
1619
1629
|
writeBuildRecord({
|
|
1620
1630
|
vaultRoot: fingerprintVaultRoot,
|
|
1631
|
+
// Fingerprint the code this process actually runs. Since the repo split
|
|
1632
|
+
// the vault holds no vendor/ tree, so `<vaultRoot>/vendor/heyspex-mcp`
|
|
1633
|
+
// fingerprinted nothing (null) for every bound repo.
|
|
1634
|
+
vendorDir: SERVER_CODE_DIR,
|
|
1621
1635
|
pid: process.pid,
|
|
1622
1636
|
startedAt: new Date().toISOString(),
|
|
1623
1637
|
});
|
|
@@ -52,6 +52,12 @@ import { filterToolSurface, handleLoadTools } from './tool-catalog.mjs';
|
|
|
52
52
|
import { handlePromptsGet, handlePromptsList } from './prompts.mjs';
|
|
53
53
|
import { SERVER_VERSION, handleInitialize, handleToolsCall, handleToolsList, resolveInjectableSeat, vaultRootGateVerdict } from './tool-dispatch.mjs';
|
|
54
54
|
import { runCallCli, runExportCli, runLoginCli, runLogoutCli, runMcpInstallCli, runPairCli, runRegisterCli, runValidateCli, runWhoamiCli, stampMcpBuildRecord } from './cli-verbs.mjs';
|
|
55
|
+
import { scrubUnusableDirEnv } from './usable-dir-env.mjs';
|
|
56
|
+
|
|
57
|
+
// HS-C0NW: a host that does not expand `${CLAUDE_PROJECT_DIR}` in the MCP manifest hands us the
|
|
58
|
+
// literal text. Treat such values as unset so every reader (resolver, console-fetch, subprocess
|
|
59
|
+
// children that inherit this env) falls through to the cwd walk instead of mkdir-ing a junk path.
|
|
60
|
+
scrubUnusableDirEnv(process.env, { requireExisting: { HEYSPEX_VAULT_ROOT: false } });
|
|
55
61
|
|
|
56
62
|
// Dev-factory seat identity (epic HS-CXO0): surface each caller's seat on connect.
|
|
57
63
|
// resolveCooModeEnabled is the SHARED readiness predicate — the SessionStart hook
|
|
@@ -191,7 +197,7 @@ function handleRequest(request) {
|
|
|
191
197
|
const vaultRoot = config.vaultRoot;
|
|
192
198
|
// Same mode-aware gate as tools/call: an absent clone only refuses on
|
|
193
199
|
// the disk tier (see vaultRootGateVerdict in tool-dispatch.mjs).
|
|
194
|
-
const promptGate = vaultRootGateVerdict(vaultRoot);
|
|
200
|
+
const promptGate = vaultRootGateVerdict(vaultRoot, { vaultSource: config.vaultSource, vaultError: config.vaultError });
|
|
195
201
|
if (promptGate) {
|
|
196
202
|
sendError(id, -32003, promptGate.message);
|
|
197
203
|
break;
|
|
@@ -302,8 +308,10 @@ const INFO_ONLY = SHOW_HELP || SHOW_VERSION;
|
|
|
302
308
|
// SV2K: migrate legacy .heyspex/idea-focus.yaml → capture-focus.yaml on startup.
|
|
303
309
|
if (!INFO_ONLY) {
|
|
304
310
|
try {
|
|
305
|
-
const { vaultRoot: bootVaultRoot } = getConfig();
|
|
306
|
-
|
|
311
|
+
const { vaultRoot: bootVaultRoot, vaultSource: bootSource } = getConfig();
|
|
312
|
+
// Unbound ([[HS-TYJA]]): vaultRoot is only the launch folder, not a vault -
|
|
313
|
+
// never migrate files there.
|
|
314
|
+
if (bootSource !== 'unbound' && existsSync(bootVaultRoot)) {
|
|
307
315
|
migrateLegacyIdeaFocus(bootVaultRoot);
|
|
308
316
|
}
|
|
309
317
|
} catch (err) {
|
|
@@ -389,7 +397,15 @@ if (!RUNNING_AS_CLI && !INFO_ONLY && process.env.HEYSPEX_DISABLE_STDIO_BOOTSTRAP
|
|
|
389
397
|
// unless HEYSPEX_ALLOW_POINTER_VAULT=1 — in which case say which vault
|
|
390
398
|
// this process will act on, once, right here at start.
|
|
391
399
|
announcePointerVaultOnce({ vaultId: bootVaultId, repoRootAbs: bootVaultRoot, source: bootVaultSource });
|
|
392
|
-
if (
|
|
400
|
+
if (bootVaultSource === 'unbound') {
|
|
401
|
+
// No vault for this launch folder ([[HS-TYJA]]). The server still boots
|
|
402
|
+
// (tools/list works); every vault-scoped call is refused per call with
|
|
403
|
+
// the typed code (console-fetch.mjs). Say why once, at start.
|
|
404
|
+
const bootErr = getConfig().vaultError;
|
|
405
|
+
process.stderr.write?.(
|
|
406
|
+
`[heyspex-mcp] ${bootErr?.code ?? 'VAULT_UNBOUND'}: no vault for ${bootVaultRoot}; vault tools will refuse until fixed. Fix: ${bootErr?.fix ?? 'hs vault bind --vault <name|id>'}\n`,
|
|
407
|
+
);
|
|
408
|
+
} else if (existsSync(bootVaultRoot)) {
|
|
393
409
|
captureChannelWatcher = createCaptureChannelWatcher({
|
|
394
410
|
vaultRoot: bootVaultRoot,
|
|
395
411
|
getConsoleUrl,
|
|
@@ -99,7 +99,7 @@ export { isVaultLessEndpoint, VAULT_LESS_ENDPOINTS };
|
|
|
99
99
|
// ([[HS-I6HT-EKYU-DIAG-every-message-tells-the-user-to-run-hs]]).
|
|
100
100
|
import { hsCommand } from '../../scripts/lib/hs-command.mjs';
|
|
101
101
|
import { maybeDrainOnFirstSuccess } from './write-outbox.mjs';
|
|
102
|
-
import { getActiveConfig, registryAccountIdForRoot, UNREGISTERED_VAULT_ID } from './vault-resolve.mjs';
|
|
102
|
+
import { getActiveConfig, isVaultResolutionError, registryAccountIdForRoot, UNREGISTERED_VAULT_ID } from './vault-resolve.mjs';
|
|
103
103
|
|
|
104
104
|
export const DEFAULT_CONSOLE_TIMEOUT_MS = 30000;
|
|
105
105
|
|
|
@@ -524,12 +524,13 @@ export class AccountNotConfiguredError extends Error {
|
|
|
524
524
|
}
|
|
525
525
|
}
|
|
526
526
|
|
|
527
|
-
/** True for the
|
|
527
|
+
/** True for the local scope refusals: this module's three, plus the resolver's VaultResolutionError (VAULT_UNBOUND / VAULT_BINDING_BROKEN). */
|
|
528
528
|
export function isLocalScopeRefusal(err) {
|
|
529
529
|
return (
|
|
530
530
|
err instanceof VaultNotRegisteredError
|
|
531
531
|
|| err instanceof AccountNotConfiguredError
|
|
532
532
|
|| err instanceof VaultNotPinnedError
|
|
533
|
+
|| isVaultResolutionError(err)
|
|
533
534
|
);
|
|
534
535
|
}
|
|
535
536
|
|
|
@@ -552,6 +553,12 @@ export function isLocalScopeRefusal(err) {
|
|
|
552
553
|
export function vaultIdHeaderFor(resolved, opts = {}) {
|
|
553
554
|
const vaultId = typeof resolved?.vaultId === 'string' ? resolved.vaultId.trim() : '';
|
|
554
555
|
if (!vaultId) return {};
|
|
556
|
+
// Nothing names a vault for this repo ([[HS-TYJA]]: no default "active"
|
|
557
|
+
// vault exists): refuse with the resolver's typed VAULT_UNBOUND /
|
|
558
|
+
// VAULT_BINDING_BROKEN, whose message carries the `hs vault bind` fix.
|
|
559
|
+
if (resolved?.source === 'unbound' && isVaultResolutionError(resolved.error)) {
|
|
560
|
+
throw resolved.error;
|
|
561
|
+
}
|
|
555
562
|
if (vaultId === UNREGISTERED_VAULT_ID) {
|
|
556
563
|
throw new VaultNotRegisteredError(resolved?.repoRootAbs, resolved?.source);
|
|
557
564
|
}
|
|
@@ -624,7 +631,7 @@ export function getAccountIdHeader({ repoAccountId, ...opts } = {}) {
|
|
|
624
631
|
* `consoleFetch` already handles (`ok: false`, `status: 0`), with the code on
|
|
625
632
|
* `refusal` AND on `authProblem` so `consoleErrorCode` / the never-buffer
|
|
626
633
|
* contract pick it up with no per-caller change. Pure.
|
|
627
|
-
* @param {VaultNotRegisteredError | AccountNotConfiguredError | VaultNotPinnedError | AccountTokenMissingError} err
|
|
634
|
+
* @param {VaultNotRegisteredError | AccountNotConfiguredError | VaultNotPinnedError | AccountTokenMissingError | import("./vault-binding.mjs").VaultResolutionError} err
|
|
628
635
|
* @param {string} url the request that was NOT sent
|
|
629
636
|
*/
|
|
630
637
|
export function scopeRefusalResult(err, url) {
|
|
@@ -638,6 +645,11 @@ export function scopeRefusalResult(err, url) {
|
|
|
638
645
|
refusal: err.code,
|
|
639
646
|
authProblem: err.code,
|
|
640
647
|
authMessage: err.message,
|
|
648
|
+
// VaultResolutionError ([[HS-TYJA]]): the exact repair command and the
|
|
649
|
+
// folder resolution walked from, so the tool refusal names the same fix
|
|
650
|
+
// `hs vault resolve` prints.
|
|
651
|
+
...(typeof err.fix === 'string' && err.fix ? { fix: err.fix } : {}),
|
|
652
|
+
...(typeof err.from === 'string' && err.from ? { from: err.from } : {}),
|
|
641
653
|
};
|
|
642
654
|
}
|
|
643
655
|
|
|
@@ -18,6 +18,10 @@ import { resolveCodeRoot } from '../../scripts/lib/shared-path-helpers.mjs';
|
|
|
18
18
|
// elsewhere; same precedence plan-review.mjs's `resolveCodeAuditRoots` uses.
|
|
19
19
|
const CHECKOUT_ROOT = resolveCodeRoot({ packageRoot: PACKAGE_ROOT });
|
|
20
20
|
|
|
21
|
+
// Where the cloud admin dev-mode setting lives. Used when an older
|
|
22
|
+
// scripts/lib/dev-mode.mjs does not export CLOUD_CONSOLE_URL itself.
|
|
23
|
+
export const CLOUD_DEV_MODE_URL = 'https://app.heyspex.com';
|
|
24
|
+
|
|
21
25
|
export async function handleDevServerStatus(vaultRoot) {
|
|
22
26
|
try {
|
|
23
27
|
const local = join(CHECKOUT_ROOT, 'scripts', 'dev-server-status.mjs');
|
|
@@ -93,10 +97,13 @@ export async function handleDevMode(vaultRoot, args = {}) {
|
|
|
93
97
|
}
|
|
94
98
|
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
95
99
|
}
|
|
96
|
-
// get (default):
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
100
|
+
// get (default): read the cloud admin setting from the CLOUD console —
|
|
101
|
+
// never the resolved console URL, which under a local override is
|
|
102
|
+
// localhost and can never answer for the cloud (staleAgainstCloud then
|
|
103
|
+
// silently read false) — and fold it into the resolution.
|
|
104
|
+
const cloud = await mod.fetchCloudDevMode({
|
|
105
|
+
consoleUrl: mod.CLOUD_CONSOLE_URL ?? CLOUD_DEV_MODE_URL,
|
|
106
|
+
});
|
|
100
107
|
const resolved = mod.resolveDevMode({
|
|
101
108
|
cloudSetting: cloud.ok ? cloud.setting : undefined,
|
|
102
109
|
});
|
|
@@ -182,7 +182,7 @@ export async function handleExportEvents(args = {}) {
|
|
|
182
182
|
let vault = null;
|
|
183
183
|
try {
|
|
184
184
|
vault = resolveActiveVault();
|
|
185
|
-
if (vault?.specsDirAbs) body.expected_vault_root = vault.specsDirAbs;
|
|
185
|
+
if (vault?.specsDirAbs && vault.source !== 'unbound') body.expected_vault_root = vault.specsDirAbs;
|
|
186
186
|
} catch { /* best-effort — the export still runs unguarded when unresolvable */ }
|
|
187
187
|
const result = await consoleFetch('/api/control/export-events', { method: 'POST', body });
|
|
188
188
|
if (!result.ok) return controlError(result, 'EXPORT_EVENTS_FAILED');
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// heyspex_factory_run_status - the MCP face of the SpexCode run-state model (epic HS-P5NU, task HS-8AOE).
|
|
2
|
+
//
|
|
3
|
+
// The SAME snapshot `spexcode status --json` and GET /api/factory/run/status return: run id, lease,
|
|
4
|
+
// heartbeat, current seat, queue positions, stopped reason and the recent audit trail from
|
|
5
|
+
// dev-factory/state/seat-events.jsonl. The reader (dev-factory/lib/run-state-model.mjs) is a code-repo
|
|
6
|
+
// artifact, so it is imported lazily from the checkout (dev-factory-bridge pattern) - a missing
|
|
7
|
+
// dev-factory degrades to a typed error, never a crash of the MCP server.
|
|
8
|
+
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { pathToFileURL } from 'node:url';
|
|
12
|
+
import { PACKAGE_ROOT } from './package-root.mjs';
|
|
13
|
+
import { resolveCodeRoot } from '../../scripts/lib/shared-path-helpers.mjs';
|
|
14
|
+
|
|
15
|
+
const CHECKOUT_ROOT = resolveCodeRoot({ packageRoot: PACKAGE_ROOT });
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {object} [args] { trail?: number } how many recent audit events (default 8, max 200)
|
|
19
|
+
* @param {{ codeRoot?: string, isPidAlive?: (pid:number)=>boolean, now?: ()=>number }} [deps]
|
|
20
|
+
*/
|
|
21
|
+
export async function handleFactoryRunStatus(args = {}, deps = {}) {
|
|
22
|
+
try {
|
|
23
|
+
const root = deps.codeRoot ?? CHECKOUT_ROOT;
|
|
24
|
+
const modPath = join(root, 'dev-factory', 'lib', 'run-state-model.mjs');
|
|
25
|
+
if (!existsSync(modPath)) {
|
|
26
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'FACTORY_RUN_STATUS_UNAVAILABLE', message: `dev-factory run-state model not found at ${modPath}` }, null, 2) }] };
|
|
27
|
+
}
|
|
28
|
+
const mod = await import(pathToFileURL(modPath).href);
|
|
29
|
+
const requested = Number(args?.trail);
|
|
30
|
+
const trail = Number.isFinite(requested) ? Math.min(Math.max(Math.trunc(requested), 0), 200) : 8;
|
|
31
|
+
const model = mod.readRunStateModel({
|
|
32
|
+
stateDir: join(root, 'dev-factory', 'state'),
|
|
33
|
+
queuePath: join(root, 'dev-factory', 'coo-queue.json'),
|
|
34
|
+
trail,
|
|
35
|
+
...(deps.isPidAlive ? { isPidAlive: deps.isPidAlive } : {}),
|
|
36
|
+
...(deps.now ? { now: deps.now } : {}),
|
|
37
|
+
});
|
|
38
|
+
return { content: [{ type: 'text', text: JSON.stringify(model, null, 2) }] };
|
|
39
|
+
} catch (err) {
|
|
40
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'FACTORY_RUN_STATUS_FAILED', message: String(err?.message ?? err) }, null, 2) }] };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -19,7 +19,9 @@ export function getConfig() {
|
|
|
19
19
|
// vaultId + vaultSource ([[HS-M95C]]): the locally resolved identity, so a
|
|
20
20
|
// cloud console request can be scoped to THIS repo's vault instead of the
|
|
21
21
|
// account's active-vault pointer (see console-fetch.mjs `getVaultIdHeader`).
|
|
22
|
-
|
|
22
|
+
// vaultError: the typed VaultResolutionError when source is 'unbound'
|
|
23
|
+
// ([[HS-TYJA]]); vaultRoot is then only the launch folder, never a vault.
|
|
24
|
+
const { repoRootAbs, vaultId, source: vaultSource, error: vaultError = null } = getActiveConfig();
|
|
23
25
|
const vaultRoot = repoRootAbs;
|
|
24
26
|
|
|
25
27
|
// Per-vault default roadmap (env > the vault's own `type: roadmap` spec >
|
|
@@ -34,7 +36,7 @@ export function getConfig() {
|
|
|
34
36
|
}
|
|
35
37
|
const defaultRoadmapId = roadmap.id;
|
|
36
38
|
|
|
37
|
-
return { vaultRoot, vaultId, vaultSource, defaultRoadmapId };
|
|
39
|
+
return { vaultRoot, vaultId, vaultSource, vaultError, defaultRoadmapId };
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
// Resolved on every call (no caching): the MCP process is spawned once at
|
|
@@ -10,6 +10,31 @@ import { detectAgentIdentity, getConsoleUrl } from './mcp-config.mjs';
|
|
|
10
10
|
import { resolveVaultWriteMode } from './read-path.mjs';
|
|
11
11
|
import { deferredPayloadFromError, splitRawSpec, updateSpecViaConsole } from './cloud-vault-writes.mjs';
|
|
12
12
|
import { writeFileSync } from 'node:fs';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Split a whole spec for the cloud PUT with a FULL YAML parser. splitRawSpec's
|
|
16
|
+
* dependency-free parseSimpleYaml only knows flat keys, string lists, block
|
|
17
|
+
* scalars and two special nested keys, so a protocol's `enforcement:` list of
|
|
18
|
+
* objects reached the console as `['rule: <id>']` and the pull wrote that
|
|
19
|
+
* flattened block back (seen 2026-10-15 on dev-factory-delegation-routing,
|
|
20
|
+
* HS-PGHA; it broke scripts/build-guardrails.mjs). js-yaml loads lazily so the
|
|
21
|
+
* MCP startup stays lean (see yaml-scalar.mjs); CORE_SCHEMA keeps unquoted
|
|
22
|
+
* dates as strings, matching parseSimpleYaml. A YAML error falls back to the
|
|
23
|
+
* simple parser, so no edit that worked before is refused now.
|
|
24
|
+
*/
|
|
25
|
+
async function splitRawSpecFullYaml(raw) {
|
|
26
|
+
const text = String(raw ?? '');
|
|
27
|
+
const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
28
|
+
if (!match) return splitRawSpec(text);
|
|
29
|
+
try {
|
|
30
|
+
const { default: yaml } = await import('js-yaml');
|
|
31
|
+
const parsed = yaml.load(match[1], { schema: yaml.CORE_SCHEMA });
|
|
32
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return splitRawSpec(text);
|
|
33
|
+
return { frontmatter: parsed, body: text.slice(match[0].length).trim() };
|
|
34
|
+
} catch {
|
|
35
|
+
return splitRawSpec(text);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
13
38
|
import { executeShipEpicCloudAware } from './plan-push.mjs';
|
|
14
39
|
import { executeRegenTask, executeRegenTasksForEpic } from './regen-tasks.mjs';
|
|
15
40
|
import { loadTask } from './vault-io.mjs';
|
|
@@ -137,7 +162,7 @@ export async function handleEditFile(args = {}, deps = {}) {
|
|
|
137
162
|
let cloudWrite = null;
|
|
138
163
|
if (writeMode === 'cloud') {
|
|
139
164
|
const consoleUrl = deps.consoleUrl ?? getConsoleUrl();
|
|
140
|
-
const { frontmatter, body } =
|
|
165
|
+
const { frontmatter, body } = await splitRawSpecFullYaml(args.content);
|
|
141
166
|
const specId = typeof frontmatter?.id === 'string' ? frontmatter.id.trim() : '';
|
|
142
167
|
if (!specId) {
|
|
143
168
|
await releaseClaim();
|
|
@@ -146,9 +146,12 @@ export function installScopedConsoleEnv({
|
|
|
146
146
|
export function mirrorValidatorDeps(repoRoot, root) {
|
|
147
147
|
cpSync(join(repoRoot, 'scripts'), join(root, 'scripts'), { recursive: true });
|
|
148
148
|
|
|
149
|
-
const vaultResolveRel = join('vendor', 'heyspex-mcp', 'vault-resolve.mjs');
|
|
150
149
|
mkdirSync(join(root, 'vendor', 'heyspex-mcp'), { recursive: true });
|
|
151
|
-
|
|
150
|
+
// vault-resolve.mjs imports vault-binding.mjs (the repo binding, HS-TYJA).
|
|
151
|
+
for (const file of ['vault-resolve.mjs', 'vault-binding.mjs']) {
|
|
152
|
+
const rel = join('vendor', 'heyspex-mcp', file);
|
|
153
|
+
cpSync(join(repoRoot, rel), join(root, rel));
|
|
154
|
+
}
|
|
152
155
|
|
|
153
156
|
const indexerRel = join('spex-console', 'src', 'lib', 'indexer');
|
|
154
157
|
mkdirSync(join(root, indexerRel), { recursive: true });
|
|
@@ -2210,6 +2210,18 @@ export const TOOLS = [
|
|
|
2210
2210
|
additionalProperties: false,
|
|
2211
2211
|
},
|
|
2212
2212
|
},
|
|
2213
|
+
{
|
|
2214
|
+
name: 'heyspex_factory_run_status',
|
|
2215
|
+
description:
|
|
2216
|
+
'Status of the SpexCode orchestrator run (`spexcode run` / `hs factory run`), read from dev-factory/state: the SAME snapshot `spexcode status --json` and GET /api/factory/run/status return. Returns { schema: "spex.run-state/1", runId, live, front, lease { held, pid, acquiredAt, runId }, heartbeat { pid, beatAt, fresh, runId }, current (the COO seat: epic, milestone, pid, alive), queue [{ position, milestone, branch }], failures, stoppedReason, trail { file, total, events[] } } where trail.events are the last N typed seat events (the audit file seat-events.jsonl). Local disk state, read-only; deferred (load via heyspex_load_tools). Use `trail` to choose how many recent events (default 8, max 200).',
|
|
2217
|
+
inputSchema: {
|
|
2218
|
+
type: 'object',
|
|
2219
|
+
properties: {
|
|
2220
|
+
trail: { type: 'number', description: 'How many recent seat events to include (default 8, max 200; 0 = none).' },
|
|
2221
|
+
},
|
|
2222
|
+
additionalProperties: false,
|
|
2223
|
+
},
|
|
2224
|
+
},
|
|
2213
2225
|
{
|
|
2214
2226
|
name: 'heyspex_dev_mode',
|
|
2215
2227
|
description:
|
|
@@ -28,6 +28,7 @@ import { handleGetProcedureViaTier, handleListProceduresViaTier, handleRunPrefli
|
|
|
28
28
|
import { handleListRecipes, handleStartRecipe } from './recipe-handlers.mjs';
|
|
29
29
|
import { handleFleetQueueClaim, handleFleetQueueComplete, handleFleetQueueList, handleFleetQueueSubmit } from './fleet-handlers.mjs';
|
|
30
30
|
import { handleDevMode, handleDevServerStatus } from './dev-tools.mjs';
|
|
31
|
+
import { handleFactoryRunStatus } from './factory-run-status.mjs';
|
|
31
32
|
import { wrapDesignResult } from './tool-helpers.mjs';
|
|
32
33
|
import { executeSettingsGet, executeSettingsList, executeSettingsSet } from './settings-tools.mjs';
|
|
33
34
|
import { executeFirecrawlResearch, executeResolveResearchPlan } from './research-tools.mjs';
|
|
@@ -339,6 +340,8 @@ function dispatchHeySpexToolUnbudgeted(name, args, vaultRoot, config) {
|
|
|
339
340
|
return handleDevServerStatus(vaultRoot);
|
|
340
341
|
case 'heyspex_dev_mode':
|
|
341
342
|
return handleDevMode(vaultRoot, args);
|
|
343
|
+
case 'heyspex_factory_run_status':
|
|
344
|
+
return handleFactoryRunStatus(args);
|
|
342
345
|
case 'heyspex_settings_list':
|
|
343
346
|
return wrapDesignResult(executeSettingsList(vaultRoot, args));
|
|
344
347
|
case 'heyspex_settings_get':
|
|
@@ -390,8 +393,14 @@ function dispatchHeySpexToolUnbudgeted(name, args, vaultRoot, config) {
|
|
|
390
393
|
* BYPASSES this gate on purpose — it calls the `*ViaTier` helpers directly —
|
|
391
394
|
* so it proves the handlers, not the gate; keep both.
|
|
392
395
|
*/
|
|
393
|
-
export function vaultRootGateVerdict(vaultRoot, { env = process.env } = {}) {
|
|
394
|
-
|
|
396
|
+
export function vaultRootGateVerdict(vaultRoot, { env = process.env, vaultSource = null, vaultError = null } = {}) {
|
|
397
|
+
// Unbound ([[HS-TYJA]]): `vaultRoot` is only the launch folder, never a
|
|
398
|
+
// vault, so its existing on disk proves nothing. Treat it as absent: the
|
|
399
|
+
// disk tier refuses with the resolver's typed code + fix; the neon tier
|
|
400
|
+
// proceeds and every vault-scoped console call is refused per call
|
|
401
|
+
// (console-fetch.mjs) while vault-less calls still work.
|
|
402
|
+
const unbound = vaultSource === 'unbound';
|
|
403
|
+
if (!unbound && typeof vaultRoot === 'string' && vaultRoot && existsSync(vaultRoot)) return null;
|
|
395
404
|
let readTier = 'disk';
|
|
396
405
|
try {
|
|
397
406
|
readTier = resolveDevMode({ env }).readTier;
|
|
@@ -399,6 +408,14 @@ export function vaultRootGateVerdict(vaultRoot, { env = process.env } = {}) {
|
|
|
399
408
|
readTier = 'disk';
|
|
400
409
|
}
|
|
401
410
|
if (readTier === 'neon') return null;
|
|
411
|
+
if (unbound && vaultError && typeof vaultError.code === 'string') {
|
|
412
|
+
return {
|
|
413
|
+
error: vaultError.code,
|
|
414
|
+
message: vaultError.message,
|
|
415
|
+
...(vaultError.fix ? { fix: vaultError.fix } : {}),
|
|
416
|
+
...(vaultError.from ? { from: vaultError.from } : {}),
|
|
417
|
+
};
|
|
418
|
+
}
|
|
402
419
|
return { error: 'VAULT_NOT_FOUND', message: `Vault root not found: ${vaultRoot}` };
|
|
403
420
|
}
|
|
404
421
|
|
|
@@ -407,7 +424,7 @@ export function handleToolsCall(params) {
|
|
|
407
424
|
const config = getConfig();
|
|
408
425
|
const vaultRoot = config.vaultRoot;
|
|
409
426
|
|
|
410
|
-
const gate = vaultRootGateVerdict(vaultRoot);
|
|
427
|
+
const gate = vaultRootGateVerdict(vaultRoot, { vaultSource: config.vaultSource, vaultError: config.vaultError });
|
|
411
428
|
if (gate) {
|
|
412
429
|
return {
|
|
413
430
|
isError: true,
|
|
@@ -444,26 +461,8 @@ export function handleToolsCall(params) {
|
|
|
444
461
|
// PreToolUse hook: hooks fail open and `.heyspex/mode.json` can disarm them,
|
|
445
462
|
// whereas this runs on every call in every session. `refuse` returns instead
|
|
446
463
|
// of dispatching; `warn` lets the call through and rides along on the result.
|
|
447
|
-
const preflightVerdict =
|
|
448
|
-
|
|
449
|
-
name,
|
|
450
|
-
args,
|
|
451
|
-
detectAgentIdentity(args.agent, args.session_id).sessionId,
|
|
452
|
-
);
|
|
453
|
-
if (preflightVerdict && preflightVerdict.level === 'refuse') {
|
|
454
|
-
return {
|
|
455
|
-
isError: true,
|
|
456
|
-
content: [{
|
|
457
|
-
type: 'text',
|
|
458
|
-
text: JSON.stringify({
|
|
459
|
-
error: preflightVerdict.error,
|
|
460
|
-
message: preflightVerdict.message,
|
|
461
|
-
required_intents: preflightVerdict.intents,
|
|
462
|
-
tool: preflightVerdict.tool,
|
|
463
|
-
}, null, 2),
|
|
464
|
-
}],
|
|
465
|
-
};
|
|
466
|
-
}
|
|
464
|
+
const preflightVerdict = preflightVerdictFor(vaultRoot, name, args);
|
|
465
|
+
if (preflightVerdict && preflightVerdict.level === 'refuse') return preflightRefusal(preflightVerdict);
|
|
467
466
|
|
|
468
467
|
// Capture-obligation backstop ([[HS-20SW-DRSM-DIAG-heyspex-capture-on-idea-and-workflow]]): holds on surfaces where
|
|
469
468
|
// no hook loads. Fail open; break-glass HEYSPEX_OBLIGATION_BACKSTOP_OFF=1.
|
|
@@ -485,6 +484,43 @@ export function handleToolsCall(params) {
|
|
|
485
484
|
: finalized;
|
|
486
485
|
}
|
|
487
486
|
|
|
487
|
+
/** The preflight proof-of-use verdict for one call — the SAME session id the marker write uses. */
|
|
488
|
+
export function preflightVerdictFor(vaultRoot, name, args = {}) {
|
|
489
|
+
return evaluatePreflightGate(vaultRoot, name, args, detectAgentIdentity(args.agent, args.session_id).sessionId);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** The refusal result a `refuse`-level verdict returns instead of dispatching. */
|
|
493
|
+
export function preflightRefusal(verdict) {
|
|
494
|
+
return {
|
|
495
|
+
isError: true,
|
|
496
|
+
content: [{
|
|
497
|
+
type: 'text',
|
|
498
|
+
text: JSON.stringify({
|
|
499
|
+
error: verdict.error,
|
|
500
|
+
message: verdict.message,
|
|
501
|
+
required_intents: verdict.intents,
|
|
502
|
+
tool: verdict.tool,
|
|
503
|
+
}, null, 2),
|
|
504
|
+
}],
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* The in-process seat path (spexharness tool-catalog createMcpInvoker) calls this, not the bare
|
|
510
|
+
* dispatchHeySpexTool, so heyspex_claim_spec / heyspex_submit_for_review refuse with
|
|
511
|
+
* PREFLIGHT_NOT_RUN there exactly as over stdio (B4, 2026-10-14). The bare function stays
|
|
512
|
+
* ungated for the console's POST /api/mcp, whose serverless instances cannot share markers.
|
|
513
|
+
*/
|
|
514
|
+
export function dispatchHeySpexToolGated(name, args = {}, vaultRoot, config, { dispatch = dispatchHeySpexTool } = {}) {
|
|
515
|
+
const verdict = preflightVerdictFor(vaultRoot, name, args);
|
|
516
|
+
if (verdict && verdict.level === 'refuse') return preflightRefusal(verdict);
|
|
517
|
+
const result = dispatch(name, args, vaultRoot, config);
|
|
518
|
+
if (!verdict || verdict.level !== 'warn') return result;
|
|
519
|
+
return result && typeof result.then === 'function'
|
|
520
|
+
? result.then((r) => attachPreflightWarning(r, verdict))
|
|
521
|
+
: attachPreflightWarning(result, verdict);
|
|
522
|
+
}
|
|
523
|
+
|
|
488
524
|
/**
|
|
489
525
|
* Ride a warn-level preflight verdict along on an otherwise successful result.
|
|
490
526
|
* Best-effort by construction — a result whose text is not JSON is left exactly
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* usable-dir-env.mjs - is an env var that is supposed to name a directory
|
|
3
|
+
* actually usable as one? (HS-C0NW)
|
|
4
|
+
*
|
|
5
|
+
* Some hosts never expand `${CLAUDE_PROJECT_DIR}`-style placeholders in an MCP
|
|
6
|
+
* manifest's `env` block (GitHub Copilot CLI expands `${CLAUDE_PLUGIN_ROOT}` /
|
|
7
|
+
* `${PLUGIN_ROOT}` but not the project dir), so the server receives the
|
|
8
|
+
* literal text. Resolved relative to the plugin dir it became a path like
|
|
9
|
+
* `<pkg>/plugins/heyspex/${CLAUDE_PROJECT_DIR}`, which then got a `.heyspex/`
|
|
10
|
+
* build record mkdir'd into it. A value like that is "unset", not a path.
|
|
11
|
+
*
|
|
12
|
+
* Pure and dependency-free (node:fs / node:path only) so the plugin launcher
|
|
13
|
+
* (plugins/heyspex/bin/launch-mcp.mjs) and the vault resolver share one rule.
|
|
14
|
+
*/
|
|
15
|
+
import { statSync } from 'node:fs';
|
|
16
|
+
import { isAbsolute } from 'node:path';
|
|
17
|
+
|
|
18
|
+
/** Env vars a host may set to name the project / vault folder. */
|
|
19
|
+
export const DIR_ENV_KEYS = ['HEYSPEX_VAULT_ROOT', 'CLAUDE_PROJECT_DIR', 'CURSOR_PROJECT_DIR'];
|
|
20
|
+
|
|
21
|
+
/** `${VAR}`, `${VAR:-x}`, `$VAR`, `%VAR%` anywhere in the value. */
|
|
22
|
+
const PLACEHOLDER = /\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*|%[A-Za-z_][A-Za-z0-9_]*%/;
|
|
23
|
+
|
|
24
|
+
/** True when `value` still contains an unexpanded variable reference. */
|
|
25
|
+
export function hasUnexpandedPlaceholder(value) {
|
|
26
|
+
return typeof value === 'string' && PLACEHOLDER.test(value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isExistingDir(p) {
|
|
30
|
+
try {
|
|
31
|
+
return statSync(p).isDirectory();
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Why `value` is not a usable directory, or null when it is.
|
|
39
|
+
* Reasons: 'empty' | 'placeholder' | 'relative' | 'missing'.
|
|
40
|
+
* `requireExisting: false` skips the on-disk check (a registered vault root may
|
|
41
|
+
* legitimately have no local checkout).
|
|
42
|
+
*/
|
|
43
|
+
export function unusableDirReason(value, { requireExisting = true, isDir = isExistingDir } = {}) {
|
|
44
|
+
if (typeof value !== 'string' || !value.trim()) return 'empty';
|
|
45
|
+
const v = value.trim();
|
|
46
|
+
if (hasUnexpandedPlaceholder(v)) return 'placeholder';
|
|
47
|
+
if (!isAbsolute(v)) return 'relative';
|
|
48
|
+
if (requireExisting && !isDir(v)) return 'missing';
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Is `value` an existing absolute directory with no leftover placeholder? */
|
|
53
|
+
export function isUsableDirValue(value, opts) {
|
|
54
|
+
return unusableDirReason(value, opts) === null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Return a COPY of `env` with every unusable DIR_ENV_KEYS entry removed, so
|
|
59
|
+
* resolution falls through to the normal cwd walk. `onDrop(key, value, reason)`
|
|
60
|
+
* is called per removal. `requireExisting` may be a boolean or a per-key map.
|
|
61
|
+
*/
|
|
62
|
+
export function withoutUnusableDirEnv(env, { keys = DIR_ENV_KEYS, requireExisting = true, isDir, onDrop } = {}) {
|
|
63
|
+
const out = { ...env };
|
|
64
|
+
for (const key of keys) {
|
|
65
|
+
if (out[key] === undefined) continue;
|
|
66
|
+
const need = typeof requireExisting === 'object' ? requireExisting[key] !== false : requireExisting;
|
|
67
|
+
const reason = unusableDirReason(out[key], { requireExisting: need, ...(isDir ? { isDir } : {}) });
|
|
68
|
+
if (reason) {
|
|
69
|
+
onDrop?.(key, out[key], reason);
|
|
70
|
+
delete out[key];
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** In-place variant for `process.env` (the launcher hands its env to the server). */
|
|
77
|
+
export function scrubUnusableDirEnv(env = process.env, opts = {}) {
|
|
78
|
+
const dropped = [];
|
|
79
|
+
withoutUnusableDirEnv(env, { ...opts, onDrop: (k, v, r) => dropped.push({ key: k, value: v, reason: r }) });
|
|
80
|
+
for (const { key } of dropped) delete env[key];
|
|
81
|
+
return dropped;
|
|
82
|
+
}
|