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.
Files changed (146) hide show
  1. package/.claude-plugin/marketplace.json +14 -0
  2. package/bin/hs.mjs +181 -15
  3. package/dist/cli/hs-check.mjs +88 -46
  4. package/dist/cli/hs-claim.mjs +84 -39
  5. package/dist/cli/hs-connect.mjs +583 -129
  6. package/dist/cli/hs-defer.mjs +251 -30
  7. package/dist/cli/hs-dev.mjs +20 -8
  8. package/dist/cli/hs-env.mjs +62 -34
  9. package/dist/cli/hs-index-rebuild.mjs +288 -83
  10. package/dist/cli/hs-index-reconcile.mjs +397 -111
  11. package/dist/cli/hs-init.mjs +2143 -91
  12. package/dist/cli/hs-install.mjs +628 -106
  13. package/dist/cli/hs-join.mjs +686 -236
  14. package/dist/cli/hs-vault-pull.mjs +3189 -179
  15. package/dist/cli/hs-vault-push.mjs +6433 -0
  16. package/dist/cli/hs-vault.mjs +396 -178
  17. package/dist/cli/hs-workspace.mjs +289 -151
  18. package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -28
  19. package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -62
  20. package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -29
  21. package/dist/cli-templates/harness-commands/claude/hs-ground.md +30 -28
  22. package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -29
  23. package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -70
  24. package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -35
  25. package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -27
  26. package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -23
  27. package/dist/cli-templates/harness-commands/claude/hs-review.md +23 -23
  28. package/dist/cli-templates/harness-commands/claude/hs-view.md +28 -26
  29. package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -32
  30. package/dist/cli-templates/harness-commands/claude/prototype.md +91 -91
  31. package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +22 -22
  32. package/dist/cli-templates/harness-commands/cursor/hs-capture.md +56 -56
  33. package/dist/cli-templates/harness-commands/cursor/hs-defer.md +23 -23
  34. package/dist/cli-templates/harness-commands/cursor/hs-ground.md +24 -22
  35. package/dist/cli-templates/harness-commands/cursor/hs-next.md +23 -23
  36. package/dist/cli-templates/harness-commands/cursor/hs-promote.md +64 -64
  37. package/dist/cli-templates/harness-commands/cursor/hs-push.md +29 -29
  38. package/dist/cli-templates/harness-commands/cursor/hs-qc.md +21 -21
  39. package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +17 -17
  40. package/dist/cli-templates/harness-commands/cursor/hs-review.md +17 -17
  41. package/dist/cli-templates/harness-commands/cursor/hs-view.md +24 -22
  42. package/dist/cli-templates/harness-commands/cursor/kill-ports.md +26 -26
  43. package/dist/cli-templates/harness-commands/cursor/prototype.md +86 -86
  44. package/docs/copilot-plugin-install.md +177 -0
  45. package/docs/driving-heyspex-from-copilot.md +165 -0
  46. package/package.json +21 -2
  47. package/plugins/heyspex/.claude-plugin/plugin.json +12 -12
  48. package/plugins/heyspex/.cursor-plugin/plugin.json +15 -15
  49. package/plugins/heyspex/README.md +312 -0
  50. package/plugins/heyspex/bin/run-hook.mjs +20 -2
  51. package/plugins/heyspex/hooks/cursor-hooks.json +81 -0
  52. package/plugins/heyspex/hooks/hooks.json +94 -37
  53. package/scripts/claude-hooks/_intent-classifier-router.mjs +456 -410
  54. package/scripts/claude-hooks/_intent-classifier-semantic.mjs +5 -0
  55. package/scripts/claude-hooks/_run.mjs +316 -23
  56. package/scripts/claude-hooks/capture-execution-gate.mjs +51 -0
  57. package/scripts/claude-hooks/claim-liveness-shared.mjs +2 -0
  58. package/scripts/claude-hooks/diagnose-capture-reminder.mjs +11 -0
  59. package/scripts/claude-hooks/directive-obligation-gate.mjs +11 -0
  60. package/scripts/claude-hooks/inject-communication-style.mjs +20 -0
  61. package/scripts/claude-hooks/intent-classifier-trigger.mjs +279 -247
  62. package/scripts/claude-hooks/session-end.mjs +2 -0
  63. package/scripts/claude-hooks/vault-mirror-catchup.mjs +11 -0
  64. package/scripts/claude-hooks/vault-write-tier-guard.mjs +11 -0
  65. package/scripts/claude-hooks/wip-checkpoint.mjs +5 -1
  66. package/scripts/cursor-hooks/manifest.mjs +46 -0
  67. package/scripts/heyspex-mode.mjs +20 -0
  68. package/scripts/hs-mode.mjs +1 -1
  69. package/scripts/hs-view.mjs +180 -17
  70. package/scripts/install-claude-code-hooks.mjs +3 -1
  71. package/scripts/install-copilot-hooks.mjs +288 -0
  72. package/scripts/lib/console-url-resolver.mjs +3 -2
  73. package/scripts/lib/customer-kit.mjs +327 -10
  74. package/scripts/lib/dev-mode.mjs +7 -6
  75. package/scripts/lib/directive-obligations.mjs +349 -0
  76. package/scripts/lib/hook-dispositions.mjs +210 -0
  77. package/scripts/lib/hook-vault-header.mjs +30 -0
  78. package/scripts/lib/hs-source-branch.mjs +196 -0
  79. package/scripts/lib/hs-source-log.mjs +2 -1
  80. package/scripts/lib/hs-source-preflight.mjs +18 -4
  81. package/scripts/lib/hs-source-push.mjs +386 -356
  82. package/scripts/lib/window-placement.mjs +109 -0
  83. package/spexharness/src/adapters/claude-code.mjs +11 -3
  84. package/spexharness/src/adapters/copilot.mjs +236 -0
  85. package/spexharness/src/adapters/detect-harness.mjs +34 -0
  86. package/spexharness/src/gates/_shell-write-targets.mjs +77 -0
  87. package/spexharness/src/gates/deep-research-cost-gate.mjs +4 -3
  88. package/spexharness/src/gates/diagnose-capture-reminder.mjs +112 -0
  89. package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -0
  90. package/spexharness/src/gates/directive-obligation-gate.mjs +183 -0
  91. package/spexharness/src/gates/vault-mirror-catchup.mjs +124 -0
  92. package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +125 -0
  93. package/spexharness/src/gates/vault-write-tier-guard.mjs +42 -0
  94. package/spexharness/src/intent-classifier.mjs +4 -2
  95. package/spexharness/src/intent-directive.mjs +626 -541
  96. package/spexharness/src/rule-table.mjs +31 -1
  97. package/vendor/heyspex-mcp/account-switch.mjs +6 -5
  98. package/vendor/heyspex-mcp/capture-buffer-patches.mjs +31 -3
  99. package/vendor/heyspex-mcp/capture-console-client.mjs +37 -2
  100. package/vendor/heyspex-mcp/capture-focus.mjs +26 -2
  101. package/vendor/heyspex-mcp/capture-outbox.mjs +10 -4
  102. package/vendor/heyspex-mcp/capture-sources.mjs +195 -0
  103. package/vendor/heyspex-mcp/capture-tools.mjs +56 -11
  104. package/vendor/heyspex-mcp/claim-tools.mjs +25 -2
  105. package/vendor/heyspex-mcp/cli-verbs.mjs +19 -21
  106. package/vendor/heyspex-mcp/cli.mjs +7 -1
  107. package/vendor/heyspex-mcp/close-branch-report.mjs +102 -0
  108. package/vendor/heyspex-mcp/cloud-vault-writes.mjs +197 -7
  109. package/vendor/heyspex-mcp/console-fetch.mjs +192 -28
  110. package/vendor/heyspex-mcp/console-transport.mjs +7 -1
  111. package/vendor/heyspex-mcp/credentials.mjs +9 -8
  112. package/vendor/heyspex-mcp/dev-tools.mjs +7 -0
  113. package/vendor/heyspex-mcp/filing-tools.mjs +35 -0
  114. package/vendor/heyspex-mcp/fleet-queue-tools.mjs +2 -1
  115. package/vendor/heyspex-mcp/folder-trust-store.mjs +2 -1
  116. package/vendor/heyspex-mcp/http-transport.mjs +2 -0
  117. package/vendor/heyspex-mcp/list-tools.mjs +1 -1
  118. package/vendor/heyspex-mcp/mcp-config.mjs +14 -1
  119. package/vendor/heyspex-mcp/memory-tools.mjs +523 -523
  120. package/vendor/heyspex-mcp/obligation-backstop.mjs +103 -0
  121. package/vendor/heyspex-mcp/occupied-keys.mjs +1 -0
  122. package/vendor/heyspex-mcp/on-deck-brief.mjs +19 -2
  123. package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -1708
  124. package/vendor/heyspex-mcp/plan-push.mjs +3 -2
  125. package/vendor/heyspex-mcp/preflight-gate.mjs +1 -0
  126. package/vendor/heyspex-mcp/procedure-handlers.mjs +327 -325
  127. package/vendor/heyspex-mcp/procedure-tools.mjs +1181 -1159
  128. package/vendor/heyspex-mcp/promote-capture.mjs +2 -0
  129. package/vendor/heyspex-mcp/promote-research.mjs +6 -0
  130. package/vendor/heyspex-mcp/read-path.mjs +162 -6
  131. package/vendor/heyspex-mcp/request-context.mjs +25 -1
  132. package/vendor/heyspex-mcp/research-handlers.mjs +6 -4
  133. package/vendor/heyspex-mcp/roadmap-default.mjs +70 -0
  134. package/vendor/heyspex-mcp/session-owner.mjs +3 -2
  135. package/vendor/heyspex-mcp/session-tools.mjs +10 -0
  136. package/vendor/heyspex-mcp/spec-edit-tools.mjs +8 -1
  137. package/vendor/heyspex-mcp/split-research.mjs +3 -0
  138. package/vendor/heyspex-mcp/submit-for-review.mjs +18 -1
  139. package/vendor/heyspex-mcp/summary-write-tools.mjs +5 -1
  140. package/vendor/heyspex-mcp/test-helpers.mjs +18 -3
  141. package/vendor/heyspex-mcp/tool-catalog.mjs +7 -1
  142. package/vendor/heyspex-mcp/tool-dispatch.mjs +18 -0
  143. package/vendor/heyspex-mcp/vault-io.mjs +720 -724
  144. package/vendor/heyspex-mcp/vault-resolve.mjs +197 -9
  145. package/vendor/heyspex-mcp/whoami.mjs +2 -1
  146. 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 new Error(`GET /api/specs/${specId} returned ${getRes.status}: ${getRes.error || JSON.stringify(getRes.body ?? {})}`);
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 = 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 = homedir(), env = process.env } = {}) {
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 = homedir(), probe = probeCredentialIdentity, fallbackConsoleUrl } = {}) {
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 = 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 = 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 = homedir(), env = process.env, stdout = process.stdout, stderr = process.stderr } = deps;
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 = 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: 'heyspex.yaml' | 'config.yaml' } | null}
1842
+ * @returns {{ accountId: string, source: 'registry' | 'config.yaml' } | null}
1841
1843
  */
1842
- export function readRecordedRepoAccount({ vaultRoot, env = process.env, homeDir = homedir() }) {
1843
- let marker = null;
1844
- try {
1845
- marker = readFileSync(join(vaultRoot, 'heyspex.yaml'), 'utf8');
1846
- } catch {
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 === 'heyspex.yaml';
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 === 'heyspex.yaml'
1927
- ? ` repo: ${recorded.accountId} (heyspex.yaml) -- matches the signed-in account`
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 = 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': idempotencyKey ?? randomUUID() },
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(`/api/specs/${encodeURIComponent(specId)}`, {
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
- throw cloudWriteFailure(route, consoleUrl, String(err?.message ?? err), { spec_id: specId });
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
+ }