heyspex 0.2.0 → 0.2.1
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 +163 -11
- 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 +162 -0
- package/docs/driving-heyspex-from-copilot.md +165 -0
- package/package.json +20 -2
- package/plugins/heyspex/.claude-plugin/plugin.json +12 -12
- package/plugins/heyspex/.cursor-plugin/plugin.json +15 -15
- package/plugins/heyspex/README.md +309 -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/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
|
@@ -10,11 +10,14 @@ import { childKindFor, getChildRefs, upsertFrontmatterKeys } from './alignment-g
|
|
|
10
10
|
import { measureDiskLanding, resolveSpecIdForMutation } from './tool-helpers.mjs';
|
|
11
11
|
import { stampSkippedDespiteFindingsOnBreakdownMarker } from './plan-review.mjs';
|
|
12
12
|
import { resolveNextOnDeckPayload } from './on-deck-brief.mjs';
|
|
13
|
-
import { ConsoleUnreachableCloudModeError, readViaSelector, resolveReadTier } from './read-path.mjs';
|
|
13
|
+
import { consoleReadErrorFrom, ConsoleUnreachableCloudModeError, readViaSelector, resolveReadTier } from './read-path.mjs';
|
|
14
14
|
import { detectAgentIdentity, effectiveConsoleUrl } from './mcp-config.mjs';
|
|
15
15
|
import { consoleFetch, controlError, controlOk, enrichConsoleFailurePayloadAsync, postClaimEndpoint } from './console-transport.mjs';
|
|
16
16
|
import { consoleErrorCode, consoleErrorMessage } from './console-fetch.mjs';
|
|
17
17
|
import { executeSubmitForReview } from './submit-for-review.mjs';
|
|
18
|
+
import { deferControlWrite } from './write-outbox.mjs';
|
|
19
|
+
|
|
20
|
+
const isCloudReliant = async () => (await resolveReadTier()) === 'neon';
|
|
18
21
|
|
|
19
22
|
/**
|
|
20
23
|
* Minimum length for an alignment override reason. Matches the bar the milestone
|
|
@@ -265,6 +268,7 @@ export async function handleRecordAlignmentViaTier(vaultRoot, config, args) {
|
|
|
265
268
|
const consoleUrl = effectiveConsoleUrl();
|
|
266
269
|
try {
|
|
267
270
|
return await readViaSelector({
|
|
271
|
+
allowDiskFallback: false,
|
|
268
272
|
tier,
|
|
269
273
|
diskRead: () => handleRecordAlignment(vaultRoot, config, args),
|
|
270
274
|
neonRead: () => handleRecordAlignmentCloud(vaultRoot, config, args, { consoleUrl }),
|
|
@@ -332,7 +336,7 @@ export async function handleRecordAlignmentCloud(vaultRoot, config, args, { cons
|
|
|
332
336
|
|
|
333
337
|
const getRes = await consoleFetch(`/api/specs/${encodeURIComponent(specId)}`);
|
|
334
338
|
if (!getRes.ok) {
|
|
335
|
-
throw
|
|
339
|
+
throw consoleReadErrorFrom(getRes, `GET /api/specs/${specId} returned ${getRes.status}: ${getRes.error || JSON.stringify(getRes.body ?? {})}`, null);
|
|
336
340
|
}
|
|
337
341
|
const spec = getRes.body?.spec;
|
|
338
342
|
if (!spec) {
|
|
@@ -680,6 +684,16 @@ export async function handleReportProgress(args = {}) {
|
|
|
680
684
|
{ method: 'PATCH', body },
|
|
681
685
|
);
|
|
682
686
|
if (!result.ok) {
|
|
687
|
+
// Append-only progress is safe to queue on an outage (degrade, don't die).
|
|
688
|
+
const deferred = await deferControlWrite(result, {
|
|
689
|
+
route: `/api/specs/${encodeURIComponent(progressSpecId)}/claim/status`,
|
|
690
|
+
method: 'PATCH',
|
|
691
|
+
request_body: body,
|
|
692
|
+
spec_id: progressSpecId,
|
|
693
|
+
acting_agent: identity.agent,
|
|
694
|
+
session_id: identity.sessionId,
|
|
695
|
+
}, { isCloud: isCloudReliant });
|
|
696
|
+
if (deferred) return deferred;
|
|
683
697
|
const payload = {
|
|
684
698
|
error: consoleErrorCode(result, 'REPORT_PROGRESS_FAILED'),
|
|
685
699
|
message: consoleErrorMessage(result, `Claim status endpoint returned ${result.status}`),
|
|
@@ -919,6 +933,15 @@ export async function handleRecordWip(args = {}) {
|
|
|
919
933
|
{ method: 'POST', body },
|
|
920
934
|
);
|
|
921
935
|
if (!result.ok) {
|
|
936
|
+
const deferred = await deferControlWrite(result, {
|
|
937
|
+
route: `/api/specs/${encodeURIComponent(args.spec_id)}/wip`,
|
|
938
|
+
method: 'POST',
|
|
939
|
+
request_body: body,
|
|
940
|
+
spec_id: args.spec_id,
|
|
941
|
+
acting_agent: identity.agent,
|
|
942
|
+
session_id: identity.sessionId,
|
|
943
|
+
}, { isCloud: isCloudReliant });
|
|
944
|
+
if (deferred) return deferred;
|
|
922
945
|
const payload = {
|
|
923
946
|
error: consoleErrorCode(result, 'WIP_FAILED'),
|
|
924
947
|
message: consoleErrorMessage(result, `WIP endpoint returned ${result.status}`),
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
// Browser hand-off primitives for `hs login` (RFC 8252 loopback), epic
|
|
13
13
|
// [[HS-IFQM-IZQ7-EPIC-hs-login-browser-hand-off-for-cli-and]].
|
|
14
14
|
|
|
15
|
+
import { registryAccountIdForRoot } from './vault-resolve.mjs';
|
|
16
|
+
import { heyspexHome } from '../../scripts/lib/heyspex-home.mjs';
|
|
15
17
|
import { DEFAULT_LOGIN_TIMEOUT_MS, buildConnectUrl, describeCallbackFailure, describeRedeemFailure, generateLoginState, openBrowser as openBrowserDefault, startCallbackListener as startCallbackListenerDefault } from './login-handoff.mjs';
|
|
16
18
|
import { getConfig, getConsoleUrl } from './mcp-config.mjs';
|
|
17
19
|
import { homedir, hostname } from 'node:os';
|
|
@@ -489,7 +491,7 @@ export async function redeemAndStorePairingCode({
|
|
|
489
491
|
code,
|
|
490
492
|
label,
|
|
491
493
|
probe = probeCredentialIdentity,
|
|
492
|
-
homeDir =
|
|
494
|
+
homeDir = heyspexHome(),
|
|
493
495
|
requireAccount = null,
|
|
494
496
|
}) {
|
|
495
497
|
let response;
|
|
@@ -672,7 +674,7 @@ function currentStoredAccount(homeDir) {
|
|
|
672
674
|
}
|
|
673
675
|
|
|
674
676
|
/** Local side: the current account in credentials.yaml, its console, and where the bearer resolves from. */
|
|
675
|
-
export function readLoginLocalSide({ homeDir =
|
|
677
|
+
export function readLoginLocalSide({ homeDir = heyspexHome(), env = process.env } = {}) {
|
|
676
678
|
try {
|
|
677
679
|
const { id, entry } = currentStoredAccount(homeDir);
|
|
678
680
|
return {
|
|
@@ -692,7 +694,7 @@ export function readLoginLocalSide({ homeDir = homedir(), env = process.env } =
|
|
|
692
694
|
* account, at that account's console (else `fallbackConsoleUrl`). Never
|
|
693
695
|
* includes token material.
|
|
694
696
|
*/
|
|
695
|
-
export async function readLoginCloudSide({ homeDir =
|
|
697
|
+
export async function readLoginCloudSide({ homeDir = heyspexHome(), probe = probeCredentialIdentity, fallbackConsoleUrl } = {}) {
|
|
696
698
|
const { entry } = currentStoredAccount(homeDir);
|
|
697
699
|
if (!entry?.mcp_token) return unreadSide('no stored sign-in to ask the console about');
|
|
698
700
|
const identity = await probe({ consoleUrl: entry.console_url ?? fallbackConsoleUrl, token: entry.mcp_token });
|
|
@@ -1032,7 +1034,7 @@ export async function runLoginCli(argv, deps = {}) {
|
|
|
1032
1034
|
probe = probeCredentialIdentity,
|
|
1033
1035
|
stdout = process.stdout,
|
|
1034
1036
|
stderr = process.stderr,
|
|
1035
|
-
homeDir =
|
|
1037
|
+
homeDir = heyspexHome(),
|
|
1036
1038
|
resolveConsoleUrl = getConsoleUrl,
|
|
1037
1039
|
env = process.env,
|
|
1038
1040
|
} = deps;
|
|
@@ -1318,7 +1320,7 @@ export async function runWhoamiCli(argv, deps = {}) {
|
|
|
1318
1320
|
probe = probeCredentialIdentity,
|
|
1319
1321
|
readBearer: readBearerDep,
|
|
1320
1322
|
env = process.env,
|
|
1321
|
-
homeDir =
|
|
1323
|
+
homeDir = heyspexHome(),
|
|
1322
1324
|
stdout = process.stdout,
|
|
1323
1325
|
stderr = process.stderr,
|
|
1324
1326
|
resolveConsoleUrl = getConsoleUrl,
|
|
@@ -1468,7 +1470,7 @@ export function parseLogoutCliArgs(argv) {
|
|
|
1468
1470
|
|
|
1469
1471
|
/** `hs logout [--account <id|email>]` — remove one stored account (default: the current one). */
|
|
1470
1472
|
export async function runLogoutCli(argv, deps = {}) {
|
|
1471
|
-
const { homeDir =
|
|
1473
|
+
const { homeDir = heyspexHome(), env = process.env, stdout = process.stdout, stderr = process.stderr } = deps;
|
|
1472
1474
|
const parsed = parseLogoutCliArgs(argv);
|
|
1473
1475
|
if (parsed.help) {
|
|
1474
1476
|
stdout.write(`${LOGOUT_CLI_HELP}\n`);
|
|
@@ -1800,7 +1802,7 @@ export function mergeRemoteMcpJson(existingJson, cloudEntry) {
|
|
|
1800
1802
|
* ever risking the value landing in a committed file
|
|
1801
1803
|
* ([[HS-D1QU-B0IT-EPIC-cloud-mcp-alongside-the-satellite-pin]] AC4).
|
|
1802
1804
|
*/
|
|
1803
|
-
export function resolveInstallBearer({ env = process.env, homeDir =
|
|
1805
|
+
export function resolveInstallBearer({ env = process.env, homeDir = heyspexHome() } = {}) {
|
|
1804
1806
|
// The ONE shared reader (./credentials.mjs, HS-HL0D) — the same precedence
|
|
1805
1807
|
// the MCP runtime, `hs whoami` and the CLI gate use; only its SOURCE leaves
|
|
1806
1808
|
// this function. `credentials` keeps this verb's pinned --json vocabulary.
|
|
@@ -1837,17 +1839,13 @@ const MARKER_ACCOUNT_LINE_RE = /^account:[ \t]*["']?([^"'\r\n#]+)/m;
|
|
|
1837
1839
|
* account reader, ./session-owner.mjs), else `null` ("unrecorded"). Never
|
|
1838
1840
|
* throws.
|
|
1839
1841
|
* @param {{ vaultRoot: string, env?: object, homeDir?: string }} opts
|
|
1840
|
-
* @returns {{ accountId: string, source: '
|
|
1842
|
+
* @returns {{ accountId: string, source: 'registry' | 'config.yaml' } | null}
|
|
1841
1843
|
*/
|
|
1842
|
-
export function readRecordedRepoAccount({ vaultRoot, env = process.env, homeDir =
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
marker = null;
|
|
1848
|
-
}
|
|
1849
|
-
const fromMarker = typeof marker === 'string' ? marker.match(MARKER_ACCOUNT_LINE_RE)?.[1]?.trim() : '';
|
|
1850
|
-
if (fromMarker) return { accountId: fromMarker, source: 'heyspex.yaml' };
|
|
1844
|
+
export function readRecordedRepoAccount({ vaultRoot, env = process.env, homeDir = heyspexHome() }) {
|
|
1845
|
+
// The repo-to-account binding is machine-local (registry row), never the
|
|
1846
|
+
// committed marker; a legacy `account:` line in heyspex.yaml is ignored.
|
|
1847
|
+
const fromRegistry = registryAccountIdForRoot(vaultRoot, { registryDir: join(homeDir, '.heyspex') });
|
|
1848
|
+
if (fromRegistry) return { accountId: fromRegistry, source: 'registry' };
|
|
1851
1849
|
const configured = resolveSessionOwnerUncached({ env, homeDir });
|
|
1852
1850
|
return configured ? { accountId: configured, source: 'config.yaml' } : null;
|
|
1853
1851
|
}
|
|
@@ -1904,7 +1902,7 @@ export function decideRemoteInstallAccount({ probe, recorded, consoleUrl, tokenS
|
|
|
1904
1902
|
// install --remote` give ([[HS-VWUD-WW9R-TASK-record-the-connecting-account-in]],
|
|
1905
1903
|
// install-identity.ts). Compared only with this machine's configured
|
|
1906
1904
|
// account (no `account:` in the marker) it stays unconfirmed (17).
|
|
1907
|
-
const fromMarker = recorded.source === '
|
|
1905
|
+
const fromMarker = recorded.source === 'registry';
|
|
1908
1906
|
const repair = fromMarker ? `hs login --account ${recorded.accountId}` : 'hs login --switch-account';
|
|
1909
1907
|
return refuse(
|
|
1910
1908
|
fromMarker ? EXIT_ACCOUNT_MISMATCH : 17,
|
|
@@ -1923,8 +1921,8 @@ export function decideRemoteInstallAccount({ probe, recorded, consoleUrl, tokenS
|
|
|
1923
1921
|
}
|
|
1924
1922
|
const repoLine = !recorded
|
|
1925
1923
|
? ' repo: unrecorded (heyspex.yaml has no account:, no account_id in ~/.heyspex/config.yaml) -- using the signed-in account'
|
|
1926
|
-
: recorded.source === '
|
|
1927
|
-
? ` repo: ${recorded.accountId} (
|
|
1924
|
+
: recorded.source === 'registry'
|
|
1925
|
+
? ` repo: ${recorded.accountId} (registry) -- matches the signed-in account`
|
|
1928
1926
|
: ` repo: unrecorded in heyspex.yaml -- compared with this machine's configured account ${recorded.accountId} (~/.heyspex/config.yaml), which matches`;
|
|
1929
1927
|
return {
|
|
1930
1928
|
ok: true,
|
|
@@ -1948,7 +1946,7 @@ export function decideRemoteInstallAccount({ probe, recorded, consoleUrl, tokenS
|
|
|
1948
1946
|
export async function runMcpInstallCli(argv, deps = {}) {
|
|
1949
1947
|
const {
|
|
1950
1948
|
env = process.env,
|
|
1951
|
-
homeDir =
|
|
1949
|
+
homeDir = heyspexHome(),
|
|
1952
1950
|
stdout = process.stdout,
|
|
1953
1951
|
stderr = process.stderr,
|
|
1954
1952
|
vaultRoot: vaultRootOverride,
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
migrateLegacyIdeaFocus,
|
|
36
36
|
} from './capture-focus.mjs';
|
|
37
37
|
import {
|
|
38
|
+
announcePointerVaultOnce,
|
|
38
39
|
getAccountIdHeader,
|
|
39
40
|
getAuthHeader,
|
|
40
41
|
getVaultIdHeader,
|
|
@@ -382,7 +383,12 @@ if (!RUNNING_AS_CLI && !INFO_ONLY && process.env.HEYSPEX_DISABLE_STDIO_BOOTSTRAP
|
|
|
382
383
|
stampMcpBuildRecord();
|
|
383
384
|
|
|
384
385
|
try {
|
|
385
|
-
const { vaultRoot: bootVaultRoot } = getConfig();
|
|
386
|
+
const { vaultRoot: bootVaultRoot, vaultId: bootVaultId, vaultSource: bootVaultSource } = getConfig();
|
|
387
|
+
// A repo that names no vault resolves to the account-wide pointer; the
|
|
388
|
+
// transport refuses its console calls (console-fetch.mjs VAULT_NOT_PINNED)
|
|
389
|
+
// unless HEYSPEX_ALLOW_POINTER_VAULT=1 — in which case say which vault
|
|
390
|
+
// this process will act on, once, right here at start.
|
|
391
|
+
announcePointerVaultOnce({ vaultId: bootVaultId, repoRootAbs: bootVaultRoot, source: bootVaultSource });
|
|
386
392
|
if (existsSync(bootVaultRoot)) {
|
|
387
393
|
captureChannelWatcher = createCaptureChannelWatcher({
|
|
388
394
|
vaultRoot: bootVaultRoot,
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-close branch report
|
|
3
|
+
* ([[HS-PPX7-CWPX-TASK-session-close-names-the-branch-each]], epic
|
|
4
|
+
* [[HS-B8CY-CWPX-EPIC-one-session-one-reviewable-change]] AC5).
|
|
5
|
+
*
|
|
6
|
+
* At close-out, names the branch each half of the session landed on — the
|
|
7
|
+
* CODE half (the checked-out git branch of the session's code repo) and the
|
|
8
|
+
* SPEC half (branches the cloud drain committed this session's specs to, read
|
|
9
|
+
* from `GET /api/source/session-branch`) — and says plainly whether they are
|
|
10
|
+
* the same branch. Pure core (`buildCloseBranchReport`) + a thin gatherer
|
|
11
|
+
* (`gatherCloseBranchReport`) with injected `consoleFetch` / `runGit`.
|
|
12
|
+
*
|
|
13
|
+
* Honesty rule: a half that could not be read is reported UNKNOWN, and an
|
|
14
|
+
* unknown half never yields a "same branch" claim.
|
|
15
|
+
*/
|
|
16
|
+
import { spawnSync } from 'node:child_process';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The written rule for a drain commit that landed on a different branch than
|
|
20
|
+
* the code (mid-session drain while the working tree is on another branch).
|
|
21
|
+
* Mirrored verbatim into the live-proof runbook.
|
|
22
|
+
*/
|
|
23
|
+
export const MID_SESSION_DRAIN_RULE =
|
|
24
|
+
'The cloud drain commits spec files on its own branch without touching your working tree. ' +
|
|
25
|
+
'To join the halves: `git fetch origin <spec-branch>`, then on your code branch run ' +
|
|
26
|
+
'`git merge --no-ff origin/<spec-branch>` (or `git cherry-pick <commit>` for one commit). ' +
|
|
27
|
+
'Never rebase-merge; a dirty working tree is fine because the merge only adds spec files. ' +
|
|
28
|
+
'Next time run `hs source branch declare` before cloud writes so both halves land on one branch.';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {{ codeBranch: string | null, specBranches: string[] | null, declared?: string | null }} input
|
|
32
|
+
* codeBranch / specBranches are null when that half could not be read.
|
|
33
|
+
* @returns {{ status: 'same'|'differ'|'none'|'unknown', matches: boolean, lines: string[] }}
|
|
34
|
+
*/
|
|
35
|
+
export function buildCloseBranchReport({ codeBranch, specBranches, declared = null }) {
|
|
36
|
+
const lines = [];
|
|
37
|
+
lines.push(`Code branch: ${codeBranch ?? 'unknown (no checked-out branch / git unavailable)'}`);
|
|
38
|
+
if (specBranches === null) {
|
|
39
|
+
lines.push('Spec branch(es): unknown (console unreachable)');
|
|
40
|
+
} else if (specBranches.length === 0) {
|
|
41
|
+
lines.push('Spec branch(es): none — no cloud spec commits this session');
|
|
42
|
+
} else {
|
|
43
|
+
lines.push(`Spec branch(es): ${specBranches.join(', ')}`);
|
|
44
|
+
}
|
|
45
|
+
if (declared) lines.push(`Declared session branch: ${declared}`);
|
|
46
|
+
|
|
47
|
+
if (codeBranch === null || specBranches === null) {
|
|
48
|
+
const which = [codeBranch === null ? 'code' : null, specBranches === null ? 'spec' : null]
|
|
49
|
+
.filter(Boolean)
|
|
50
|
+
.join(' and ');
|
|
51
|
+
lines.push(`Match: unknown — the ${which} half could not be read.`);
|
|
52
|
+
return { status: 'unknown', matches: false, lines };
|
|
53
|
+
}
|
|
54
|
+
if (specBranches.length === 0) {
|
|
55
|
+
lines.push('Match: nothing to compare — the cloud drain made no spec commits for this session.');
|
|
56
|
+
return { status: 'none', matches: false, lines };
|
|
57
|
+
}
|
|
58
|
+
const differing = specBranches.filter((b) => b !== codeBranch);
|
|
59
|
+
if (differing.length === 0) {
|
|
60
|
+
lines.push('Match: yes — same branch, the code and the spec files can be reviewed together.');
|
|
61
|
+
return { status: 'same', matches: true, lines };
|
|
62
|
+
}
|
|
63
|
+
lines.push(`Match: NO — spec commits are on ${differing.join(', ')}, code is on ${codeBranch}.`);
|
|
64
|
+
lines.push(MID_SESSION_DRAIN_RULE);
|
|
65
|
+
return { status: 'differ', matches: false, lines };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function defaultRunGit(args, cwd) {
|
|
69
|
+
return spawnSync('git', args, { cwd, encoding: 'utf8' });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Current checked-out branch of `cwd`, or null. */
|
|
73
|
+
export function readCodeBranch({ cwd = process.cwd(), runGit = defaultRunGit } = {}) {
|
|
74
|
+
try {
|
|
75
|
+
const r = runGit(['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
|
|
76
|
+
const out = String(r?.stdout ?? '').trim();
|
|
77
|
+
if (r?.status !== 0 || !out || out === 'HEAD') return null;
|
|
78
|
+
return out;
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Gather both halves and build the report. Never throws.
|
|
86
|
+
* `consoleFetch(path)` is the MCP's console client (`{ ok, body }`).
|
|
87
|
+
*/
|
|
88
|
+
export async function gatherCloseBranchReport({ consoleFetch, cwd = process.cwd(), runGit } = {}) {
|
|
89
|
+
const codeBranch = readCodeBranch({ cwd, runGit });
|
|
90
|
+
let specBranches = null;
|
|
91
|
+
let declared = null;
|
|
92
|
+
try {
|
|
93
|
+
const res = await consoleFetch('/api/source/session-branch');
|
|
94
|
+
if (res?.ok && Array.isArray(res.body?.drainCommits)) {
|
|
95
|
+
specBranches = [...new Set(res.body.drainCommits.map((c) => c.branch).filter(Boolean))];
|
|
96
|
+
declared = res.body.declaration?.branch ?? null;
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
specBranches = null;
|
|
100
|
+
}
|
|
101
|
+
return buildCloseBranchReport({ codeBranch, specBranches, declared });
|
|
102
|
+
}
|
|
@@ -39,6 +39,16 @@
|
|
|
39
39
|
import { randomUUID } from 'node:crypto';
|
|
40
40
|
import { walkSpecListPages } from './list-tools.mjs';
|
|
41
41
|
import { parseFrontmatter } from './vault-io.mjs';
|
|
42
|
+
import {
|
|
43
|
+
buildDeferredPayload,
|
|
44
|
+
drainAndSummarize,
|
|
45
|
+
getWriteOutboxRoot,
|
|
46
|
+
isOutageClassResult,
|
|
47
|
+
stashIfOutage,
|
|
48
|
+
stashWrite,
|
|
49
|
+
failureOf,
|
|
50
|
+
DEFERRED_RECEIPT,
|
|
51
|
+
} from './write-outbox.mjs';
|
|
42
52
|
|
|
43
53
|
/**
|
|
44
54
|
* Raised when a `cloud`-mode write fails. Mirrors
|
|
@@ -53,6 +63,50 @@ export class CloudWriteUnreachableError extends Error {
|
|
|
53
63
|
}
|
|
54
64
|
}
|
|
55
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Raised INSTEAD of a plain {@link CloudWriteUnreachableError} when the write hit
|
|
68
|
+
* an outage (status 0 / timeout / 5xx) and was queued to the write outbox
|
|
69
|
+
* ([[heyspex-is-online-only-no-offline]] 2026-08-20 amendment). It extends the
|
|
70
|
+
* unreachable error so every existing `catch` still works, but it carries
|
|
71
|
+
* `deferred: true` + the truthful receipt: the write did NOT land yet, and it is
|
|
72
|
+
* NOT lost. Callers that can return a tool response render
|
|
73
|
+
* {@link deferredPayloadFromError}; callers that need the new spec's id/basename
|
|
74
|
+
* (promotion chains) cannot continue and surface it as before.
|
|
75
|
+
*/
|
|
76
|
+
export class CloudWriteDeferredError extends CloudWriteUnreachableError {
|
|
77
|
+
constructor(message, details = {}) {
|
|
78
|
+
super(message, details);
|
|
79
|
+
this.deferred = true;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The `{ status: 'deferred', … }` tool payload for a deferred error, or null. */
|
|
84
|
+
export function deferredPayloadFromError(err) {
|
|
85
|
+
if (!err?.deferred || !err.outbox_id) return null;
|
|
86
|
+
return {
|
|
87
|
+
status: 'deferred',
|
|
88
|
+
outbox_id: err.outbox_id,
|
|
89
|
+
pending_sync: err.pending_sync ?? 1,
|
|
90
|
+
receipt: err.receipt ?? DEFERRED_RECEIPT,
|
|
91
|
+
...(err.completed_steps ? { completed_steps: err.completed_steps } : {}),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Details carrying the replayable request for a no-stash caller (commitWritesViaConsole). */
|
|
96
|
+
function outageRequest(noStash, result, entry) {
|
|
97
|
+
return noStash && isOutageClassResult(result)
|
|
98
|
+
? { outage_request: { ...entry, failure: failureOf(result) } }
|
|
99
|
+
: {};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function deferredError(routeLabel, consoleUrl, stashed, vaultRoot, details = {}) {
|
|
103
|
+
const payload = buildDeferredPayload(stashed, vaultRoot);
|
|
104
|
+
return new CloudWriteDeferredError(
|
|
105
|
+
`CONSOLE_UNREACHABLE_CLOUD_MODE: route "${routeLabel}" failed against console ${consoleUrl ?? '<unknown console>'} — ${DEFERRED_RECEIPT} (outbox_id ${payload.outbox_id})`,
|
|
106
|
+
{ route: routeLabel, console_url: consoleUrl ?? null, ...details, outbox_id: payload.outbox_id, pending_sync: payload.pending_sync, receipt: payload.receipt },
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
56
110
|
/** Build the canonical failure error for a route + console + detail. */
|
|
57
111
|
export function cloudWriteFailure(routeLabel, consoleUrl, detail, details = {}) {
|
|
58
112
|
return new CloudWriteUnreachableError(
|
|
@@ -155,7 +209,10 @@ export async function createSpecViaConsole({
|
|
|
155
209
|
actingAgent = null,
|
|
156
210
|
idempotencyKey = null,
|
|
157
211
|
routeLabel = 'POST /api/specs',
|
|
212
|
+
vaultRoot = null,
|
|
213
|
+
noStash = false,
|
|
158
214
|
}) {
|
|
215
|
+
const outboxRoot = noStash ? null : vaultRoot ?? getWriteOutboxRoot();
|
|
159
216
|
if (typeof consoleFetch !== 'function') {
|
|
160
217
|
throw cloudWriteFailure(routeLabel, consoleUrl, 'no consoleFetch dependency was injected');
|
|
161
218
|
}
|
|
@@ -186,24 +243,55 @@ export async function createSpecViaConsole({
|
|
|
186
243
|
...(actingAgent ? { acting_agent: actingAgent } : {}),
|
|
187
244
|
};
|
|
188
245
|
|
|
246
|
+
// One key per LOGICAL call: the stash below stores THIS key so a replay is the
|
|
247
|
+
// same create, never a second mint ([[HS-2FQY]]).
|
|
248
|
+
const createKey = idempotencyKey ?? randomUUID();
|
|
249
|
+
const createEntry = {
|
|
250
|
+
op: 'create',
|
|
251
|
+
kind: 'create',
|
|
252
|
+
route: '/api/specs',
|
|
253
|
+
method: 'POST',
|
|
254
|
+
request_body: requestBody,
|
|
255
|
+
idempotency_key: createKey,
|
|
256
|
+
acting_agent: actingAgent,
|
|
257
|
+
};
|
|
258
|
+
if (outboxRoot) await drainAndSummarize(outboxRoot, consoleFetch);
|
|
189
259
|
let result;
|
|
190
260
|
try {
|
|
191
261
|
result = await consoleFetch('/api/specs', {
|
|
192
262
|
method: 'POST',
|
|
193
263
|
body: requestBody,
|
|
194
|
-
headers: { 'idempotency-key':
|
|
264
|
+
headers: { 'idempotency-key': createKey },
|
|
195
265
|
retryOnTimeout: false,
|
|
196
266
|
});
|
|
197
267
|
} catch (err) {
|
|
268
|
+
if (outboxRoot) {
|
|
269
|
+
const stashed = stashWrite(outboxRoot, {
|
|
270
|
+
...createEntry,
|
|
271
|
+
failure: { status: 0, errorKind: 'network', message: String(err?.message ?? err).slice(0, 500) },
|
|
272
|
+
});
|
|
273
|
+
if (stashed) throw deferredError(routeLabel, consoleUrl, stashed, outboxRoot, { attempted: { type: requestBody.type, name: requestBody.name } });
|
|
274
|
+
}
|
|
198
275
|
throw cloudWriteFailure(routeLabel, consoleUrl, String(err?.message ?? err), {
|
|
199
276
|
attempted: { type: requestBody.type, name: requestBody.name },
|
|
277
|
+
...outageRequest(noStash, { ok: false, status: 0, errorKind: 'network', error: String(err?.message ?? err) }, createEntry),
|
|
200
278
|
});
|
|
201
279
|
}
|
|
202
280
|
if (!result?.ok) {
|
|
281
|
+
const stashed = outboxRoot
|
|
282
|
+
? stashIfOutage(result, createEntry, outboxRoot)
|
|
283
|
+
: null;
|
|
284
|
+
if (stashed) {
|
|
285
|
+
throw deferredError(routeLabel, result?.url ?? consoleUrl, stashed, outboxRoot, {
|
|
286
|
+
status: result?.status ?? null,
|
|
287
|
+
attempted: { type: requestBody.type, name: requestBody.name },
|
|
288
|
+
});
|
|
289
|
+
}
|
|
203
290
|
throw cloudWriteFailure(routeLabel, result?.url ?? consoleUrl, describeResult(result), {
|
|
204
291
|
status: result?.status ?? null,
|
|
205
292
|
console_response: result?.body ?? null,
|
|
206
293
|
attempted: { type: requestBody.type, name: requestBody.name },
|
|
294
|
+
...outageRequest(noStash, result, createEntry),
|
|
207
295
|
...(result?.errorKind === 'timeout'
|
|
208
296
|
? {
|
|
209
297
|
hint:
|
|
@@ -250,8 +338,11 @@ export async function updateSpecViaConsole({
|
|
|
250
338
|
frontmatter,
|
|
251
339
|
body,
|
|
252
340
|
routeLabel = null,
|
|
341
|
+
vaultRoot = null,
|
|
342
|
+
noStash = false,
|
|
253
343
|
}) {
|
|
254
344
|
const route = routeLabel ?? `PUT /api/specs/${specId}`;
|
|
345
|
+
const outboxRoot = noStash ? null : vaultRoot ?? getWriteOutboxRoot();
|
|
255
346
|
if (typeof consoleFetch !== 'function') {
|
|
256
347
|
throw cloudWriteFailure(route, consoleUrl, 'no consoleFetch dependency was injected');
|
|
257
348
|
}
|
|
@@ -261,20 +352,40 @@ export async function updateSpecViaConsole({
|
|
|
261
352
|
if (typeof frontmatter !== 'object' || frontmatter === null) {
|
|
262
353
|
throw cloudWriteFailure(route, consoleUrl, 'PUT /api/specs/:id requires a frontmatter object');
|
|
263
354
|
}
|
|
355
|
+
const putPath = `/api/specs/${encodeURIComponent(specId)}`;
|
|
356
|
+
const putBody = { frontmatter, body: typeof body === 'string' ? body : '' };
|
|
357
|
+
const updateEntry = {
|
|
358
|
+
op: 'update',
|
|
359
|
+
kind: 'update',
|
|
360
|
+
route: putPath,
|
|
361
|
+
method: 'PUT',
|
|
362
|
+
request_body: putBody,
|
|
363
|
+
spec_id: specId,
|
|
364
|
+
// The cloud copy this edit was based on; replay refuses (cloud_newer)
|
|
365
|
+
// when the cloud has moved on since.
|
|
366
|
+
pre_image_updated: frontmatter?.updated ?? null,
|
|
367
|
+
};
|
|
368
|
+
const stashUpdate = (result) => (outboxRoot ? stashIfOutage(result, updateEntry, outboxRoot) : null);
|
|
369
|
+
if (outboxRoot) await drainAndSummarize(outboxRoot, consoleFetch);
|
|
264
370
|
let result;
|
|
265
371
|
try {
|
|
266
|
-
result = await consoleFetch(
|
|
267
|
-
method: 'PUT',
|
|
268
|
-
body: { frontmatter, body: typeof body === 'string' ? body : '' },
|
|
269
|
-
});
|
|
372
|
+
result = await consoleFetch(putPath, { method: 'PUT', body: putBody });
|
|
270
373
|
} catch (err) {
|
|
271
|
-
|
|
374
|
+
const stashed = stashUpdate({ ok: false, status: 0, errorKind: 'network', error: String(err?.message ?? err) });
|
|
375
|
+
if (stashed) throw deferredError(route, consoleUrl, stashed, outboxRoot, { spec_id: specId });
|
|
376
|
+
throw cloudWriteFailure(route, consoleUrl, String(err?.message ?? err), {
|
|
377
|
+
spec_id: specId,
|
|
378
|
+
...outageRequest(noStash, { ok: false, status: 0, errorKind: 'network', error: String(err?.message ?? err) }, updateEntry),
|
|
379
|
+
});
|
|
272
380
|
}
|
|
273
381
|
if (!result?.ok) {
|
|
382
|
+
const stashed = stashUpdate(result);
|
|
383
|
+
if (stashed) throw deferredError(route, result?.url ?? consoleUrl, stashed, outboxRoot, { status: result?.status ?? null, spec_id: specId });
|
|
274
384
|
throw cloudWriteFailure(route, result?.url ?? consoleUrl, describeResult(result), {
|
|
275
385
|
status: result?.status ?? null,
|
|
276
386
|
console_response: result?.body ?? null,
|
|
277
387
|
spec_id: specId,
|
|
388
|
+
...outageRequest(noStash, result, updateEntry),
|
|
278
389
|
...(result?.status === 404 ? { notFound: true } : {}),
|
|
279
390
|
});
|
|
280
391
|
}
|
|
@@ -350,8 +461,10 @@ export async function listSpecsViaConsole({
|
|
|
350
461
|
* `{ ok: false, error, completed }` — the same `{ ok }`-shaped verdict
|
|
351
462
|
* `commitWrites` returns, so callers branch identically.
|
|
352
463
|
*/
|
|
353
|
-
export async function commitWritesViaConsole(writes, { consoleFetch, consoleUrl = null, actingAgent = null } = {}) {
|
|
464
|
+
export async function commitWritesViaConsole(writes, { consoleFetch, consoleUrl = null, actingAgent = null, vaultRoot = null } = {}) {
|
|
354
465
|
const results = [];
|
|
466
|
+
const outboxRoot = vaultRoot ?? getWriteOutboxRoot();
|
|
467
|
+
if (outboxRoot && typeof consoleFetch === 'function') await drainAndSummarize(outboxRoot, consoleFetch);
|
|
355
468
|
for (let i = 0; i < (writes ?? []).length; i++) {
|
|
356
469
|
const w = writes[i];
|
|
357
470
|
const { frontmatter, body } = splitRawSpec(w.content);
|
|
@@ -370,6 +483,7 @@ export async function commitWritesViaConsole(writes, { consoleFetch, consoleUrl
|
|
|
370
483
|
frontmatter,
|
|
371
484
|
body,
|
|
372
485
|
actingAgent: meta.actingAgent ?? actingAgent,
|
|
486
|
+
noStash: true,
|
|
373
487
|
});
|
|
374
488
|
results.push({
|
|
375
489
|
kind: 'create',
|
|
@@ -387,6 +501,7 @@ export async function commitWritesViaConsole(writes, { consoleFetch, consoleUrl
|
|
|
387
501
|
specId,
|
|
388
502
|
frontmatter,
|
|
389
503
|
body,
|
|
504
|
+
noStash: true,
|
|
390
505
|
});
|
|
391
506
|
results.push({
|
|
392
507
|
kind: 'update',
|
|
@@ -405,8 +520,83 @@ export async function commitWritesViaConsole(writes, { consoleFetch, consoleUrl
|
|
|
405
520
|
} catch (err) {
|
|
406
521
|
err.failed_step = { index: i, kind: w.kind, abs_path: w.absPath ?? null };
|
|
407
522
|
err.completed_steps = results;
|
|
523
|
+
// Outage on step i: queue steps i..n-1 as ONE ordered batch (each step keeps
|
|
524
|
+
// its own idempotency key) so the remainder replays in order.
|
|
525
|
+
const failedReq = err.outage_request;
|
|
526
|
+
if (outboxRoot && failedReq) {
|
|
527
|
+
const steps = [failedReq];
|
|
528
|
+
for (let j = i + 1; j < writes.length; j++) {
|
|
529
|
+
const planned = planBatchStep(writes[j], actingAgent);
|
|
530
|
+
if (!planned) break;
|
|
531
|
+
steps.push(planned);
|
|
532
|
+
}
|
|
533
|
+
if (steps.length === writes.length - i) {
|
|
534
|
+
const stashed = stashWrite(outboxRoot, {
|
|
535
|
+
op: 'batch',
|
|
536
|
+
route: failedReq.route,
|
|
537
|
+
method: failedReq.method,
|
|
538
|
+
request_body: null,
|
|
539
|
+
acting_agent: actingAgent,
|
|
540
|
+
failure: failedReq.failure,
|
|
541
|
+
steps,
|
|
542
|
+
completed_steps: results,
|
|
543
|
+
});
|
|
544
|
+
if (stashed) {
|
|
545
|
+
const deferred = deferredError('commitWritesViaConsole', consoleUrl, stashed, outboxRoot, {
|
|
546
|
+
status: err.status ?? null,
|
|
547
|
+
failed_step: err.failed_step,
|
|
548
|
+
completed_steps: results,
|
|
549
|
+
});
|
|
550
|
+
return { ok: false, error: deferred, completed: results, deferred: true };
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
408
554
|
return { ok: false, error: err, completed: results };
|
|
409
555
|
}
|
|
410
556
|
}
|
|
411
557
|
return { ok: true, results };
|
|
412
558
|
}
|
|
559
|
+
|
|
560
|
+
/** Build a replayable batch step for a write that was never attempted. */
|
|
561
|
+
function planBatchStep(w, actingAgent) {
|
|
562
|
+
const { frontmatter, body } = splitRawSpec(w.content);
|
|
563
|
+
const meta = w.cloud ?? {};
|
|
564
|
+
if (w.kind === 'create') {
|
|
565
|
+
const type = meta.type ?? frontmatter.type ?? null;
|
|
566
|
+
const name = meta.name ?? frontmatter.name ?? null;
|
|
567
|
+
if (!type || !name) return null;
|
|
568
|
+
const cleaned = stripServerMintedFields(frontmatter);
|
|
569
|
+
delete cleaned.name;
|
|
570
|
+
delete cleaned.type;
|
|
571
|
+
if (isDefaultInitialStatus(cleaned.status)) delete cleaned.status;
|
|
572
|
+
const summary = meta.summary ?? frontmatter.summary ?? null;
|
|
573
|
+
const agent = meta.actingAgent ?? actingAgent;
|
|
574
|
+
return {
|
|
575
|
+
kind: 'create',
|
|
576
|
+
route: '/api/specs',
|
|
577
|
+
method: 'POST',
|
|
578
|
+
request_body: {
|
|
579
|
+
type: String(type).trim(),
|
|
580
|
+
name: String(name).trim(),
|
|
581
|
+
...(summary ? { summary } : {}),
|
|
582
|
+
...(Object.keys(cleaned).length > 0 ? { frontmatter: cleaned } : {}),
|
|
583
|
+
body,
|
|
584
|
+
...(agent ? { acting_agent: agent } : {}),
|
|
585
|
+
},
|
|
586
|
+
idempotency_key: randomUUID(),
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
if (w.kind === 'update') {
|
|
590
|
+
const specId = meta.specId ?? frontmatter.id ?? (w.absPath ? basenameFromRelPath(w.absPath) : null);
|
|
591
|
+
if (!specId) return null;
|
|
592
|
+
return {
|
|
593
|
+
kind: 'update',
|
|
594
|
+
route: `/api/specs/${encodeURIComponent(specId)}`,
|
|
595
|
+
method: 'PUT',
|
|
596
|
+
request_body: { frontmatter, body },
|
|
597
|
+
spec_id: specId,
|
|
598
|
+
pre_image_updated: frontmatter?.updated ?? null,
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
return null;
|
|
602
|
+
}
|