@spexcode/spec-cli 0.6.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +95 -0
- package/bin/spex.mjs +76 -0
- package/dist/attach.d.ts +2 -0
- package/dist/attach.js +48 -0
- package/dist/claude-headless.d.ts +33 -0
- package/dist/claude-headless.js +307 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +1859 -0
- package/dist/client.d.ts +111 -0
- package/dist/client.js +476 -0
- package/dist/cockpit.d.ts +7 -0
- package/dist/cockpit.js +26 -0
- package/dist/codex-headless.d.ts +1 -0
- package/dist/codex-headless.js +7 -0
- package/dist/codex-runtime-generations.d.ts +53 -0
- package/dist/codex-runtime-generations.js +686 -0
- package/dist/commit-surgery.d.ts +1 -0
- package/dist/commit-surgery.js +90 -0
- package/dist/contract-filter.d.ts +14 -0
- package/dist/contract-filter.js +219 -0
- package/dist/dashboard-assets.d.ts +6 -0
- package/dist/dashboard-assets.js +50 -0
- package/dist/delivery-queue.d.ts +23 -0
- package/dist/delivery-queue.js +179 -0
- package/dist/doctor.d.ts +9 -0
- package/dist/doctor.js +547 -0
- package/dist/eval-host.d.ts +1 -0
- package/dist/eval-host.js +19 -0
- package/dist/execution-trace.d.ts +26 -0
- package/dist/execution-trace.js +475 -0
- package/dist/file-write.d.ts +2 -0
- package/dist/file-write.js +26 -0
- package/dist/flat.d.ts +89 -0
- package/dist/flat.js +1076 -0
- package/dist/gateway-auth.d.ts +56 -0
- package/dist/gateway-auth.js +168 -0
- package/dist/gateway-hub.d.ts +28 -0
- package/dist/gateway-hub.js +361 -0
- package/dist/gateway.d.ts +37 -0
- package/dist/gateway.js +503 -0
- package/dist/graphCache.d.ts +27 -0
- package/dist/graphCache.js +716 -0
- package/dist/graphSnapshot.d.ts +14 -0
- package/dist/graphSnapshot.js +35 -0
- package/dist/graphStream.d.ts +85 -0
- package/dist/graphStream.js +1195 -0
- package/dist/guidance-catalog.d.ts +64 -0
- package/dist/guidance-catalog.js +166 -0
- package/dist/guide.d.ts +8 -0
- package/dist/guide.js +684 -0
- package/dist/harness-select.d.ts +18 -0
- package/dist/harness-select.js +62 -0
- package/dist/harness.d.ts +325 -0
- package/dist/harness.js +3026 -0
- package/dist/headless-controller.d.ts +9 -0
- package/dist/headless-controller.js +47 -0
- package/dist/help.d.ts +10 -0
- package/dist/help.js +574 -0
- package/dist/hook-prompts.d.ts +18 -0
- package/dist/hook-prompts.js +122 -0
- package/dist/hooks.d.ts +1 -0
- package/dist/hooks.js +27 -0
- package/dist/host-resources.d.ts +97 -0
- package/dist/host-resources.js +795 -0
- package/dist/host.d.ts +88 -0
- package/dist/host.js +663 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +899 -0
- package/dist/init.d.ts +1 -0
- package/dist/init.js +284 -0
- package/dist/issues-cli.d.ts +6 -0
- package/dist/issues-cli.js +334 -0
- package/dist/issues.d.ts +69 -0
- package/dist/issues.js +189 -0
- package/dist/lint.d.ts +34 -0
- package/dist/lint.js +476 -0
- package/dist/listen.d.ts +7 -0
- package/dist/listen.js +24 -0
- package/dist/localIssues.d.ts +65 -0
- package/dist/localIssues.js +582 -0
- package/dist/login-page.d.ts +5 -0
- package/dist/login-page.js +86 -0
- package/dist/loop-in.d.ts +23 -0
- package/dist/loop-in.js +68 -0
- package/dist/machine-peer.d.ts +69 -0
- package/dist/machine-peer.js +603 -0
- package/dist/materialize.d.ts +18 -0
- package/dist/materialize.js +468 -0
- package/dist/mentions.d.ts +50 -0
- package/dist/mentions.js +117 -0
- package/dist/opencode-headless.d.ts +4 -0
- package/dist/opencode-headless.js +208 -0
- package/dist/opencode.d.ts +3 -0
- package/dist/opencode.js +142 -0
- package/dist/pi-harness.d.ts +3 -0
- package/dist/pi-harness.js +129 -0
- package/dist/pi-headless.d.ts +25 -0
- package/dist/pi-headless.js +196 -0
- package/dist/plugin-harness.d.ts +21 -0
- package/dist/plugin-harness.js +146 -0
- package/dist/pty-bridge.d.ts +10 -0
- package/dist/pty-bridge.js +404 -0
- package/dist/pty-helper.d.mts +1 -0
- package/dist/pty-helper.mjs +101 -0
- package/dist/pty-native-helper.d.mts +2 -0
- package/dist/pty-native-helper.mjs +23 -0
- package/dist/public-graph.d.ts +46 -0
- package/dist/public-graph.js +56 -0
- package/dist/ranker.d.ts +15 -0
- package/dist/ranker.js +188 -0
- package/dist/reaper.d.ts +6 -0
- package/dist/reaper.js +81 -0
- package/dist/reviews.d.ts +189 -0
- package/dist/reviews.js +350 -0
- package/dist/runtime-guard.d.ts +6 -0
- package/dist/runtime-guard.js +37 -0
- package/dist/runtime-ownership.d.ts +21 -0
- package/dist/runtime-ownership.js +84 -0
- package/dist/search.bench.d.mts +1 -0
- package/dist/search.bench.mjs +75 -0
- package/dist/search.d.ts +20 -0
- package/dist/search.js +61 -0
- package/dist/session-cursors.d.ts +14 -0
- package/dist/session-cursors.js +82 -0
- package/dist/session-declarations.d.ts +13 -0
- package/dist/session-declarations.js +126 -0
- package/dist/session-execution.d.ts +10 -0
- package/dist/session-execution.js +70 -0
- package/dist/session-files.d.ts +28 -0
- package/dist/session-files.js +130 -0
- package/dist/session-follow.d.ts +31 -0
- package/dist/session-follow.js +179 -0
- package/dist/session-reparent.d.ts +10 -0
- package/dist/session-reparent.js +33 -0
- package/dist/session-timeline.d.ts +51 -0
- package/dist/session-timeline.js +233 -0
- package/dist/session-web.d.ts +24 -0
- package/dist/session-web.js +141 -0
- package/dist/sessions.d.ts +412 -0
- package/dist/sessions.js +4239 -0
- package/dist/sh.d.ts +1 -0
- package/dist/sh.js +3 -0
- package/dist/shim-runtime.d.ts +1 -0
- package/dist/shim-runtime.js +161 -0
- package/dist/slash-commands.d.ts +9 -0
- package/dist/slash-commands.js +307 -0
- package/dist/source-files.d.ts +9 -0
- package/dist/source-files.js +113 -0
- package/dist/supervise.d.ts +1 -0
- package/dist/supervise.js +308 -0
- package/dist/tree.d.ts +25 -0
- package/dist/tree.js +98 -0
- package/dist/tsx-bin.d.ts +3 -0
- package/dist/tsx-bin.js +41 -0
- package/dist/uninstall.d.ts +3 -0
- package/dist/uninstall.js +177 -0
- package/dist/uploads.d.ts +36 -0
- package/dist/uploads.js +243 -0
- package/dist/worktree-sources.d.ts +1 -0
- package/dist/worktree-sources.js +47 -0
- package/hooks/compat/mark-active-0.5.2-eef1.fixture +53 -0
- package/hooks/compat/mark-active-sed-v0.fixture +46 -0
- package/hooks/dispatch.sh +105 -0
- package/hooks/harness.sh +272 -0
- package/package.json +47 -0
- package/templates/hooks/commit-msg +14 -0
- package/templates/hooks/post-checkout +25 -0
- package/templates/hooks/post-merge +34 -0
- package/templates/hooks/pre-commit +120 -0
- package/templates/hooks/prepare-commit-msg +35 -0
- package/templates/hooks/reference-transaction +105 -0
- package/templates/spec/project/.plugins/commands/extract/spec.md +66 -0
- package/templates/spec/project/.plugins/commands/regroup/spec.md +25 -0
- package/templates/spec/project/.plugins/commands/rename/spec.md +11 -0
- package/templates/spec/project/.plugins/commands/spec.md +30 -0
- package/templates/spec/project/.plugins/commands/supervisor/spec.md +8 -0
- package/templates/spec/project/.plugins/commands/tidy/spec.md +43 -0
- package/templates/spec/project/.plugins/core/comment-altitude/spec.md +9 -0
- package/templates/spec/project/.plugins/core/idle/idle.sh +15 -0
- package/templates/spec/project/.plugins/core/idle/spec.md +13 -0
- package/templates/spec/project/.plugins/core/mark-active/mark-active.sh +59 -0
- package/templates/spec/project/.plugins/core/mark-active/spec.md +22 -0
- package/templates/spec/project/.plugins/core/session-fail/fail.sh +12 -0
- package/templates/spec/project/.plugins/core/session-fail/spec.md +15 -0
- package/templates/spec/project/.plugins/core/spec-first/spec-first.sh +43 -0
- package/templates/spec/project/.plugins/core/spec-first/spec.md +17 -0
- package/templates/spec/project/.plugins/core/spec-of-file/spec-of-file.sh +66 -0
- package/templates/spec/project/.plugins/core/spec-of-file/spec.md +15 -0
- package/templates/spec/project/.plugins/core/spec.md +22 -0
- package/templates/spec/project/.plugins/core/stop-gate/spec.md +23 -0
- package/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +149 -0
- package/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +15 -0
- package/templates/spec/project/.plugins/prompts/forge-link/spec.md +10 -0
- package/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +13 -0
- package/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +12 -0
- package/templates/spec/project/.plugins/prompts/spec.md +24 -0
- package/templates/spec/project/.plugins/skills/distill/digest.mjs +136 -0
- package/templates/spec/project/.plugins/skills/distill/spec.md +67 -0
- package/templates/spec/project/.plugins/skills/spec.md +19 -0
- package/templates/spec/project/.plugins/spec.md +23 -0
- package/templates/spec/project/spec.md +20 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type UploadPolicy } from '@spexcode/spec-core';
|
|
2
|
+
type UploadMeta = {
|
|
3
|
+
version: 1;
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
size: number;
|
|
7
|
+
offset: number;
|
|
8
|
+
createdAt: number;
|
|
9
|
+
updatedAt: number;
|
|
10
|
+
};
|
|
11
|
+
export type UploadStatus = Pick<UploadMeta, 'id' | 'name' | 'size' | 'offset'> & Pick<UploadPolicy, 'chunkBytes' | 'concurrency' | 'requestTimeoutMs' | 'retryLimit' | 'retryDelayMs'>;
|
|
12
|
+
export declare class UploadError extends Error {
|
|
13
|
+
readonly status: number;
|
|
14
|
+
readonly offset?: number | undefined;
|
|
15
|
+
constructor(status: number, message: string, offset?: number | undefined);
|
|
16
|
+
}
|
|
17
|
+
export declare const evidenceMaxBytes: () => number;
|
|
18
|
+
export declare function cleanupExpiredUploads(policy?: Required<{
|
|
19
|
+
maxBytes?: number;
|
|
20
|
+
chunkBytes?: number;
|
|
21
|
+
concurrency?: number;
|
|
22
|
+
requestTimeoutMs?: number;
|
|
23
|
+
retryLimit?: number;
|
|
24
|
+
retryDelayMs?: number;
|
|
25
|
+
incompleteTtlMs?: number;
|
|
26
|
+
cleanupIntervalMs?: number;
|
|
27
|
+
minFreeBytes?: number;
|
|
28
|
+
evidenceMaxBytes?: number;
|
|
29
|
+
}>): void;
|
|
30
|
+
export declare function startUploadReaper(): void;
|
|
31
|
+
export declare function createUpload(name: unknown, size: unknown): UploadStatus;
|
|
32
|
+
export declare function uploadStatus(id: string): UploadStatus;
|
|
33
|
+
export declare function appendUpload(id: string, offset: unknown, body: ReadableStream<Uint8Array> | null, contentLength?: string): Promise<UploadStatus>;
|
|
34
|
+
export declare function completeUpload(id: string): string;
|
|
35
|
+
export declare function cancelUpload(id: string): void;
|
|
36
|
+
export {};
|
package/dist/uploads.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { createWriteStream, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, statfsSync, truncateSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { basename, join } from 'node:path';
|
|
5
|
+
import { Readable, Transform } from 'node:stream';
|
|
6
|
+
import { pipeline } from 'node:stream/promises';
|
|
7
|
+
import { repoRoot } from '@spexcode/spec-core';
|
|
8
|
+
import { readUploadPolicy } from '@spexcode/spec-core';
|
|
9
|
+
// The backend's tmpdir is the worker's filesystem. Completed files stay directly under this sink; private
|
|
10
|
+
// metadata and partial bytes live below .staging until an exact-length transfer is promoted atomically.
|
|
11
|
+
const UPLOAD_DIR = join(tmpdir(), 'spexcode-uploads');
|
|
12
|
+
const STAGING_DIR = join(UPLOAD_DIR, '.staging');
|
|
13
|
+
export class UploadError extends Error {
|
|
14
|
+
status;
|
|
15
|
+
offset;
|
|
16
|
+
constructor(status, message, offset) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.status = status;
|
|
19
|
+
this.offset = offset;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const uploadPolicy = () => readUploadPolicy(repoRoot());
|
|
23
|
+
export const evidenceMaxBytes = () => uploadPolicy().evidenceMaxBytes;
|
|
24
|
+
function ensureDirs() {
|
|
25
|
+
mkdirSync(STAGING_DIR, { recursive: true });
|
|
26
|
+
}
|
|
27
|
+
function safeName(name) {
|
|
28
|
+
const base = basename(name || '').replace(/[^A-Za-z0-9._-]/g, '_').replace(/^\.+/, '');
|
|
29
|
+
return base || 'upload';
|
|
30
|
+
}
|
|
31
|
+
function validId(id) {
|
|
32
|
+
return /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(id);
|
|
33
|
+
}
|
|
34
|
+
function metaPath(id) {
|
|
35
|
+
return join(STAGING_DIR, `${id}.json`);
|
|
36
|
+
}
|
|
37
|
+
function partPath(id) {
|
|
38
|
+
return join(STAGING_DIR, `${id}.part`);
|
|
39
|
+
}
|
|
40
|
+
function removeTransfer(id) {
|
|
41
|
+
rmSync(metaPath(id), { force: true });
|
|
42
|
+
rmSync(partPath(id), { force: true });
|
|
43
|
+
}
|
|
44
|
+
function readMetaFile(id) {
|
|
45
|
+
if (!validId(id))
|
|
46
|
+
return null;
|
|
47
|
+
try {
|
|
48
|
+
const meta = JSON.parse(readFileSync(metaPath(id), 'utf8'));
|
|
49
|
+
if (meta.version !== 1 || meta.id !== id || typeof meta.name !== 'string' ||
|
|
50
|
+
!Number.isSafeInteger(meta.size) || meta.size <= 0 ||
|
|
51
|
+
!Number.isSafeInteger(meta.offset) || meta.offset < 0 || meta.offset > meta.size ||
|
|
52
|
+
!Number.isSafeInteger(meta.createdAt) || !Number.isSafeInteger(meta.updatedAt))
|
|
53
|
+
return null;
|
|
54
|
+
return meta;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function writeMeta(meta) {
|
|
61
|
+
const path = metaPath(meta.id);
|
|
62
|
+
const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
63
|
+
writeFileSync(temp, JSON.stringify(meta) + '\n');
|
|
64
|
+
renameSync(temp, path);
|
|
65
|
+
}
|
|
66
|
+
function actualOffset(meta) {
|
|
67
|
+
try {
|
|
68
|
+
return statSync(partPath(meta.id)).size;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function status(meta, policy) {
|
|
75
|
+
const { chunkBytes, concurrency, requestTimeoutMs, retryLimit, retryDelayMs } = policy;
|
|
76
|
+
return { id: meta.id, name: meta.name, size: meta.size, offset: meta.offset, chunkBytes, concurrency, requestTimeoutMs, retryLimit, retryDelayMs };
|
|
77
|
+
}
|
|
78
|
+
function syncOffset(meta) {
|
|
79
|
+
const offset = actualOffset(meta);
|
|
80
|
+
if (offset > meta.size)
|
|
81
|
+
throw new UploadError(409, 'upload staging file exceeds its declared length');
|
|
82
|
+
if (offset !== meta.offset) {
|
|
83
|
+
meta.offset = offset;
|
|
84
|
+
meta.updatedAt = Date.now();
|
|
85
|
+
writeMeta(meta);
|
|
86
|
+
}
|
|
87
|
+
return meta;
|
|
88
|
+
}
|
|
89
|
+
function loadUpload(id, policy) {
|
|
90
|
+
const meta = readMetaFile(id);
|
|
91
|
+
if (!meta)
|
|
92
|
+
throw new UploadError(404, 'upload not found');
|
|
93
|
+
if (Date.now() - meta.updatedAt > policy.incompleteTtlMs) {
|
|
94
|
+
removeTransfer(id);
|
|
95
|
+
throw new UploadError(404, 'upload expired');
|
|
96
|
+
}
|
|
97
|
+
return syncOffset(meta);
|
|
98
|
+
}
|
|
99
|
+
function reservedBytes(policy) {
|
|
100
|
+
let total = 0;
|
|
101
|
+
for (const entry of readdirSync(STAGING_DIR, { withFileTypes: true })) {
|
|
102
|
+
if (!entry.isFile() || !entry.name.endsWith('.json'))
|
|
103
|
+
continue;
|
|
104
|
+
const meta = readMetaFile(entry.name.slice(0, -5));
|
|
105
|
+
if (!meta || Date.now() - meta.updatedAt > policy.incompleteTtlMs)
|
|
106
|
+
continue;
|
|
107
|
+
total += Math.max(0, meta.size - actualOffset(meta));
|
|
108
|
+
}
|
|
109
|
+
return total;
|
|
110
|
+
}
|
|
111
|
+
function reserveCapacity(size, policy) {
|
|
112
|
+
let available = 0;
|
|
113
|
+
try {
|
|
114
|
+
const fs = statfsSync(UPLOAD_DIR);
|
|
115
|
+
available = Number(fs.bavail) * Number(fs.bsize);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// A filesystem that cannot report capacity will still fail loudly during the stream write.
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (size > Math.max(0, available - policy.minFreeBytes - reservedBytes(policy))) {
|
|
122
|
+
throw new UploadError(507, 'insufficient backend disk capacity for this upload');
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
export function cleanupExpiredUploads(policy = uploadPolicy()) {
|
|
126
|
+
ensureDirs();
|
|
127
|
+
const now = Date.now();
|
|
128
|
+
for (const entry of readdirSync(STAGING_DIR, { withFileTypes: true })) {
|
|
129
|
+
if (!entry.isFile() || !entry.name.endsWith('.json'))
|
|
130
|
+
continue;
|
|
131
|
+
const id = entry.name.slice(0, -5);
|
|
132
|
+
const meta = readMetaFile(id);
|
|
133
|
+
if (!meta || now - meta.updatedAt > policy.incompleteTtlMs)
|
|
134
|
+
removeTransfer(id);
|
|
135
|
+
}
|
|
136
|
+
for (const entry of readdirSync(STAGING_DIR, { withFileTypes: true })) {
|
|
137
|
+
if (!entry.isFile() || !entry.name.endsWith('.part'))
|
|
138
|
+
continue;
|
|
139
|
+
const id = entry.name.slice(0, -5);
|
|
140
|
+
if (readMetaFile(id))
|
|
141
|
+
continue;
|
|
142
|
+
try {
|
|
143
|
+
if (now - statSync(partPath(id)).mtimeMs > policy.incompleteTtlMs)
|
|
144
|
+
rmSync(partPath(id), { force: true });
|
|
145
|
+
}
|
|
146
|
+
catch { /* raced a cancellation or another cleanup pass */ }
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
export function startUploadReaper() {
|
|
150
|
+
const sweep = () => {
|
|
151
|
+
const policy = uploadPolicy();
|
|
152
|
+
cleanupExpiredUploads(policy);
|
|
153
|
+
const timer = setTimeout(sweep, policy.cleanupIntervalMs);
|
|
154
|
+
timer.unref();
|
|
155
|
+
};
|
|
156
|
+
sweep();
|
|
157
|
+
}
|
|
158
|
+
export function createUpload(name, size) {
|
|
159
|
+
const policy = uploadPolicy();
|
|
160
|
+
if (typeof name !== 'string' || !name.trim())
|
|
161
|
+
throw new UploadError(400, 'file name is required');
|
|
162
|
+
if (typeof size !== 'number' || !Number.isSafeInteger(size) || size <= 0)
|
|
163
|
+
throw new UploadError(400, 'file must not be empty');
|
|
164
|
+
if (size > policy.maxBytes)
|
|
165
|
+
throw new UploadError(413, 'file exceeds the configured upload limit');
|
|
166
|
+
ensureDirs();
|
|
167
|
+
cleanupExpiredUploads(policy);
|
|
168
|
+
reserveCapacity(size, policy);
|
|
169
|
+
const now = Date.now();
|
|
170
|
+
const meta = { version: 1, id: randomUUID(), name: safeName(name), size, offset: 0, createdAt: now, updatedAt: now };
|
|
171
|
+
writeMeta(meta);
|
|
172
|
+
return status(meta, policy);
|
|
173
|
+
}
|
|
174
|
+
export function uploadStatus(id) {
|
|
175
|
+
const policy = uploadPolicy();
|
|
176
|
+
return status(loadUpload(id, policy), policy);
|
|
177
|
+
}
|
|
178
|
+
export async function appendUpload(id, offset, body, contentLength) {
|
|
179
|
+
const policy = uploadPolicy();
|
|
180
|
+
const meta = loadUpload(id, policy);
|
|
181
|
+
if (typeof offset !== 'number' || !Number.isSafeInteger(offset) || offset < 0)
|
|
182
|
+
throw new UploadError(400, 'upload offset must be a non-negative integer');
|
|
183
|
+
if (offset !== meta.offset)
|
|
184
|
+
throw new UploadError(409, 'upload offset does not match the committed bytes', meta.offset);
|
|
185
|
+
if (!body)
|
|
186
|
+
throw new UploadError(400, 'upload chunk is required');
|
|
187
|
+
const declared = contentLength == null ? null : Number(contentLength);
|
|
188
|
+
if (declared != null && (!Number.isSafeInteger(declared) || declared <= 0))
|
|
189
|
+
throw new UploadError(400, 'content-length must be a positive integer');
|
|
190
|
+
if (declared != null && (declared > policy.chunkBytes || meta.offset + declared > meta.size)) {
|
|
191
|
+
throw new UploadError(413, 'upload chunk exceeds its declared bounds');
|
|
192
|
+
}
|
|
193
|
+
let written = 0;
|
|
194
|
+
const before = meta.offset;
|
|
195
|
+
const limiter = new Transform({
|
|
196
|
+
transform(chunk, _encoding, callback) {
|
|
197
|
+
written += chunk.length;
|
|
198
|
+
if (written > policy.chunkBytes || before + written > meta.size) {
|
|
199
|
+
callback(new UploadError(413, 'upload chunk exceeds its declared bounds'));
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
callback(null, chunk);
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
try {
|
|
206
|
+
await pipeline(Readable.fromWeb(body), limiter, createWriteStream(partPath(id), { flags: 'a' }));
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
if (error instanceof UploadError) {
|
|
210
|
+
truncateSync(partPath(id), before);
|
|
211
|
+
throw error;
|
|
212
|
+
}
|
|
213
|
+
// An interrupted connection may have committed a valid prefix. The next GET reports its actual length.
|
|
214
|
+
meta.offset = actualOffset(meta);
|
|
215
|
+
meta.updatedAt = Date.now();
|
|
216
|
+
writeMeta(meta);
|
|
217
|
+
throw new UploadError(500, `upload write failed: ${error.message}`);
|
|
218
|
+
}
|
|
219
|
+
if (written === 0)
|
|
220
|
+
throw new UploadError(400, 'upload chunk is empty');
|
|
221
|
+
meta.offset = actualOffset(meta);
|
|
222
|
+
meta.updatedAt = Date.now();
|
|
223
|
+
writeMeta(meta);
|
|
224
|
+
return status(meta, policy);
|
|
225
|
+
}
|
|
226
|
+
export function completeUpload(id) {
|
|
227
|
+
const meta = loadUpload(id, uploadPolicy());
|
|
228
|
+
if (meta.offset !== meta.size)
|
|
229
|
+
throw new UploadError(409, `upload is incomplete (${meta.offset} of ${meta.size} bytes)`, meta.offset);
|
|
230
|
+
const path = join(UPLOAD_DIR, `${Date.now().toString(36)}-${meta.id}-${safeName(meta.name)}`);
|
|
231
|
+
try {
|
|
232
|
+
renameSync(partPath(id), path);
|
|
233
|
+
rmSync(metaPath(id), { force: true });
|
|
234
|
+
return path;
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
throw new UploadError(500, `upload completion failed: ${error.message}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
export function cancelUpload(id) {
|
|
241
|
+
loadUpload(id, uploadPolicy());
|
|
242
|
+
removeTransfer(id);
|
|
243
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function seedWorktreeHostState(main: string, wt: string): void;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { git } from '@spexcode/spec-core';
|
|
4
|
+
export function seedWorktreeHostState(main, wt) {
|
|
5
|
+
const f = 'spexcode.local.json';
|
|
6
|
+
try {
|
|
7
|
+
if (!existsSync(join(main, f)) || existsSync(join(wt, f)))
|
|
8
|
+
return;
|
|
9
|
+
copyFileSync(join(main, f), join(wt, f));
|
|
10
|
+
}
|
|
11
|
+
catch (e) {
|
|
12
|
+
console.error(`spexcode: could not seed ${f} from ${main} into worktree ${wt} — that worker runs on defaults (${e})`);
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
hideSeededFromGit(wt, [f]);
|
|
16
|
+
}
|
|
17
|
+
// what we seed, we hide: a seeded entry git still sees is force-add bait (a real PR once carried seeded
|
|
18
|
+
// files into a product repo). `.git/info/exclude` lives in the COMMON git dir, so one write hides the entry
|
|
19
|
+
// in every linked worktree AND the main checkout. Only entries seeded by THIS call and reported un-ignored
|
|
20
|
+
// by `git check-ignore` are written: idempotent across dispatches, and a repo whose materialize already ignores
|
|
21
|
+
// the overlay (materialize's block under any policy) writes nothing — the self-heal for a half-configured repo.
|
|
22
|
+
function hideSeededFromGit(wt, seeded) {
|
|
23
|
+
for (const f of seeded) {
|
|
24
|
+
try {
|
|
25
|
+
if (isIgnored(wt, f))
|
|
26
|
+
continue;
|
|
27
|
+
const exclude = join(git(['-C', wt, 'rev-parse', '--path-format=absolute', '--git-common-dir']).trim(), 'info', 'exclude');
|
|
28
|
+
mkdirSync(dirname(exclude), { recursive: true });
|
|
29
|
+
const cur = existsSync(exclude) ? readFileSync(exclude, 'utf8') : '';
|
|
30
|
+
appendFileSync(exclude, `${cur && !cur.endsWith('\n') ? '\n' : ''}${f}\n`);
|
|
31
|
+
}
|
|
32
|
+
catch (e) {
|
|
33
|
+
console.error(`spexcode: could not hide seeded ${f} in the shared info/exclude for ${wt} — it will show untracked there (${e})`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function isIgnored(wt, f) {
|
|
38
|
+
try {
|
|
39
|
+
git(['-C', wt, 'check-ignore', '-q', f]);
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
catch (e) {
|
|
43
|
+
if (e?.status === 1)
|
|
44
|
+
return false; // check-ignore's documented "not ignored" exit
|
|
45
|
+
throw e;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# @@@ mark-active - the SINGLE freshness hook, wired to BOTH UserPromptSubmit and PreToolUse. It branches
|
|
3
|
+
# on ONE structured signal read straight from the hook payload (stdin JSON), so the state is HARD — never
|
|
4
|
+
# text-sniffed from the TUI:
|
|
5
|
+
# the agent is pausing to ask the HUMAN (hp_is_ask) → status: asking, with the question text as the note
|
|
6
|
+
# (the deterministic capture of a question).
|
|
7
|
+
# any other tool, or a prompt submit → the agent is working → status: active (drop a now-stale proposal/note).
|
|
8
|
+
# WHAT counts as "asking" is the [[harness-adapter]]'s call (Claude: the AskUserQuestion tool; Codex: the
|
|
9
|
+
# request_user_input tool) — read via hp_is_ask, so this hook never names a harness tool.
|
|
10
|
+
# Fires BEFORE the tool runs, so a `spex session done` declaration (itself a tool) lands AFTER this and wins;
|
|
11
|
+
# the next real tool flips back to active, forcing a fresh Stop-gate declaration. Pure shell (no node/tsx) so
|
|
12
|
+
# it stays cheap on every tool call — it value-replaces status/proposal/note in session.json with sed, never jq.
|
|
13
|
+
# @@@ global store - state lives NOT in the worktree but in the per-session GLOBAL record session.json, keyed
|
|
14
|
+
# by the harness session_id, grouped per-project (see hp_store_dir). GATED on `governed`: a user-self-launched
|
|
15
|
+
# (non-governed) session has no board to feed, so this no-ops on it. cwd = the session worktree.
|
|
16
|
+
. "${SPEXCODE_HARNESS_LIB:?harness.sh not exported by dispatch.sh}"
|
|
17
|
+
payload=$(cat 2>/dev/null)
|
|
18
|
+
# an IN-PROCESS SUBAGENT's tool call (Claude's Task tool) fires the parent's hooks with the PARENT's
|
|
19
|
+
# session_id — flipping here let a supervising parent's own subagents erase its declared park/ask within
|
|
20
|
+
# seconds and race the stop-gate into "undeclared stop" (issue #60). A subagent working is not the parent
|
|
21
|
+
# agent ACTING, so its calls never touch the record; the parent's own next tool call still flips. The
|
|
22
|
+
# discriminator is the payload's own top-level agent_id stamp (hp_is_subagent) — deterministic, never a
|
|
23
|
+
# timing window.
|
|
24
|
+
[ -n "$(hp_is_subagent "$payload")" ] && exit 0
|
|
25
|
+
sid=$(hp_session_id "$payload"); [ -n "$sid" ] || exit 0
|
|
26
|
+
sdir=$(hp_store_dir "$sid") || exit 0
|
|
27
|
+
rec="$sdir/session.json"
|
|
28
|
+
# board-lifecycle gate: only a GOVERNED (dashboard-launched) session has a board state to maintain.
|
|
29
|
+
grep -q '"governed"[[:space:]]*:[[:space:]]*true' "$rec" 2>/dev/null || exit 0
|
|
30
|
+
|
|
31
|
+
jget() { sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" "$rec" 2>/dev/null | head -1; }
|
|
32
|
+
|
|
33
|
+
if [ -n "$(hp_is_ask "$payload")" ]; then
|
|
34
|
+
status=asking
|
|
35
|
+
note=$(hp_ask_note "$payload") # first question's text → the note (best-effort)
|
|
36
|
+
else
|
|
37
|
+
status=active
|
|
38
|
+
note=
|
|
39
|
+
fi
|
|
40
|
+
|
|
41
|
+
# cheap path: already active with nothing stale to clear → no-op (the common every-tool case).
|
|
42
|
+
[ "$status" = active ] && [ "$(jget status)" = active ] && [ -z "$(jget proposal)" ] && [ -z "$(jget note)" ] && exit 0
|
|
43
|
+
|
|
44
|
+
# value-replace status + clear proposal + (re)set note, in place. The record is written one-field-per-line
|
|
45
|
+
# with these keys ALWAYS present (sessions.ts writeRecord), so each is a single value substitution — no key
|
|
46
|
+
# add/remove, no JSON parser. Escape \ / & in the note for the sed REPLACEMENT (the note never contains ").
|
|
47
|
+
note_esc=$(printf '%s' "$note" | sed 's/[\\/&]/\\&/g')
|
|
48
|
+
tmp=$(mktemp) || exit 0
|
|
49
|
+
sed -e "s/\(\"status\"[[:space:]]*:[[:space:]]*\)\"[^\"]*\"/\1\"$status\"/" \
|
|
50
|
+
-e "s/\(\"proposal\"[[:space:]]*:[[:space:]]*\)\"[^\"]*\"/\1\"\"/" \
|
|
51
|
+
-e "s/\(\"note\"[[:space:]]*:[[:space:]]*\)\"[^\"]*\"/\1\"$note_esc\"/" \
|
|
52
|
+
"$rec" > "$tmp" && mv "$tmp" "$rec"
|
|
53
|
+
exit 0
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# @@@ mark-active - the SINGLE freshness hook, wired to BOTH UserPromptSubmit and PreToolUse. It branches
|
|
3
|
+
# on ONE structured signal read straight from the hook payload (stdin JSON), so the state is HARD — never
|
|
4
|
+
# text-sniffed from the TUI:
|
|
5
|
+
# the agent is pausing to ask the HUMAN (hp_is_ask) → status: asking, with the question text as the note
|
|
6
|
+
# (the deterministic capture of a question).
|
|
7
|
+
# any other tool, or a prompt submit → the agent is working → status: active (drop a now-stale proposal/note).
|
|
8
|
+
# WHAT counts as "asking" is the [[harness-adapter]]'s call (Claude: the AskUserQuestion tool; Codex: the
|
|
9
|
+
# request_user_input tool) — read via hp_is_ask, so this hook never names a harness tool.
|
|
10
|
+
# Fires BEFORE the tool runs, so a `spex session done` declaration (itself a tool) lands AFTER this and wins;
|
|
11
|
+
# the next real tool flips back to active, forcing a fresh Stop-gate declaration. Pure shell (no node/tsx) so
|
|
12
|
+
# it stays cheap on every tool call — it value-replaces status/proposal/note in session.json with sed, never jq.
|
|
13
|
+
# @@@ global store - state lives NOT in the worktree but in the per-session GLOBAL record session.json, keyed
|
|
14
|
+
# by the harness session_id, grouped per-project (see hp_store_dir). GATED on `governed`: a user-self-launched
|
|
15
|
+
# (non-governed) session has no board to feed, so this no-ops on it. cwd = the session worktree.
|
|
16
|
+
. "${SPEXCODE_HARNESS_LIB:?harness.sh not exported by dispatch.sh}"
|
|
17
|
+
payload=$(cat 2>/dev/null)
|
|
18
|
+
sid=$(hp_session_id "$payload"); [ -n "$sid" ] || exit 0
|
|
19
|
+
sdir=$(hp_store_dir "$sid") || exit 0
|
|
20
|
+
rec="$sdir/session.json"
|
|
21
|
+
# board-lifecycle gate: only a GOVERNED (dashboard-launched) session has a board state to maintain.
|
|
22
|
+
grep -q '"governed"[[:space:]]*:[[:space:]]*true' "$rec" 2>/dev/null || exit 0
|
|
23
|
+
|
|
24
|
+
jget() { sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" "$rec" 2>/dev/null | head -1; }
|
|
25
|
+
|
|
26
|
+
if [ -n "$(hp_is_ask "$payload")" ]; then
|
|
27
|
+
status=asking
|
|
28
|
+
note=$(hp_ask_note "$payload") # first question's text → the note (best-effort)
|
|
29
|
+
else
|
|
30
|
+
status=active
|
|
31
|
+
note=
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
# cheap path: already active with nothing stale to clear → no-op (the common every-tool case).
|
|
35
|
+
[ "$status" = active ] && [ "$(jget status)" = active ] && [ -z "$(jget proposal)" ] && [ -z "$(jget note)" ] && exit 0
|
|
36
|
+
|
|
37
|
+
# value-replace status + clear proposal + (re)set note, in place. The record is written one-field-per-line
|
|
38
|
+
# with these keys ALWAYS present (sessions.ts writeRecord), so each is a single value substitution — no key
|
|
39
|
+
# add/remove, no JSON parser. Escape \ / & in the note for the sed REPLACEMENT (the note never contains ").
|
|
40
|
+
note_esc=$(printf '%s' "$note" | sed 's/[\\/&]/\\&/g')
|
|
41
|
+
tmp=$(mktemp) || exit 0
|
|
42
|
+
sed -e "s/\(\"status\"[[:space:]]*:[[:space:]]*\)\"[^\"]*\"/\1\"$status\"/" \
|
|
43
|
+
-e "s/\(\"proposal\"[[:space:]]*:[[:space:]]*\)\"[^\"]*\"/\1\"\"/" \
|
|
44
|
+
-e "s/\(\"note\"[[:space:]]*:[[:space:]]*\)\"[^\"]*\"/\1\"$note_esc\"/" \
|
|
45
|
+
"$rec" > "$tmp" && mv "$tmp" "$rec"
|
|
46
|
+
exit 0
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# @@@ dispatch - the SINGLE hook entry point for ALL harness lifecycle events. The shim (.claude/settings.json
|
|
3
|
+
# / .codex/hooks.json, written by each [[harness-adapter]]) binds one line per event to
|
|
4
|
+
# `dispatch.sh <harness> <Event>` — the harness id is BAKED IN by the adapter that wrote the shim, so this is
|
|
5
|
+
# the deterministic harness DETECTOR for the shell side: we export SPEXCODE_HARNESS (read by harness.sh, the
|
|
6
|
+
# adapter's shell mirror, which the hook handlers source) without ever sniffing the payload shape. ONE job:
|
|
7
|
+
# DISPATCH — run every handler bound to <Event> from the persistent manifest, in order, feeding each the
|
|
8
|
+
# ORIGINAL stdin. Reproduces the native parallel multi-hook contract DETERMINISTICALLY: all handlers run
|
|
9
|
+
# (side effects preserved), their stdout (decision/additionalContext) is concatenated through, and a
|
|
10
|
+
# block:true handler that exits 2 makes the dispatch exit 2 with that handler's stderr — the one signal
|
|
11
|
+
# the harness propagates. Pure bash, no node boot on the hot path. cwd = the project/worktree. $SPEX (abs
|
|
12
|
+
# tsx+cli) is inherited from the shim env.
|
|
13
|
+
#
|
|
14
|
+
# The old (1) GATE — an auto-materialize when the config content-hash moved — is RETIRED ([[commit-surgery]]):
|
|
15
|
+
# a harness event is never a materialize trigger; the materialize anchors are git-native only (spex verbs,
|
|
16
|
+
# session-worktree creation, and the pre-commit/post-checkout/post-merge hooks). .plugins edits are
|
|
17
|
+
# git-transactional: they take effect at the commit/checkout/merge that carries them, like any other source.
|
|
18
|
+
set -u
|
|
19
|
+
# args: `<harness> <Event>`. A harness id as $1 (claude|codex|opencode|pi|zcode|plugin) is consumed; otherwise we keep
|
|
20
|
+
# $1 as the event and default the harness to claude — so a stale shim still written as `dispatch.sh <Event>`
|
|
21
|
+
# keeps working. `plugin` is the bundle form ([[plugin-harness]]), `opencode` the generated event-bus plugin
|
|
22
|
+
# ([[opencode-harness]]), `pi` the generated extension ([[pi-harness]]), and `zcode` the native adapter: all four
|
|
23
|
+
# carry Claude-shaped payloads (Claude tool names + file_path), so they join the claude branch in harness.sh via
|
|
24
|
+
# the default case — no parse arm of their own.
|
|
25
|
+
harness=claude
|
|
26
|
+
case "${1:-}" in claude|codex|opencode|pi|zcode|plugin) harness="$1"; shift ;; esac
|
|
27
|
+
event="${1:?usage: dispatch.sh <harness> <Event>}"
|
|
28
|
+
export SPEXCODE_HARNESS="$harness"
|
|
29
|
+
# the harness.sh path (the adapter's shell mirror) — sibling of this script; hook handlers source it, and we
|
|
30
|
+
# source it here too for hp_runtime_dir (the per-project store dir).
|
|
31
|
+
hook_root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
32
|
+
tool_root="$(cd "$hook_root/.." && pwd)"
|
|
33
|
+
export SPEXCODE_HARNESS_LIB="$hook_root/harness.sh"
|
|
34
|
+
. "$SPEXCODE_HARNESS_LIB"
|
|
35
|
+
if [ -n "${SPEX:-}" ]; then
|
|
36
|
+
read -r -a spex_cmd <<< "$SPEX"
|
|
37
|
+
else
|
|
38
|
+
spex_cmd=("$tool_root/bin/spex.mjs")
|
|
39
|
+
fi
|
|
40
|
+
proj="${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
41
|
+
# the manifest lives in THIS tree's materialize slot of the GLOBAL per-project store (mirrors layout.treeSlotDir),
|
|
42
|
+
# NOT the worktree — and per tree, so a dispatch can only read the manifest of the tree it fires in
|
|
43
|
+
# ([[hook-dispatch]]). Slot key = this cwd's rev-parse --show-toplevel through hp_tree_dir. Empty if git
|
|
44
|
+
# can't resolve.
|
|
45
|
+
rt="$(cd "$proj" 2>/dev/null && hp_runtime_dir)" || rt=""
|
|
46
|
+
slot="$(cd "$proj" 2>/dev/null && hp_tree_dir)" || slot=""
|
|
47
|
+
|
|
48
|
+
# A project transport can outlive the tree that installed it. The current tree's last successful materialize
|
|
49
|
+
# is the authority for whether its events are active. Before the v1 marker, an absent allowlist is the legacy
|
|
50
|
+
# shape; afterwards absence means this tree never successfully selected a harness and dispatch stays inert.
|
|
51
|
+
allowed="$slot/harnesses"
|
|
52
|
+
if [ -f "$allowed" ]; then
|
|
53
|
+
grep -Fxq "$harness" "$allowed" || exit 0
|
|
54
|
+
elif [ -f "$rt/harness-selection-v1" ]; then
|
|
55
|
+
exit 0
|
|
56
|
+
fi
|
|
57
|
+
|
|
58
|
+
# --- dispatch ---------------------------------------------------------------------------------------------
|
|
59
|
+
if [ -n "${SPEX_HOOK_MANIFEST:-}" ]; then
|
|
60
|
+
manifest="$SPEX_HOOK_MANIFEST"
|
|
61
|
+
else
|
|
62
|
+
# migration window: a tree last materialized by a pre-slot toolchain has no slot until its next git-native
|
|
63
|
+
# anchor — fall back to the legacy global manifest (its exact pre-migration behavior) so no hook (the
|
|
64
|
+
# Stop gate included) silently no-ops. The legacy file is never written again; the next anchor plants the
|
|
65
|
+
# slot and this branch goes dead.
|
|
66
|
+
manifest="$slot/hooks-manifest"
|
|
67
|
+
[ -f "$manifest" ] || manifest="$rt/hooks-manifest"
|
|
68
|
+
fi
|
|
69
|
+
[ -f "$manifest" ] || exit 0 # no manifest yet (materialize never ran) → nothing to dispatch
|
|
70
|
+
input="$(cat 2>/dev/null || true)" # capture stdin ONCE; each handler gets its own copy
|
|
71
|
+
err="/tmp/.spex-hook-$$.err" # per-dispatch (pid-unique) stderr capture; no cross-session race
|
|
72
|
+
cleanup() { rm -f "$err"; }
|
|
73
|
+
trap cleanup EXIT
|
|
74
|
+
trap 'exit 130' INT
|
|
75
|
+
trap 'exit 143' HUP TERM
|
|
76
|
+
rc=0
|
|
77
|
+
# manifest line: event<TAB>order<TAB>block<TAB>script (pre-sorted by event,order,script)
|
|
78
|
+
while IFS=$'\t' read -r ev order block script; do
|
|
79
|
+
[ "$ev" = "$event" ] || continue
|
|
80
|
+
handler="$proj/$script"
|
|
81
|
+
# A seeded core hook is tracked project source, so package replacement cannot safely overwrite it. These
|
|
82
|
+
# byte-exact default revisions compose an ask note into JSON with sed; route only them to the package
|
|
83
|
+
# implementation. `cmp` makes a user-modified hook ineligible without a platform-specific hash utility.
|
|
84
|
+
if [ "$script" = '.spec/project/.plugins/core/mark-active/mark-active.sh' ] &&
|
|
85
|
+
{ cmp -s "$handler" "$hook_root/compat/mark-active-sed-v0.fixture" ||
|
|
86
|
+
cmp -s "$handler" "$hook_root/compat/mark-active-0.5.2-eef1.fixture"; }; then
|
|
87
|
+
handler="$tool_root/templates/spec/project/.plugins/core/mark-active/mark-active.sh"
|
|
88
|
+
fi
|
|
89
|
+
out="$(printf '%s' "$input" | bash "$handler" 2>"$err")"; code=$?
|
|
90
|
+
[ -n "$out" ] && printf '%s' "$out"
|
|
91
|
+
if [ "$block" = "true" ] && { [ "$code" = "2" ] || printf '%s' "$out" | grep -q '"decision"[[:space:]]*:[[:space:]]*"block"'; }; then
|
|
92
|
+
cat "$err" >&2
|
|
93
|
+
# codex reads a Stop block's continuation prompt from STDERR (+ exit 2), NOT the claude-style
|
|
94
|
+
# decision:block JSON a handler writes to stdout. So when we block on the JSON path under codex and the
|
|
95
|
+
# handler left stderr empty, extract its "reason" and forward it to stderr — else codex sees exit 2 with
|
|
96
|
+
# no stderr ("Stop hook exited with code 2 but did not write a continuation prompt"). Claude is unchanged
|
|
97
|
+
# (it keeps reading the stdout JSON). The reason is the JSON's last field, so capture to the final `"}`.
|
|
98
|
+
if [ "$SPEXCODE_HARNESS" = codex ] && [ ! -s "$err" ]; then
|
|
99
|
+
printf '%s' "$out" | sed -n 's/.*"reason"[[:space:]]*:[[:space:]]*"\(.*\)"[[:space:]]*}[[:space:]]*$/\1/p' \
|
|
100
|
+
| sed 's/\\"/"/g; s/\\\\/\\/g' >&2
|
|
101
|
+
fi
|
|
102
|
+
rc=2
|
|
103
|
+
fi
|
|
104
|
+
done < "$manifest"
|
|
105
|
+
exit "$rc"
|