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.
Files changed (145) hide show
  1. package/.claude-plugin/marketplace.json +14 -0
  2. package/bin/hs.mjs +163 -11
  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 +162 -0
  45. package/docs/driving-heyspex-from-copilot.md +165 -0
  46. package/package.json +20 -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 +309 -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/lib/console-url-resolver.mjs +3 -2
  72. package/scripts/lib/customer-kit.mjs +327 -10
  73. package/scripts/lib/dev-mode.mjs +7 -6
  74. package/scripts/lib/directive-obligations.mjs +349 -0
  75. package/scripts/lib/hook-dispositions.mjs +210 -0
  76. package/scripts/lib/hook-vault-header.mjs +30 -0
  77. package/scripts/lib/hs-source-branch.mjs +196 -0
  78. package/scripts/lib/hs-source-log.mjs +2 -1
  79. package/scripts/lib/hs-source-preflight.mjs +18 -4
  80. package/scripts/lib/hs-source-push.mjs +386 -356
  81. package/scripts/lib/window-placement.mjs +109 -0
  82. package/spexharness/src/adapters/claude-code.mjs +11 -3
  83. package/spexharness/src/adapters/copilot.mjs +236 -0
  84. package/spexharness/src/adapters/detect-harness.mjs +34 -0
  85. package/spexharness/src/gates/_shell-write-targets.mjs +77 -0
  86. package/spexharness/src/gates/deep-research-cost-gate.mjs +4 -3
  87. package/spexharness/src/gates/diagnose-capture-reminder.mjs +112 -0
  88. package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -0
  89. package/spexharness/src/gates/directive-obligation-gate.mjs +183 -0
  90. package/spexharness/src/gates/vault-mirror-catchup.mjs +124 -0
  91. package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +125 -0
  92. package/spexharness/src/gates/vault-write-tier-guard.mjs +42 -0
  93. package/spexharness/src/intent-classifier.mjs +4 -2
  94. package/spexharness/src/intent-directive.mjs +626 -541
  95. package/spexharness/src/rule-table.mjs +31 -1
  96. package/vendor/heyspex-mcp/account-switch.mjs +6 -5
  97. package/vendor/heyspex-mcp/capture-buffer-patches.mjs +31 -3
  98. package/vendor/heyspex-mcp/capture-console-client.mjs +37 -2
  99. package/vendor/heyspex-mcp/capture-focus.mjs +26 -2
  100. package/vendor/heyspex-mcp/capture-outbox.mjs +10 -4
  101. package/vendor/heyspex-mcp/capture-sources.mjs +195 -0
  102. package/vendor/heyspex-mcp/capture-tools.mjs +56 -11
  103. package/vendor/heyspex-mcp/claim-tools.mjs +25 -2
  104. package/vendor/heyspex-mcp/cli-verbs.mjs +19 -21
  105. package/vendor/heyspex-mcp/cli.mjs +7 -1
  106. package/vendor/heyspex-mcp/close-branch-report.mjs +102 -0
  107. package/vendor/heyspex-mcp/cloud-vault-writes.mjs +197 -7
  108. package/vendor/heyspex-mcp/console-fetch.mjs +192 -28
  109. package/vendor/heyspex-mcp/console-transport.mjs +7 -1
  110. package/vendor/heyspex-mcp/credentials.mjs +9 -8
  111. package/vendor/heyspex-mcp/dev-tools.mjs +7 -0
  112. package/vendor/heyspex-mcp/filing-tools.mjs +35 -0
  113. package/vendor/heyspex-mcp/fleet-queue-tools.mjs +2 -1
  114. package/vendor/heyspex-mcp/folder-trust-store.mjs +2 -1
  115. package/vendor/heyspex-mcp/http-transport.mjs +2 -0
  116. package/vendor/heyspex-mcp/list-tools.mjs +1 -1
  117. package/vendor/heyspex-mcp/mcp-config.mjs +14 -1
  118. package/vendor/heyspex-mcp/memory-tools.mjs +523 -523
  119. package/vendor/heyspex-mcp/obligation-backstop.mjs +103 -0
  120. package/vendor/heyspex-mcp/occupied-keys.mjs +1 -0
  121. package/vendor/heyspex-mcp/on-deck-brief.mjs +19 -2
  122. package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -1708
  123. package/vendor/heyspex-mcp/plan-push.mjs +3 -2
  124. package/vendor/heyspex-mcp/preflight-gate.mjs +1 -0
  125. package/vendor/heyspex-mcp/procedure-handlers.mjs +327 -325
  126. package/vendor/heyspex-mcp/procedure-tools.mjs +1181 -1159
  127. package/vendor/heyspex-mcp/promote-capture.mjs +2 -0
  128. package/vendor/heyspex-mcp/promote-research.mjs +6 -0
  129. package/vendor/heyspex-mcp/read-path.mjs +162 -6
  130. package/vendor/heyspex-mcp/request-context.mjs +25 -1
  131. package/vendor/heyspex-mcp/research-handlers.mjs +6 -4
  132. package/vendor/heyspex-mcp/roadmap-default.mjs +70 -0
  133. package/vendor/heyspex-mcp/session-owner.mjs +3 -2
  134. package/vendor/heyspex-mcp/session-tools.mjs +10 -0
  135. package/vendor/heyspex-mcp/spec-edit-tools.mjs +8 -1
  136. package/vendor/heyspex-mcp/split-research.mjs +3 -0
  137. package/vendor/heyspex-mcp/submit-for-review.mjs +18 -1
  138. package/vendor/heyspex-mcp/summary-write-tools.mjs +5 -1
  139. package/vendor/heyspex-mcp/test-helpers.mjs +18 -3
  140. package/vendor/heyspex-mcp/tool-catalog.mjs +7 -1
  141. package/vendor/heyspex-mcp/tool-dispatch.mjs +18 -0
  142. package/vendor/heyspex-mcp/vault-io.mjs +720 -724
  143. package/vendor/heyspex-mcp/vault-resolve.mjs +197 -9
  144. package/vendor/heyspex-mcp/whoami.mjs +2 -1
  145. package/vendor/heyspex-mcp/write-outbox.mjs +512 -0
@@ -333,6 +333,7 @@ function findResearchByIdLocal(vaultRoot, researchId) {
333
333
  */
334
334
  async function findCaptureByIdViaTier(vaultRoot, captureId, { tier, consoleUrl, consoleFetch }) {
335
335
  return readViaSelector({
336
+ allowDiskFallback: false,
336
337
  tier,
337
338
  diskRead: () => findCaptureByIdLocal(vaultRoot, captureId),
338
339
  neonRead: () => fetchSpecRecordViaConsole(consoleFetch, captureId),
@@ -347,6 +348,7 @@ async function findCaptureByIdViaTier(vaultRoot, captureId, { tier, consoleUrl,
347
348
  */
348
349
  async function findResearchByIdViaTier(vaultRoot, researchId, { tier, consoleUrl, consoleFetch }) {
349
350
  return readViaSelector({
351
+ allowDiskFallback: false,
350
352
  tier,
351
353
  diskRead: () => findResearchByIdLocal(vaultRoot, researchId),
352
354
  neonRead: () => fetchSpecRecordViaConsole(consoleFetch, researchId),
@@ -217,6 +217,7 @@ function findResearchByIdLocal(vaultRoot, researchId) {
217
217
  */
218
218
  async function findResearchByIdViaTier(vaultRoot, researchId, { tier, consoleUrl, consoleFetch }) {
219
219
  return readViaSelector({
220
+ allowDiskFallback: false,
220
221
  tier,
221
222
  diskRead: () => findResearchByIdLocal(vaultRoot, researchId),
222
223
  neonRead: async () => {
@@ -274,6 +275,7 @@ function findMilestoneById(vaultRoot, milestoneId) {
274
275
  */
275
276
  async function findMilestoneByBasenameViaTier(vaultRoot, basename, { tier, consoleUrl, consoleFetch }) {
276
277
  return readViaSelector({
278
+ allowDiskFallback: false,
277
279
  tier,
278
280
  diskRead: () => findMilestoneByBasename(vaultRoot, basename),
279
281
  neonRead: () => fetchSpecRecordViaConsole(consoleFetch, basename),
@@ -288,6 +290,7 @@ async function findMilestoneByBasenameViaTier(vaultRoot, basename, { tier, conso
288
290
  */
289
291
  async function findMilestoneByIdViaTier(vaultRoot, milestoneId, { tier, consoleUrl, consoleFetch }) {
290
292
  return readViaSelector({
293
+ allowDiskFallback: false,
291
294
  tier,
292
295
  diskRead: () => findMilestoneById(vaultRoot, milestoneId),
293
296
  neonRead: () => fetchSpecRecordViaConsole(consoleFetch, milestoneId),
@@ -334,6 +337,7 @@ function findEpicByIdOrBasename(vaultRoot, idOrBasename) {
334
337
  */
335
338
  async function findEpicByIdOrBasenameViaTier(vaultRoot, idOrBasename, { tier, consoleUrl, consoleFetch }) {
336
339
  return readViaSelector({
340
+ allowDiskFallback: false,
337
341
  tier,
338
342
  diskRead: () => findEpicByIdOrBasename(vaultRoot, idOrBasename),
339
343
  neonRead: () => fetchSpecRecordViaConsole(consoleFetch, idOrBasename),
@@ -437,6 +441,7 @@ async function resolvePillarViaTier(vaultRoot, raw, { tier, consoleUrl, consoleF
437
441
  const basename = normalizePillarRef(raw);
438
442
  if (!basename) return { ok: false };
439
443
  return readViaSelector({
444
+ allowDiskFallback: false,
440
445
  tier,
441
446
  diskRead: () => resolvePillar(vaultRoot, raw),
442
447
  neonRead: async () => {
@@ -1004,6 +1009,7 @@ async function resolveOwnerIntentViaTier(vaultRoot, researchRaw, { tier, console
1004
1009
  let raw;
1005
1010
  try {
1006
1011
  raw = await readViaSelector({
1012
+ allowDiskFallback: false,
1007
1013
  tier,
1008
1014
  diskRead: () => {
1009
1015
  const capturesDir = join(vaultRoot, 'specs', 'captures');
@@ -46,6 +46,9 @@
46
46
  * import) so tests can stub scripts/lib/dev-mode.mjs and prove the verdict
47
47
  * comes from `resolveDevMode` itself, not from string-matching this file.
48
48
  */
49
+ import { AsyncLocalStorage } from 'node:async_hooks';
50
+ import { execFileSync } from 'node:child_process';
51
+ import { existsSync } from 'node:fs';
49
52
  import { join } from 'node:path';
50
53
  import { importDevModeCore } from './dev-mode-core.mjs';
51
54
  // THIS PACKAGE's own root (vendor/heyspex-mcp/read-path.mjs -> two levels
@@ -97,6 +100,27 @@ export class ConsoleUnreachableCloudModeError extends Error {
97
100
  }
98
101
  }
99
102
 
103
+ /**
104
+ * `{ degraded:true, source:'cloud', warning }` when the resolved vault is a
105
+ * satellite binding whose vault clone is absent on this machine (marker with
106
+ * vault_path, no clone), else null. Same record shape as the outage fallback.
107
+ */
108
+ export function vaultNotClonedInfo() {
109
+ try {
110
+ const cfg = getActiveConfig();
111
+ if (cfg && cfg.vaultCloned === false) {
112
+ return {
113
+ degraded: true,
114
+ source: 'cloud',
115
+ warning: `vault ${cfg.vaultId} is bound by this repo's heyspex.yaml but is not cloned at ${cfg.vaultPath ?? 'its vault_path'} on this machine; answered from the cloud (cloud-only mode). Clone the vault repo there for local reads.`,
116
+ };
117
+ }
118
+ } catch {
119
+ /* resolver failure: not a clone problem */
120
+ }
121
+ return null;
122
+ }
123
+
100
124
  /**
101
125
  * resolveReadTier({ env, homeDir, loadDevMode? }) -> "disk" | "neon"
102
126
  *
@@ -132,23 +156,155 @@ export async function resolveVaultWriteMode({ env, homeDir, loadDevMode = loadRe
132
156
  }
133
157
 
134
158
  /**
135
- * readViaSelector({ tier, diskRead, neonRead, routeLabel, consoleUrl })
159
+ * A console read that failed, carrying the transport verdict so
160
+ * {@link readViaSelector} can tell an OUTAGE (status 0, 5xx, timeout/network)
161
+ * from an ANSWER (401/403/404/409). Thrown by the `neonRead` closures; build
162
+ * one from a consoleFetch result with {@link consoleReadErrorFrom}.
163
+ */
164
+ export class ConsoleReadError extends Error {
165
+ constructor(message, { status, errorKind, route } = {}) {
166
+ super(message);
167
+ this.name = 'ConsoleReadError';
168
+ this.status = status;
169
+ this.errorKind = errorKind;
170
+ this.route = route;
171
+ }
172
+ }
173
+
174
+ /** Build a {@link ConsoleReadError} from a consoleFetch result `{status, errorKind}`. */
175
+ export function consoleReadErrorFrom(res, message, route) {
176
+ const e = new ConsoleReadError(message, { status: res?.status, errorKind: res?.errorKind, route });
177
+ if (res?.authProblem || res?.refusal) e.authProblem = true;
178
+ return e;
179
+ }
180
+
181
+ /**
182
+ * True when `err` says the console was unreachable or broken (status 0, any
183
+ * 5xx, errorKind timeout/abort/network). False for answered 4xx verdicts
184
+ * (401/403/404/409) and for anything unclassified.
185
+ */
186
+ export function isOutageClass(err) {
187
+ if (!err || typeof err !== 'object') return false;
188
+ if (err.authProblem) return false; // sign-in / scope refusals are not outages
189
+ const { status, errorKind } = err;
190
+ if (errorKind === 'timeout' || errorKind === 'abort' || errorKind === 'network') return true;
191
+ if (typeof status === 'number' && (status === 0 || status >= 500)) return true;
192
+ return false;
193
+ }
194
+
195
+ // Per-call collector so callers whose reader returns a non-object (Set, array,
196
+ // null) can still learn that the answer came from the local clone.
197
+ const degradationScope = new AsyncLocalStorage();
198
+
199
+ /**
200
+ * Run `fn`, collecting a {@link readViaSelector} degradation record for every
201
+ * disk-fallback read made inside it. Resolves `{ value, degraded }` where
202
+ * `degraded` is null or `{ degraded:true, source:'disk', cloud_error, warning }`.
203
+ */
204
+ export async function withDegradationScope(fn) {
205
+ const records = [];
206
+ const value = await degradationScope.run(records, fn);
207
+ return { value, degraded: records[0] ?? null };
208
+ }
209
+
210
+ function lastCommitIso(root) {
211
+ try {
212
+ const out = execFileSync('git', ['-C', root, 'log', '-1', '--format=%cI'], {
213
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 3000,
214
+ }).trim();
215
+ return out || null;
216
+ } catch {
217
+ return null;
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Stamp a degradation record onto a reader's result. Plain objects get the
223
+ * fields spread in; an MCP tool result (`{content:[{type:'text',text:<json>}]}`)
224
+ * gets them merged into the JSON payload inside `text` (and spread on the
225
+ * envelope too); any other shape (Set/array/null/primitive) is returned
226
+ * unchanged — it rides the {@link withDegradationScope} collector instead.
227
+ */
228
+ export function mergeDegradation(result, info) {
229
+ if (!isPlainObject(result) || !info) return result;
230
+ const first = Array.isArray(result.content) ? result.content[0] : null;
231
+ if (first && first.type === 'text' && typeof first.text === 'string') {
232
+ try {
233
+ const parsed = JSON.parse(first.text);
234
+ if (isPlainObject(parsed)) {
235
+ return { ...result, ...info, content: [{ ...first, text: JSON.stringify({ ...parsed, ...info }, null, 2) }, ...result.content.slice(1)] };
236
+ }
237
+ } catch {
238
+ /* not JSON — fall through to the envelope spread */
239
+ }
240
+ }
241
+ return { ...result, ...info };
242
+ }
243
+
244
+ function isPlainObject(v) {
245
+ return v !== null && typeof v === 'object' && !Array.isArray(v) && Object.getPrototypeOf(v) === Object.prototype;
246
+ }
247
+
248
+ /**
249
+ * readViaSelector({ tier, diskRead, neonRead, routeLabel, consoleUrl, vaultRoot?, allowDiskFallback? })
136
250
  *
137
251
  * Runs the read matching `tier`:
138
252
  * - "disk": calls `diskRead()` (`neonRead` is never invoked).
139
- * - "neon": calls `neonRead()`. On failure, wraps the error into a plain
140
- * {@link ConsoleUnreachableCloudModeError} naming `consoleUrl` and
141
- * `routeLabel`; `diskRead` is never called (no silent local fallback
142
- * out of cloud reliance).
253
+ * - "neon": calls `neonRead()`. On failure:
254
+ * * DEGRADE ("degrade, don't die"): when `diskRead` exists, the failure
255
+ * is outage-class ({@link isOutageClass}), a local clone exists
256
+ * (`<vaultRoot>/specs`), and HEYSPEX_NO_DISK_FALLBACK !== '1', answer
257
+ * from `diskRead()`. A plain-object result is returned spread with
258
+ * `degraded: true, source: 'disk', cloud_error, warning`; any other
259
+ * shape (Set/array/null) is returned unchanged and the same record is
260
+ * pushed to the active {@link withDegradationScope} collector.
261
+ * * otherwise wraps the error into {@link ConsoleUnreachableCloudModeError}
262
+ * naming `consoleUrl` and `routeLabel` (4xx, no clone, no diskRead,
263
+ * opt-out env, or `allowDiskFallback: false` for write preconditions).
143
264
  */
144
- export async function readViaSelector({ tier, diskRead, neonRead, routeLabel, consoleUrl }) {
265
+ export async function readViaSelector({ tier, diskRead, neonRead, routeLabel, consoleUrl, vaultRoot, allowDiskFallback = true }) {
266
+ // A satellite whose marker names a vault that is not cloned here is
267
+ // CLOUD-ONLY: a disk read would silently answer empty. Serve the cloud read
268
+ // (when this read has one) and declare it - never a silent empty answer.
269
+ const uncloned = vaultNotClonedInfo();
270
+ if (uncloned && typeof neonRead === 'function') {
271
+ const result = await neonRead();
272
+ const store = degradationScope.getStore();
273
+ if (store) store.push(uncloned);
274
+ return mergeDegradation(result, uncloned);
275
+ }
145
276
  if (tier === 'disk') {
277
+ if (uncloned) {
278
+ throw new ConsoleUnreachableCloudModeError(
279
+ `VAULT_NOT_CLONED_LOCALLY: route "${routeLabel}" has no cloud read and ${uncloned.warning}`,
280
+ );
281
+ }
146
282
  return diskRead();
147
283
  }
148
284
  if (tier === 'neon') {
149
285
  try {
150
286
  return await neonRead();
151
287
  } catch (err) {
288
+ const root = vaultRoot ?? resolveVaultRoot();
289
+ if (
290
+ allowDiskFallback &&
291
+ typeof diskRead === 'function' &&
292
+ isOutageClass(err) &&
293
+ process.env.HEYSPEX_NO_DISK_FALLBACK !== '1' &&
294
+ root && existsSync(join(root, 'specs'))
295
+ ) {
296
+ const result = await diskRead();
297
+ const kind = err.status ? `HTTP ${err.status}` : (err.errorKind ?? 'unreachable');
298
+ const info = {
299
+ degraded: true,
300
+ source: 'disk',
301
+ cloud_error: String(err?.message ?? err),
302
+ warning: `Console ${consoleUrl} failed (${kind}); answered from the local clone at ${root} (last commit ${lastCommitIso(root)}). Writes still require the console.`,
303
+ };
304
+ const store = degradationScope.getStore();
305
+ if (store) store.push(info);
306
+ return mergeDegradation(result, info);
307
+ }
152
308
  throw new ConsoleUnreachableCloudModeError(
153
309
  `CONSOLE_UNREACHABLE_CLOUD_MODE: route "${routeLabel}" failed against console ${consoleUrl} — ${String(err?.message ?? err)}`,
154
310
  );
@@ -18,7 +18,7 @@
18
18
  */
19
19
  import { AsyncLocalStorage } from 'node:async_hooks';
20
20
 
21
- /** @typedef {{ consoleUrl?: string, authHeader?: Record<string, string> }} RequestContext */
21
+ /** @typedef {{ consoleUrl?: string, authHeader?: Record<string, string>, vaultId?: string }} RequestContext */
22
22
 
23
23
  const storage = new AsyncLocalStorage();
24
24
 
@@ -57,3 +57,27 @@ export function contextAuthHeader() {
57
57
  const header = storage.getStore()?.authHeader;
58
58
  return header && typeof header === 'object' ? header : undefined;
59
59
  }
60
+
61
+ /**
62
+ * Outbound `x-heyspex-vault-id` for the active request, or `undefined` outside
63
+ * any request context (the stdio server then resolves the repo-bound vault
64
+ * from its cwd). INSIDE a context the answer is ALWAYS an object: the caller's
65
+ * own vault header, or `{}` when it sent none. It is never the server
66
+ * process's own vault (a shared serverless runtime has none), and never the
67
+ * account-wide active pointer: a bearer call that names no vault is refused by
68
+ * the console (`vault-scope-required`). Owner principles P1/P2/P3, 2026-10-16.
69
+ */
70
+ export function contextVaultIdHeader() {
71
+ const store = storage.getStore();
72
+ if (!store) return undefined;
73
+ const id = typeof store.vaultId === 'string' ? store.vaultId.trim() : '';
74
+ return id ? { 'x-heyspex-vault-id': id } : {};
75
+ }
76
+
77
+ /**
78
+ * Outbound `x-heyspex-account-id` for the active request: always `{}` inside a
79
+ * context (the forwarded credential IS the account), `undefined` outside.
80
+ */
81
+ export function contextAccountIdHeader() {
82
+ return storage.getStore() ? {} : undefined;
83
+ }
@@ -14,7 +14,7 @@ import { consoleErrorCode, consoleErrorMessage } from './console-fetch.mjs';
14
14
  import { randomUUID } from 'node:crypto';
15
15
  import { basename, join, relative, isAbsolute, resolve, sep } from 'node:path';
16
16
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
17
- import { readViaSelector, resolveReadTier, resolveVaultWriteMode } from './read-path.mjs';
17
+ import { consoleReadErrorFrom, readViaSelector, resolveReadTier, resolveVaultWriteMode, withDegradationScope } from './read-path.mjs';
18
18
  import { detectAgentIdentity, getConsoleUrl } from './mcp-config.mjs';
19
19
  import { collectOccupiedKeysEitherKind, mintFamilyKey, mintFileKey, parseSpecId } from './spec-id-lib.mjs';
20
20
  import { yamlScalar } from './yaml-scalar.mjs';
@@ -318,8 +318,9 @@ export async function handleSaveResearch(vaultRoot, args) {
318
318
  const tier = await resolveReadTier();
319
319
  const consoleUrl = getConsoleUrl();
320
320
  let occupiedResearch;
321
+ let occupiedDegraded = null;
321
322
  try {
322
- occupiedResearch = await readViaSelector({
323
+ ({ value: occupiedResearch, degraded: occupiedDegraded } = await withDegradationScope(() => readViaSelector({
323
324
  tier,
324
325
  diskRead: () => {
325
326
  const existingFiles = readdirSync(researchDir).filter(f => f.endsWith('.md') && !f.endsWith('.review.md'));
@@ -343,13 +344,13 @@ export async function handleSaveResearch(vaultRoot, args) {
343
344
  neonRead: async () => {
344
345
  const res = await consoleFetch('/api/vault/occupied-ids?bucket=research');
345
346
  if (!res.ok) {
346
- throw new Error(`GET /api/vault/occupied-ids?bucket=research returned ${res.status}: ${res.error || ''}`);
347
+ throw consoleReadErrorFrom(res, `GET /api/vault/occupied-ids?bucket=research returned ${res.status}: ${res.error || ''}`, null);
347
348
  }
348
349
  return new Set(Array.isArray(res.body?.ids) ? res.body.ids : []);
349
350
  },
350
351
  routeLabel: 'GET /api/vault/occupied-ids?bucket=research',
351
352
  consoleUrl,
352
- });
353
+ })));
353
354
  } catch (err) {
354
355
  return {
355
356
  isError: true,
@@ -445,6 +446,7 @@ ${bodyMarkdown}`;
445
446
  // the caller that research_type was not supplied so it defaulted, rather
446
447
  // than silently going unnoticed.
447
448
  if (researchTypeWasOmitted) attachResearchTypeWarning(result);
449
+ if (occupiedDegraded) result.degraded_warning = occupiedDegraded.warning;
448
450
 
449
451
  return {
450
452
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
@@ -0,0 +1,70 @@
1
+ // Per-vault default roadmap resolution. The MCP used to hardcode the HeySpex
2
+ // platform vault's roadmap id ('HS-ROADMAP-PRODUCT'), so every customer vault
3
+ // (whose roadmap has a different id, e.g. the `hs init` starter
4
+ // 'HS-START-ROADMAP') failed `heyspex_next_on_deck` with ROADMAP_NOT_FOUND.
5
+ //
6
+ // Precedence (explicit `roadmap_id` arg is applied by callers, above this):
7
+ // HEYSPEX_ROADMAP_ID env > the vault's own `type: roadmap` spec >
8
+ // 'HS-ROADMAP-PRODUCT' (last resort).
9
+ // Resolved per call from the vault root of THAT call: one MCP process can
10
+ // serve several vaults, so nothing here is cached.
11
+
12
+ import { join } from 'node:path';
13
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
14
+
15
+ export const PLATFORM_ROADMAP_ID = 'HS-ROADMAP-PRODUCT';
16
+
17
+ const SCAN_DIRS = ['specs/roadmaps', 'specs/roadmap', 'specs'];
18
+
19
+ function headFields(file) {
20
+ let text;
21
+ try {
22
+ text = readFileSync(file, 'utf8');
23
+ } catch {
24
+ return null;
25
+ }
26
+ const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
27
+ if (!m) return null;
28
+ const grab = (k) => {
29
+ const r = new RegExp('^' + k + ':[ \t]*(.+?)[ \t\r]*$', 'm').exec(m[1]);
30
+ return r ? r[1].replace(/^["']|["']$/g, '') : null;
31
+ };
32
+ return { type: grab('type'), id: grab('id') };
33
+ }
34
+
35
+ /** All `type: roadmap` spec ids found on disk under the vault's specs/. */
36
+ export function listVaultRoadmapIds(vaultRoot) {
37
+ const ids = new Set();
38
+ if (!vaultRoot) return [];
39
+ for (const dir of SCAN_DIRS) {
40
+ const abs = join(vaultRoot, dir);
41
+ if (!existsSync(abs)) continue;
42
+ let names;
43
+ try {
44
+ names = readdirSync(abs);
45
+ } catch {
46
+ continue;
47
+ }
48
+ for (const n of names) {
49
+ if (!n.endsWith('.md')) continue;
50
+ const f = headFields(join(abs, n));
51
+ if (f && f.type === 'roadmap' && f.id) ids.add(f.id);
52
+ }
53
+ }
54
+ return [...ids].sort();
55
+ }
56
+
57
+ /**
58
+ * @returns {{ id: string, source: 'env'|'vault'|'vault-multiple'|'fallback', candidates?: string[] }}
59
+ */
60
+ export function resolveDefaultRoadmap(vaultRoot, env = process.env) {
61
+ const fromEnv = env.HEYSPEX_ROADMAP_ID?.trim();
62
+ if (fromEnv) return { id: fromEnv, source: 'env' };
63
+ const ids = listVaultRoadmapIds(vaultRoot);
64
+ if (ids.length === 1) return { id: ids[0], source: 'vault' };
65
+ if (ids.length > 1) {
66
+ const id = ids.includes(PLATFORM_ROADMAP_ID) ? PLATFORM_ROADMAP_ID : ids[0];
67
+ return { id, source: 'vault-multiple', candidates: ids };
68
+ }
69
+ return { id: PLATFORM_ROADMAP_ID, source: 'fallback' };
70
+ }
@@ -33,6 +33,7 @@
33
33
  // is spawned once per session; pairing changes restart it). Callers inject
34
34
  // `env` / `homeDir` / `readFile` for tests, which bypasses the cache.
35
35
 
36
+ import { heyspexHome } from '../../scripts/lib/heyspex-home.mjs';
36
37
  import { readFileSync } from 'node:fs';
37
38
  import { homedir } from 'node:os';
38
39
  import { join } from 'node:path';
@@ -45,7 +46,7 @@ const ACCOUNT_ID_LINE_RE = /^account_id:\s*["']?([^"'\r\n#]+)/m;
45
46
  * Read the paired account id from `~/.heyspex/config.yaml`, or null.
46
47
  * @param {{ homeDir?: string, readFile?: (p: string) => string }} [opts]
47
48
  */
48
- export function readConfigAccountId({ homeDir = homedir(), readFile } = {}) {
49
+ export function readConfigAccountId({ homeDir = heyspexHome(), readFile } = {}) {
49
50
  const read = readFile ?? ((p) => readFileSync(p, 'utf8'));
50
51
  try {
51
52
  const raw = read(join(homeDir, '.heyspex', 'config.yaml'));
@@ -62,7 +63,7 @@ export function readConfigAccountId({ homeDir = homedir(), readFile } = {}) {
62
63
  * @param {{ env?: NodeJS.ProcessEnv | Record<string, string | undefined>, homeDir?: string, readFile?: (p: string) => string }} [opts]
63
64
  * @returns {string | null}
64
65
  */
65
- export function resolveSessionOwnerUncached({ env = process.env, homeDir = homedir(), readFile } = {}) {
66
+ export function resolveSessionOwnerUncached({ env = process.env, homeDir = heyspexHome(), readFile } = {}) {
66
67
  const fromEnv = typeof env?.HS_ACCOUNT_ID === 'string' ? env.HS_ACCOUNT_ID.trim() : '';
67
68
  if (fromEnv) return fromEnv;
68
69
  return readCurrentAccountId({ homeDir, readFile });
@@ -5,6 +5,8 @@
5
5
  import { consoleFetch } from './console-transport.mjs';
6
6
  import { consoleErrorCode, consoleErrorMessage } from './console-fetch.mjs';
7
7
  import { detectAgentIdentity } from './mcp-config.mjs';
8
+ import { resolveReadTier } from './read-path.mjs';
9
+ import { deferControlWrite } from './write-outbox.mjs';
8
10
 
9
11
  /**
10
12
  * Import a community agent role via console `POST /api/agents/import` — the MCP
@@ -85,6 +87,14 @@ export async function handleHandoffRecord(args = {}) {
85
87
  body,
86
88
  });
87
89
  if (!result.ok) {
90
+ const deferred = await deferControlWrite(result, {
91
+ route: '/api/control/handoff-context',
92
+ method: 'PUT',
93
+ request_body: body,
94
+ acting_agent: identity.agent,
95
+ session_id: identity.sessionId,
96
+ }, { isCloud: async () => (await resolveReadTier()) === 'neon' });
97
+ if (deferred) return deferred;
88
98
  const payload = {
89
99
  error: consoleErrorCode(result, 'HANDOFF_RECORD_FAILED'),
90
100
  message: consoleErrorMessage(result, `Handoff-context endpoint returned ${result.status}`),
@@ -8,7 +8,7 @@ import { detectTagDrift } from './plan-ground.mjs';
8
8
  import { consoleErrorMessage } from './console-fetch.mjs';
9
9
  import { detectAgentIdentity, getConsoleUrl } from './mcp-config.mjs';
10
10
  import { resolveVaultWriteMode } from './read-path.mjs';
11
- import { splitRawSpec, updateSpecViaConsole } from './cloud-vault-writes.mjs';
11
+ import { deferredPayloadFromError, splitRawSpec, updateSpecViaConsole } from './cloud-vault-writes.mjs';
12
12
  import { writeFileSync } from 'node:fs';
13
13
  import { executeShipEpicCloudAware } from './plan-push.mjs';
14
14
  import { executeRegenTask, executeRegenTasksForEpic } from './regen-tasks.mjs';
@@ -168,6 +168,13 @@ export async function handleEditFile(args = {}, deps = {}) {
168
168
  });
169
169
  } catch (err) {
170
170
  await releaseClaim();
171
+ // Outage: the edit was queued to the write outbox (truthful receipt, not an error).
172
+ const deferred = deferredPayloadFromError(err);
173
+ if (deferred) {
174
+ return {
175
+ content: [{ type: 'text', text: JSON.stringify({ ...deferred, file_path: args.file_path, spec_id: specId, write_mode: 'cloud', on_disk: false }, null, 2) }],
176
+ };
177
+ }
171
178
  return {
172
179
  isError: true,
173
180
  content: [{ type: 'text', text: JSON.stringify({
@@ -438,6 +438,7 @@ function findCaptureByIdOrBasename(vaultRoot, ref) {
438
438
 
439
439
  async function findResearchByIdViaTier(vaultRoot, researchId, { tier, consoleUrl, consoleFetch }) {
440
440
  return readViaSelector({
441
+ allowDiskFallback: false,
441
442
  tier,
442
443
  diskRead: () => findResearchByIdLocal(vaultRoot, researchId),
443
444
  neonRead: () => fetchSpecRecordViaConsole(consoleFetch, researchId),
@@ -448,6 +449,7 @@ async function findResearchByIdViaTier(vaultRoot, researchId, { tier, consoleUrl
448
449
 
449
450
  async function findMilestoneViaTier(vaultRoot, ref, { tier, consoleUrl, consoleFetch }) {
450
451
  return readViaSelector({
452
+ allowDiskFallback: false,
451
453
  tier,
452
454
  diskRead: () => findMilestoneByIdOrBasename(vaultRoot, ref),
453
455
  neonRead: () => fetchSpecRecordViaConsole(consoleFetch, ref),
@@ -458,6 +460,7 @@ async function findMilestoneViaTier(vaultRoot, ref, { tier, consoleUrl, consoleF
458
460
 
459
461
  async function findCaptureViaTier(vaultRoot, ref, { tier, consoleUrl, consoleFetch }) {
460
462
  return readViaSelector({
463
+ allowDiskFallback: false,
461
464
  tier,
462
465
  diskRead: () => findCaptureByIdOrBasename(vaultRoot, ref),
463
466
  neonRead: () => fetchSpecRecordViaConsole(consoleFetch, ref),
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { extractSection, hasHeading, parseAcceptanceSubsections } from './alignment-gate.mjs';
9
+ import { gatherCloseBranchReport } from './close-branch-report.mjs';
9
10
  import { readMvpModeState } from './mvp-mode.mjs';
10
11
  import { isBelowReviewLevel, reviewLevelForGrain } from './review-rollup.mjs';
11
12
 
@@ -142,7 +143,13 @@ function findClaimAcProgress(claimsBody, specId) {
142
143
  /**
143
144
  * @param {{ vaultRoot: string, consoleFetch: Function, postClaimEndpoint?: Function, args?: Record<string, unknown> }} ctx
144
145
  */
145
- export async function executeSubmitForReview({ vaultRoot, consoleFetch, postClaimEndpoint, args = {} }) {
146
+ export async function executeSubmitForReview({
147
+ vaultRoot,
148
+ consoleFetch,
149
+ postClaimEndpoint,
150
+ args = {},
151
+ gatherBranchReport = gatherCloseBranchReport,
152
+ }) {
146
153
  const specId = typeof args.spec_id === 'string' ? args.spec_id.trim() : '';
147
154
  if (!specId) {
148
155
  return { error: 'INVALID_ARGS', message: 'spec_id is required' };
@@ -295,6 +302,16 @@ export async function executeSubmitForReview({ vaultRoot, consoleFetch, postClai
295
302
  isReviewUnit: effectiveIsReviewUnit,
296
303
  });
297
304
 
305
+ // Name the branch each half of the session landed on, and whether they
306
+ // match ([[HS-PPX7-CWPX-TASK-session-close-names-the-branch-each]]).
307
+ // Best-effort: a failure here never blocks the close-out.
308
+ try {
309
+ const report = await gatherBranchReport({ consoleFetch });
310
+ guidance.branch_report = report;
311
+ } catch {
312
+ guidance.branch_report = { status: 'unknown', matches: false, lines: ['Branch report unavailable.'] };
313
+ }
314
+
298
315
  return {
299
316
  spec_id: specId,
300
317
  requested_status: 'in_review',
@@ -27,7 +27,7 @@
27
27
 
28
28
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
29
29
  import { join } from 'node:path';
30
- import { updateSpecViaConsole } from './cloud-vault-writes.mjs';
30
+ import { deferredPayloadFromError, updateSpecViaConsole } from './cloud-vault-writes.mjs';
31
31
  import { consoleFetch, controlOk } from './console-transport.mjs';
32
32
  import { consoleErrorCode, consoleErrorMessage } from './console-fetch.mjs';
33
33
  import { getConsoleUrl } from './mcp-config.mjs';
@@ -157,6 +157,10 @@ export async function handleAuthorSummary(vaultRoot, args = {}, deps = {}) {
157
157
  try {
158
158
  written = await updateSpecViaConsole({ consoleFetch: fetchDep, consoleUrl, specId: rowId, frontmatter, body });
159
159
  } catch (err) {
160
+ const deferred = deferredPayloadFromError(err);
161
+ if (deferred) {
162
+ return controlOk({ ...deferred, written: false, spec_id: rowId, summary, write_mode: 'cloud', on_disk: false });
163
+ }
160
164
  return errorResult({
161
165
  error: err?.notFound ? 'SPEC_NOT_FOUND' : 'CONSOLE_UNREACHABLE_CLOUD_MODE',
162
166
  message: String(err?.message ?? err),
@@ -22,7 +22,15 @@ import { resetActiveConfigCache } from './vault-resolve.mjs';
22
22
  * - a temp HOME (`HOME` + `USERPROFILE`, what `os.homedir()` reads) holding
23
23
  * `~/.heyspex/registry.json` with the roots the test names,
24
24
  * - `HS_ACCOUNT_ID` (tier 1 of session-owner.mjs) as the configured account,
25
- * - optionally `HEYSPEX_VAULT_ROOT` pointed at a registered root.
25
+ * - optionally `HEYSPEX_VAULT_ROOT` pointed at a registered root,
26
+ * - `CLAUDE_PROJECT_DIR` (the resolver's cwd base) pointed at that root —
27
+ * or at the temp home when none is given — so the resolver's walk starts
28
+ * inside the fixture and never climbs through the ambient checkout. The
29
+ * walk (tier 2) outranks `HEYSPEX_VAULT_ROOT` (tier 3, [[HS-VVNQ]]), so a
30
+ * satellite pin in the repo the suite is run FROM (heyspex-app's own
31
+ * `.mcp.json` since 2026-10-10) would otherwise silently replace the
32
+ * root a test pinned, and the machine's registry pointer (tier 4) would
33
+ * otherwise answer for a test that pinned nothing.
26
34
  *
27
35
  * `registerRoot(root)` adds a row for a root a test mints later (its own temp
28
36
  * vault) and resets the memoized resolver so the next request sees it. Call
@@ -42,10 +50,12 @@ export function installScopedConsoleEnv({
42
50
  repoRootAbs,
43
51
  activeVaultId,
44
52
  } = {}) {
45
- const ENV_KEYS = ['HOME', 'USERPROFILE', 'HS_ACCOUNT_ID', 'HEYSPEX_VAULT_ROOT'];
53
+ const ENV_KEYS = ['HOME', 'USERPROFILE', 'HS_ACCOUNT_ID', 'HEYSPEX_VAULT_ROOT', 'CLAUDE_PROJECT_DIR', 'HEYSPEX_MCP_TOKEN'];
46
54
  const saved = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
47
55
  const home = mkdtempSync(join(tmpdir(), 'hs-scoped-home-'));
48
- const registryDir = join(home, '.heyspex');
56
+ // The registry lives under the HeySpex home: HEYSPEX_HOME when a test set one, else the scoped HOME.
57
+ const registryHome = process.env.HEYSPEX_HOME?.trim() || home;
58
+ const registryDir = join(registryHome, '.heyspex');
49
59
  mkdirSync(registryDir, { recursive: true });
50
60
  const registryPath = join(registryDir, 'registry.json');
51
61
  // Registry schema v2 (HS-RXLG): the pointer is the signed-in account's row.
@@ -77,6 +87,11 @@ export function installScopedConsoleEnv({
77
87
  process.env.HOME = home;
78
88
  process.env.USERPROFILE = home;
79
89
  process.env.HS_ACCOUNT_ID = accountId;
90
+ // The scoped registry row is bound to this account (machine-local binding), so the
91
+ // machine must hold a sign-in for it; an env bearer stands in for the credentials file.
92
+ process.env.HEYSPEX_MCP_TOKEN ||= 'hsmcp_scoped_test_token';
93
+ // Fence the resolver's walk inside the fixture (see the doc comment above).
94
+ process.env.CLAUDE_PROJECT_DIR = repoRootAbs ?? home;
80
95
  if (repoRootAbs) {
81
96
  registerRoot(repoRootAbs, vaultId);
82
97
  process.env.HEYSPEX_VAULT_ROOT = repoRootAbs;
@@ -605,7 +605,7 @@ export const TOOLS = [
605
605
  },
606
606
  user_quote: {
607
607
  type: 'string',
608
- description: 'Verbatim excerpt from the human in this turn (appended to Notes)',
608
+ description: 'Verbatim excerpt from the human in this turn (appended to Notes). Stored exactly as given: newlines, spacing and wording are preserved (no trimming or flattening).',
609
609
  },
610
610
  agent_note: {
611
611
  type: 'string',
@@ -637,6 +637,12 @@ export const TOOLS = [
637
637
  description:
638
638
  'Author the `## Done When` checklist — markdown checklist lines (e.g. "- [ ] criterion") stating how the idea will be verified done. Written between User Stories and Expanded Concept. This is the fix for the promote sufficiency gate\'s "done-criteria" missing-element ([[HS-NR43-DIAG-CAIO]]): pass this instead of force_promote when the gate reports done-criteria as missing — elicit the criteria from the user rather than inventing them.',
639
639
  },
640
+ sources: {
641
+ type: 'array',
642
+ description:
643
+ 'Sources saved WITH the capture, verbatim (owner: files and original docs are saved as source). Each entry is ONE of: { url, title? } a link; { path, name?, mime? } a local file the MCP reads itself (preferred for documents, so the content never has to pass through the model); { name, text, mime?, origin? } an original text document; { name, content_base64, mime?, origin? } a binary file. Stored inline in the capture and listed under Source Material, with the original content under Source Files. Limits: 256 KiB per file, 1 MiB and 20 entries per capture; larger entries are refused by name (nothing is truncated). Appends to earlier sources.',
644
+ items: { type: 'object' },
645
+ },
640
646
  jtbd: {
641
647
  type: 'string',
642
648
  description: