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
|
@@ -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
|
-
*
|
|
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
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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({
|
|
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
|
-
|
|
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:
|