@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.
Files changed (201) hide show
  1. package/README.md +95 -0
  2. package/bin/spex.mjs +76 -0
  3. package/dist/attach.d.ts +2 -0
  4. package/dist/attach.js +48 -0
  5. package/dist/claude-headless.d.ts +33 -0
  6. package/dist/claude-headless.js +307 -0
  7. package/dist/cli.d.ts +1 -0
  8. package/dist/cli.js +1859 -0
  9. package/dist/client.d.ts +111 -0
  10. package/dist/client.js +476 -0
  11. package/dist/cockpit.d.ts +7 -0
  12. package/dist/cockpit.js +26 -0
  13. package/dist/codex-headless.d.ts +1 -0
  14. package/dist/codex-headless.js +7 -0
  15. package/dist/codex-runtime-generations.d.ts +53 -0
  16. package/dist/codex-runtime-generations.js +686 -0
  17. package/dist/commit-surgery.d.ts +1 -0
  18. package/dist/commit-surgery.js +90 -0
  19. package/dist/contract-filter.d.ts +14 -0
  20. package/dist/contract-filter.js +219 -0
  21. package/dist/dashboard-assets.d.ts +6 -0
  22. package/dist/dashboard-assets.js +50 -0
  23. package/dist/delivery-queue.d.ts +23 -0
  24. package/dist/delivery-queue.js +179 -0
  25. package/dist/doctor.d.ts +9 -0
  26. package/dist/doctor.js +547 -0
  27. package/dist/eval-host.d.ts +1 -0
  28. package/dist/eval-host.js +19 -0
  29. package/dist/execution-trace.d.ts +26 -0
  30. package/dist/execution-trace.js +475 -0
  31. package/dist/file-write.d.ts +2 -0
  32. package/dist/file-write.js +26 -0
  33. package/dist/flat.d.ts +89 -0
  34. package/dist/flat.js +1076 -0
  35. package/dist/gateway-auth.d.ts +56 -0
  36. package/dist/gateway-auth.js +168 -0
  37. package/dist/gateway-hub.d.ts +28 -0
  38. package/dist/gateway-hub.js +361 -0
  39. package/dist/gateway.d.ts +37 -0
  40. package/dist/gateway.js +503 -0
  41. package/dist/graphCache.d.ts +27 -0
  42. package/dist/graphCache.js +716 -0
  43. package/dist/graphSnapshot.d.ts +14 -0
  44. package/dist/graphSnapshot.js +35 -0
  45. package/dist/graphStream.d.ts +85 -0
  46. package/dist/graphStream.js +1195 -0
  47. package/dist/guidance-catalog.d.ts +64 -0
  48. package/dist/guidance-catalog.js +166 -0
  49. package/dist/guide.d.ts +8 -0
  50. package/dist/guide.js +684 -0
  51. package/dist/harness-select.d.ts +18 -0
  52. package/dist/harness-select.js +62 -0
  53. package/dist/harness.d.ts +325 -0
  54. package/dist/harness.js +3026 -0
  55. package/dist/headless-controller.d.ts +9 -0
  56. package/dist/headless-controller.js +47 -0
  57. package/dist/help.d.ts +10 -0
  58. package/dist/help.js +574 -0
  59. package/dist/hook-prompts.d.ts +18 -0
  60. package/dist/hook-prompts.js +122 -0
  61. package/dist/hooks.d.ts +1 -0
  62. package/dist/hooks.js +27 -0
  63. package/dist/host-resources.d.ts +97 -0
  64. package/dist/host-resources.js +795 -0
  65. package/dist/host.d.ts +88 -0
  66. package/dist/host.js +663 -0
  67. package/dist/index.d.ts +1 -0
  68. package/dist/index.js +899 -0
  69. package/dist/init.d.ts +1 -0
  70. package/dist/init.js +284 -0
  71. package/dist/issues-cli.d.ts +6 -0
  72. package/dist/issues-cli.js +334 -0
  73. package/dist/issues.d.ts +69 -0
  74. package/dist/issues.js +189 -0
  75. package/dist/lint.d.ts +34 -0
  76. package/dist/lint.js +476 -0
  77. package/dist/listen.d.ts +7 -0
  78. package/dist/listen.js +24 -0
  79. package/dist/localIssues.d.ts +65 -0
  80. package/dist/localIssues.js +582 -0
  81. package/dist/login-page.d.ts +5 -0
  82. package/dist/login-page.js +86 -0
  83. package/dist/loop-in.d.ts +23 -0
  84. package/dist/loop-in.js +68 -0
  85. package/dist/machine-peer.d.ts +69 -0
  86. package/dist/machine-peer.js +603 -0
  87. package/dist/materialize.d.ts +18 -0
  88. package/dist/materialize.js +468 -0
  89. package/dist/mentions.d.ts +50 -0
  90. package/dist/mentions.js +117 -0
  91. package/dist/opencode-headless.d.ts +4 -0
  92. package/dist/opencode-headless.js +208 -0
  93. package/dist/opencode.d.ts +3 -0
  94. package/dist/opencode.js +142 -0
  95. package/dist/pi-harness.d.ts +3 -0
  96. package/dist/pi-harness.js +129 -0
  97. package/dist/pi-headless.d.ts +25 -0
  98. package/dist/pi-headless.js +196 -0
  99. package/dist/plugin-harness.d.ts +21 -0
  100. package/dist/plugin-harness.js +146 -0
  101. package/dist/pty-bridge.d.ts +10 -0
  102. package/dist/pty-bridge.js +404 -0
  103. package/dist/pty-helper.d.mts +1 -0
  104. package/dist/pty-helper.mjs +101 -0
  105. package/dist/pty-native-helper.d.mts +2 -0
  106. package/dist/pty-native-helper.mjs +23 -0
  107. package/dist/public-graph.d.ts +46 -0
  108. package/dist/public-graph.js +56 -0
  109. package/dist/ranker.d.ts +15 -0
  110. package/dist/ranker.js +188 -0
  111. package/dist/reaper.d.ts +6 -0
  112. package/dist/reaper.js +81 -0
  113. package/dist/reviews.d.ts +189 -0
  114. package/dist/reviews.js +350 -0
  115. package/dist/runtime-guard.d.ts +6 -0
  116. package/dist/runtime-guard.js +37 -0
  117. package/dist/runtime-ownership.d.ts +21 -0
  118. package/dist/runtime-ownership.js +84 -0
  119. package/dist/search.bench.d.mts +1 -0
  120. package/dist/search.bench.mjs +75 -0
  121. package/dist/search.d.ts +20 -0
  122. package/dist/search.js +61 -0
  123. package/dist/session-cursors.d.ts +14 -0
  124. package/dist/session-cursors.js +82 -0
  125. package/dist/session-declarations.d.ts +13 -0
  126. package/dist/session-declarations.js +126 -0
  127. package/dist/session-execution.d.ts +10 -0
  128. package/dist/session-execution.js +70 -0
  129. package/dist/session-files.d.ts +28 -0
  130. package/dist/session-files.js +130 -0
  131. package/dist/session-follow.d.ts +31 -0
  132. package/dist/session-follow.js +179 -0
  133. package/dist/session-reparent.d.ts +10 -0
  134. package/dist/session-reparent.js +33 -0
  135. package/dist/session-timeline.d.ts +51 -0
  136. package/dist/session-timeline.js +233 -0
  137. package/dist/session-web.d.ts +24 -0
  138. package/dist/session-web.js +141 -0
  139. package/dist/sessions.d.ts +412 -0
  140. package/dist/sessions.js +4239 -0
  141. package/dist/sh.d.ts +1 -0
  142. package/dist/sh.js +3 -0
  143. package/dist/shim-runtime.d.ts +1 -0
  144. package/dist/shim-runtime.js +161 -0
  145. package/dist/slash-commands.d.ts +9 -0
  146. package/dist/slash-commands.js +307 -0
  147. package/dist/source-files.d.ts +9 -0
  148. package/dist/source-files.js +113 -0
  149. package/dist/supervise.d.ts +1 -0
  150. package/dist/supervise.js +308 -0
  151. package/dist/tree.d.ts +25 -0
  152. package/dist/tree.js +98 -0
  153. package/dist/tsx-bin.d.ts +3 -0
  154. package/dist/tsx-bin.js +41 -0
  155. package/dist/uninstall.d.ts +3 -0
  156. package/dist/uninstall.js +177 -0
  157. package/dist/uploads.d.ts +36 -0
  158. package/dist/uploads.js +243 -0
  159. package/dist/worktree-sources.d.ts +1 -0
  160. package/dist/worktree-sources.js +47 -0
  161. package/hooks/compat/mark-active-0.5.2-eef1.fixture +53 -0
  162. package/hooks/compat/mark-active-sed-v0.fixture +46 -0
  163. package/hooks/dispatch.sh +105 -0
  164. package/hooks/harness.sh +272 -0
  165. package/package.json +47 -0
  166. package/templates/hooks/commit-msg +14 -0
  167. package/templates/hooks/post-checkout +25 -0
  168. package/templates/hooks/post-merge +34 -0
  169. package/templates/hooks/pre-commit +120 -0
  170. package/templates/hooks/prepare-commit-msg +35 -0
  171. package/templates/hooks/reference-transaction +105 -0
  172. package/templates/spec/project/.plugins/commands/extract/spec.md +66 -0
  173. package/templates/spec/project/.plugins/commands/regroup/spec.md +25 -0
  174. package/templates/spec/project/.plugins/commands/rename/spec.md +11 -0
  175. package/templates/spec/project/.plugins/commands/spec.md +30 -0
  176. package/templates/spec/project/.plugins/commands/supervisor/spec.md +8 -0
  177. package/templates/spec/project/.plugins/commands/tidy/spec.md +43 -0
  178. package/templates/spec/project/.plugins/core/comment-altitude/spec.md +9 -0
  179. package/templates/spec/project/.plugins/core/idle/idle.sh +15 -0
  180. package/templates/spec/project/.plugins/core/idle/spec.md +13 -0
  181. package/templates/spec/project/.plugins/core/mark-active/mark-active.sh +59 -0
  182. package/templates/spec/project/.plugins/core/mark-active/spec.md +22 -0
  183. package/templates/spec/project/.plugins/core/session-fail/fail.sh +12 -0
  184. package/templates/spec/project/.plugins/core/session-fail/spec.md +15 -0
  185. package/templates/spec/project/.plugins/core/spec-first/spec-first.sh +43 -0
  186. package/templates/spec/project/.plugins/core/spec-first/spec.md +17 -0
  187. package/templates/spec/project/.plugins/core/spec-of-file/spec-of-file.sh +66 -0
  188. package/templates/spec/project/.plugins/core/spec-of-file/spec.md +15 -0
  189. package/templates/spec/project/.plugins/core/spec.md +22 -0
  190. package/templates/spec/project/.plugins/core/stop-gate/spec.md +23 -0
  191. package/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +149 -0
  192. package/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +15 -0
  193. package/templates/spec/project/.plugins/prompts/forge-link/spec.md +10 -0
  194. package/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +13 -0
  195. package/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +12 -0
  196. package/templates/spec/project/.plugins/prompts/spec.md +24 -0
  197. package/templates/spec/project/.plugins/skills/distill/digest.mjs +136 -0
  198. package/templates/spec/project/.plugins/skills/distill/spec.md +67 -0
  199. package/templates/spec/project/.plugins/skills/spec.md +19 -0
  200. package/templates/spec/project/.plugins/spec.md +23 -0
  201. 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 {};
@@ -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"