specpi 0.10.0
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/CHANGELOG.md +150 -0
- package/LICENSE +21 -0
- package/NPM_RELEASE.md +110 -0
- package/README.md +155 -0
- package/SECURITY.md +85 -0
- package/SECURITY_MODEL.md +107 -0
- package/THIRD_PARTY.md +61 -0
- package/browser-runtime/package-lock.json +86 -0
- package/browser-runtime/package.json +15 -0
- package/extensions/browser/core.mjs +306 -0
- package/extensions/browser/index.ts +723 -0
- package/extensions/browser/smoke.mjs +47 -0
- package/extensions/command-guard/bash.mjs +1426 -0
- package/extensions/command-guard/cmd.mjs +369 -0
- package/extensions/command-guard/core.mjs +506 -0
- package/extensions/command-guard/index.ts +634 -0
- package/extensions/command-guard/managed-files.mjs +22 -0
- package/extensions/command-guard/paths.mjs +398 -0
- package/extensions/command-guard/powershell-parser.ps1 +47 -0
- package/extensions/command-guard/powershell.mjs +655 -0
- package/extensions/command-guard/redact.mjs +65 -0
- package/extensions/command-guard/rules.mjs +2557 -0
- package/extensions/command-guard/smoke.mjs +422 -0
- package/extensions/files/core.mjs +422 -0
- package/extensions/files/index.ts +678 -0
- package/extensions/spec/core.mjs +47 -0
- package/extensions/spec.ts +457 -0
- package/extensions/tool-wishlist/capabilities.json +114 -0
- package/extensions/tool-wishlist/core.mjs +1525 -0
- package/extensions/tool-wishlist/index.ts +804 -0
- package/extensions/tool-wishlist/registry.mjs +99 -0
- package/extensions/tool-wishlist/validators.mjs +345 -0
- package/extensions/ui-refresh/index.ts +54 -0
- package/extensions/workflow-controls/challenge.mjs +196 -0
- package/extensions/workflow-controls/experiments.mjs +628 -0
- package/extensions/workflow-controls/index.ts +1144 -0
- package/extensions/workflow-controls/scope.mjs +272 -0
- package/extensions/workflow-controls/smoke.mjs +201 -0
- package/package.json +98 -0
- package/scripts/check-package.mjs +483 -0
- package/scripts/check-pi-package.mjs +223 -0
- package/scripts/check-release-order.mjs +97 -0
- package/scripts/lib.mjs +182 -0
- package/scripts/lock.mjs +122 -0
- package/scripts/specpi.mjs +2037 -0
- package/scripts/verify-artifact.mjs +21 -0
- package/shell/pi-profiles.sh +14 -0
- package/site/logo.svg +9 -0
- package/site/self-improvement-loop-v2.svg +108 -0
- package/skills/donsetch/SKILL.md +76 -0
- package/skills/specpi-improve/SKILL.md +54 -0
- package/specpi +4 -0
- package/specpi.cmd +4 -0
- package/templates/AGENTS.md +23 -0
- package/templates/settings.json +10 -0
- package/themes/specpi-spec.json +96 -0
- package/themes/tea-house.json +89 -0
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { parsePorcelainEntries, parsePorcelainZ, sanitizePathLabel } from "./scope.mjs";
|
|
5
|
+
|
|
6
|
+
const REGISTRY_SCHEMA = 1;
|
|
7
|
+
const MAX_EXPERIMENTS = 32;
|
|
8
|
+
const MAX_TEXT = 600;
|
|
9
|
+
const MAX_IGNORED_PATHS = 200;
|
|
10
|
+
const MAX_PATCH_BYTES = 32_000_000;
|
|
11
|
+
|
|
12
|
+
function compact(value, maximum = MAX_TEXT) {
|
|
13
|
+
return String(value ?? "")
|
|
14
|
+
.normalize("NFKC")
|
|
15
|
+
.replace(/[\u0000-\u001f\u007f]+/gu, " ")
|
|
16
|
+
.replace(/\s+/gu, " ")
|
|
17
|
+
.trim()
|
|
18
|
+
.slice(0, maximum);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function experimentStateRoot(stateDir) {
|
|
22
|
+
return path.join(path.resolve(stateDir), "experiments");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function registryPath(stateDir) {
|
|
26
|
+
return path.join(experimentStateRoot(stateDir), "registry.json");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function lockPath(stateDir) {
|
|
30
|
+
return path.join(experimentStateRoot(stateDir), "registry.lock");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function ensurePrivateDirectory(directory) {
|
|
34
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
35
|
+
const stat = fs.lstatSync(directory);
|
|
36
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
37
|
+
throw new Error("Private experiment state directory must not be a symbolic link");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
fs.chmodSync(directory, 0o700);
|
|
42
|
+
} catch {
|
|
43
|
+
/* Windows permissions are inherited from the profile. */
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function prepareDestination(file, privateParent) {
|
|
48
|
+
const parent = path.dirname(file);
|
|
49
|
+
if (privateParent) {
|
|
50
|
+
ensurePrivateDirectory(parent);
|
|
51
|
+
if (fs.existsSync(file) && fs.lstatSync(file).isSymbolicLink()) {
|
|
52
|
+
throw new Error("Private experiment state file must not be a symbolic link");
|
|
53
|
+
}
|
|
54
|
+
} else {
|
|
55
|
+
const stat = fs.statSync(parent);
|
|
56
|
+
if (!stat.isDirectory()) {
|
|
57
|
+
throw new Error("Patch output parent must be an existing directory");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (fs.existsSync(file) && fs.lstatSync(file).isSymbolicLink()) {
|
|
61
|
+
throw new Error("Refusing to replace a symbolic-link patch output");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return parent;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function applyMode(file, mode) {
|
|
69
|
+
try {
|
|
70
|
+
fs.chmodSync(file, mode);
|
|
71
|
+
} catch {
|
|
72
|
+
/* Windows permissions are inherited from the profile. */
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function atomicWrite(file, bytes, mode = 0o600, privateParent = true) {
|
|
77
|
+
const parent = prepareDestination(file, privateParent);
|
|
78
|
+
const temporary = path.join(parent, `.${path.basename(file)}.${randomUUID()}.tmp`);
|
|
79
|
+
fs.writeFileSync(temporary, bytes, { mode, flag: "wx" });
|
|
80
|
+
fs.renameSync(temporary, file);
|
|
81
|
+
applyMode(file, mode);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Patch bytes are never decoded into a JavaScript string: a text file that is not valid UTF-8 would lose its original
|
|
85
|
+
// bytes on the way back out and produce a patch that no longer applies. Git writes the file itself and we only move it.
|
|
86
|
+
function atomicAdopt(source, file, { mode = 0o600, privateParent = true, overwrite = false } = {}) {
|
|
87
|
+
prepareDestination(file, privateParent);
|
|
88
|
+
applyMode(source, mode);
|
|
89
|
+
// Without approval to overwrite, the destination has to be claimed exclusively rather than checked and then
|
|
90
|
+
// replaced: `rename` silently clobbers whatever appeared in the gap, while `link` fails closed with EEXIST.
|
|
91
|
+
if (!overwrite) {
|
|
92
|
+
try {
|
|
93
|
+
fs.linkSync(source, file);
|
|
94
|
+
fs.rmSync(source, { force: true });
|
|
95
|
+
applyMode(file, mode);
|
|
96
|
+
|
|
97
|
+
return;
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (error?.code === "EEXIST") {
|
|
100
|
+
throw new Error("Patch output appeared before it could be written; explicit overwrite is required");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (error?.code !== "EXDEV" && error?.code !== "EPERM" && error?.code !== "ENOSYS") {
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Hard links are unavailable across volumes and on some filesystems; COPYFILE_EXCL keeps the same guarantee.
|
|
109
|
+
fs.copyFileSync(source, file, fs.constants.COPYFILE_EXCL);
|
|
110
|
+
fs.rmSync(source, { force: true });
|
|
111
|
+
applyMode(file, mode);
|
|
112
|
+
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
fs.renameSync(source, file);
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (error?.code !== "EXDEV") {
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const parent = path.dirname(file);
|
|
124
|
+
const temporary = path.join(parent, `.${path.basename(file)}.${randomUUID()}.tmp`);
|
|
125
|
+
fs.copyFileSync(source, temporary);
|
|
126
|
+
applyMode(temporary, mode);
|
|
127
|
+
fs.renameSync(temporary, file);
|
|
128
|
+
fs.rmSync(source, { force: true });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
applyMode(file, mode);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function validCommit(value) {
|
|
135
|
+
return typeof value === "string" && /^[0-9a-f]{40,64}$/u.test(value);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function validateRecord(record) {
|
|
139
|
+
if (
|
|
140
|
+
!record ||
|
|
141
|
+
typeof record !== "object" ||
|
|
142
|
+
typeof record.id !== "string" ||
|
|
143
|
+
!/^[0-9a-f-]{36}$/u.test(record.id) ||
|
|
144
|
+
typeof record.name !== "string" ||
|
|
145
|
+
!["prepared", "active", "closing"].includes(record.status) ||
|
|
146
|
+
typeof record.repoRoot !== "string" ||
|
|
147
|
+
!path.isAbsolute(record.repoRoot) ||
|
|
148
|
+
typeof record.commonDir !== "string" ||
|
|
149
|
+
!path.isAbsolute(record.commonDir) ||
|
|
150
|
+
typeof record.worktreePath !== "string" ||
|
|
151
|
+
!path.isAbsolute(record.worktreePath) ||
|
|
152
|
+
!validCommit(record.baseCommit) ||
|
|
153
|
+
typeof record.hypothesis !== "string" ||
|
|
154
|
+
typeof record.acceptance !== "string" ||
|
|
155
|
+
!Array.isArray(record.nonGoals) ||
|
|
156
|
+
record.nonGoals.some((item) => typeof item !== "string") ||
|
|
157
|
+
typeof record.createdAt !== "string" ||
|
|
158
|
+
!Number.isFinite(Date.parse(record.createdAt)) ||
|
|
159
|
+
typeof record.updatedAt !== "string" ||
|
|
160
|
+
!Number.isFinite(Date.parse(record.updatedAt))
|
|
161
|
+
) {
|
|
162
|
+
throw new Error(`Malformed experiment registry record: ${record?.id ?? "unknown"}`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return record;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function validateExperimentRegistry(value) {
|
|
169
|
+
if (!value || value.schema !== REGISTRY_SCHEMA || !Array.isArray(value.experiments)) {
|
|
170
|
+
throw new Error("Experiment registry schema is invalid");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (value.experiments.length > MAX_EXPERIMENTS) {
|
|
174
|
+
throw new Error(`Experiment registry exceeds ${MAX_EXPERIMENTS} entries`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const ids = new Set();
|
|
178
|
+
const worktrees = new Set();
|
|
179
|
+
for (const record of value.experiments) {
|
|
180
|
+
validateRecord(record);
|
|
181
|
+
const worktree = process.platform === "win32" ? record.worktreePath.toLowerCase() : record.worktreePath;
|
|
182
|
+
if (ids.has(record.id) || worktrees.has(worktree)) {
|
|
183
|
+
throw new Error("Experiment registry contains duplicate identities");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
ids.add(record.id);
|
|
187
|
+
worktrees.add(worktree);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return value;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function readExperimentRegistry(stateDir) {
|
|
194
|
+
const file = registryPath(stateDir);
|
|
195
|
+
if (!fs.existsSync(file)) {
|
|
196
|
+
return { schema: REGISTRY_SCHEMA, experiments: [] };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return validateExperimentRegistry(JSON.parse(fs.readFileSync(file, "utf8")));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function withRegistryLock(stateDir, operation) {
|
|
203
|
+
const root = experimentStateRoot(stateDir);
|
|
204
|
+
ensurePrivateDirectory(root);
|
|
205
|
+
const lock = lockPath(stateDir);
|
|
206
|
+
const owner = { pid: process.pid, token: randomUUID(), createdAt: new Date().toISOString() };
|
|
207
|
+
try {
|
|
208
|
+
fs.mkdirSync(lock, { mode: 0o700 });
|
|
209
|
+
fs.writeFileSync(path.join(lock, "owner.json"), `${JSON.stringify(owner)}\n`, { mode: 0o600, flag: "wx" });
|
|
210
|
+
} catch (error) {
|
|
211
|
+
if (error?.code === "EEXIST") {
|
|
212
|
+
throw new Error("Experiment registry is locked; inspect the owner before manual recovery");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
throw error;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
return await operation();
|
|
220
|
+
} finally {
|
|
221
|
+
let current;
|
|
222
|
+
try {
|
|
223
|
+
current = JSON.parse(fs.readFileSync(path.join(lock, "owner.json"), "utf8"));
|
|
224
|
+
} catch {
|
|
225
|
+
current = undefined;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (current?.token === owner.token) {
|
|
229
|
+
fs.rmSync(lock, { recursive: true, force: true });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function updateRegistry(stateDir, mutate) {
|
|
235
|
+
return withRegistryLock(stateDir, async () => {
|
|
236
|
+
const registry = readExperimentRegistry(stateDir);
|
|
237
|
+
const result = await mutate(registry);
|
|
238
|
+
validateExperimentRegistry(registry);
|
|
239
|
+
atomicWrite(registryPath(stateDir), `${JSON.stringify(registry, null, 2)}\n`);
|
|
240
|
+
|
|
241
|
+
return result;
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function git(exec, cwd, args, options = {}) {
|
|
246
|
+
const result = await exec("git", args, { cwd, timeout: options.timeout ?? 30_000, env: options.env });
|
|
247
|
+
if (!result || result.code !== 0) {
|
|
248
|
+
const detail = compact(result?.stderr || result?.stdout || "Git command failed", 300);
|
|
249
|
+
throw new Error(detail || "Git command failed");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (
|
|
253
|
+
typeof result.stdout !== "string" ||
|
|
254
|
+
Buffer.byteLength(result.stdout, "utf8") > (options.maxBytes ?? 16_000_000)
|
|
255
|
+
) {
|
|
256
|
+
throw new Error("Git output is missing or exceeds the safety bound");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return result.stdout;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export async function inspectRepository(exec, cwd) {
|
|
263
|
+
const rootOutput = await git(exec, cwd, ["rev-parse", "--show-toplevel"]);
|
|
264
|
+
const repoRoot = fs.realpathSync.native(path.resolve(cwd, rootOutput.trim()));
|
|
265
|
+
const commonOutput = await git(exec, repoRoot, ["rev-parse", "--git-common-dir"]);
|
|
266
|
+
const commonCandidate = path.resolve(repoRoot, commonOutput.trim());
|
|
267
|
+
const commonDir = fs.realpathSync.native(commonCandidate);
|
|
268
|
+
const baseCommit = (await git(exec, repoRoot, ["rev-parse", "HEAD"])).trim();
|
|
269
|
+
if (!validCommit(baseCommit)) {
|
|
270
|
+
throw new Error("Git returned an invalid HEAD commit");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const statusOutput = await git(exec, repoRoot, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]);
|
|
274
|
+
const changedPaths = parsePorcelainZ(statusOutput);
|
|
275
|
+
|
|
276
|
+
return { repoRoot, commonDir, baseCommit, changedPaths };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function sanitizeExperimentCard(card) {
|
|
280
|
+
const name = compact(card?.name, 48)
|
|
281
|
+
.toLowerCase()
|
|
282
|
+
.replace(/[^a-z0-9._-]+/gu, "-")
|
|
283
|
+
.replace(/^-+|-+$/gu, "");
|
|
284
|
+
const hypothesis = compact(card?.hypothesis);
|
|
285
|
+
const acceptance = compact(card?.acceptance);
|
|
286
|
+
const nonGoals = Array.isArray(card?.nonGoals)
|
|
287
|
+
? card.nonGoals
|
|
288
|
+
.map((item) => compact(item, 240))
|
|
289
|
+
.filter(Boolean)
|
|
290
|
+
.slice(0, 8)
|
|
291
|
+
: [];
|
|
292
|
+
if (!name || !hypothesis || !acceptance) {
|
|
293
|
+
throw new Error("Experiment name, hypothesis, and acceptance check are required");
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return { name, hypothesis, acceptance, nonGoals };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export async function createExperiment({ exec, stateDir, repository, card, now = new Date().toISOString() }) {
|
|
300
|
+
const sanitized = sanitizeExperimentCard(card);
|
|
301
|
+
const id = randomUUID();
|
|
302
|
+
const root = experimentStateRoot(stateDir);
|
|
303
|
+
const worktreesRoot = path.join(root, "worktrees");
|
|
304
|
+
ensurePrivateDirectory(worktreesRoot);
|
|
305
|
+
const worktreePath = path.join(worktreesRoot, `${sanitized.name}-${id.slice(0, 8)}`);
|
|
306
|
+
const record = {
|
|
307
|
+
id,
|
|
308
|
+
...sanitized,
|
|
309
|
+
status: "prepared",
|
|
310
|
+
repoRoot: repository.repoRoot,
|
|
311
|
+
commonDir: repository.commonDir,
|
|
312
|
+
worktreePath,
|
|
313
|
+
baseCommit: repository.baseCommit,
|
|
314
|
+
createdAt: now,
|
|
315
|
+
updatedAt: now,
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
await updateRegistry(stateDir, (registry) => {
|
|
319
|
+
if (registry.experiments.length >= MAX_EXPERIMENTS) {
|
|
320
|
+
throw new Error(`At most ${MAX_EXPERIMENTS} experiments may be retained`);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
registry.experiments.push(record);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
try {
|
|
327
|
+
await git(exec, repository.repoRoot, ["worktree", "add", "--detach", worktreePath, repository.baseCommit], {
|
|
328
|
+
timeout: 120_000,
|
|
329
|
+
});
|
|
330
|
+
const canonicalWorktree = fs.realpathSync.native(worktreePath);
|
|
331
|
+
await updateRegistry(stateDir, (registry) => {
|
|
332
|
+
const current = registry.experiments.find((item) => item.id === id);
|
|
333
|
+
if (!current || current.status !== "prepared") {
|
|
334
|
+
throw new Error("Experiment transaction changed during worktree creation");
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
current.worktreePath = canonicalWorktree;
|
|
338
|
+
current.status = "active";
|
|
339
|
+
current.updatedAt = new Date().toISOString();
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
return readExperimentRegistry(stateDir).experiments.find((item) => item.id === id);
|
|
343
|
+
} catch (error) {
|
|
344
|
+
throw new Error(`Experiment ${id.slice(0, 8)} remains prepared for /experiment recover: ${error.message}`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export function findExperiment(stateDir, query, cwd) {
|
|
349
|
+
const records = readExperimentRegistry(stateDir).experiments;
|
|
350
|
+
const needle = compact(query, 64).toLowerCase();
|
|
351
|
+
let matches;
|
|
352
|
+
if (needle) {
|
|
353
|
+
matches = records.filter(
|
|
354
|
+
(record) => record.id.toLowerCase().startsWith(needle) || record.name.toLowerCase() === needle,
|
|
355
|
+
);
|
|
356
|
+
} else if (cwd) {
|
|
357
|
+
let canonical;
|
|
358
|
+
try {
|
|
359
|
+
canonical = fs.realpathSync.native(path.resolve(cwd));
|
|
360
|
+
} catch {
|
|
361
|
+
canonical = path.resolve(cwd);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const comparable = process.platform === "win32" ? canonical.toLowerCase() : canonical;
|
|
365
|
+
matches = records.filter((record) => {
|
|
366
|
+
const candidate = process.platform === "win32" ? record.worktreePath.toLowerCase() : record.worktreePath;
|
|
367
|
+
|
|
368
|
+
return candidate === comparable;
|
|
369
|
+
});
|
|
370
|
+
} else {
|
|
371
|
+
matches = records;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (matches.length !== 1) {
|
|
375
|
+
throw new Error(matches.length === 0 ? "No matching experiment" : "Experiment ID is ambiguous");
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return matches[0];
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export async function experimentStatus(exec, record) {
|
|
382
|
+
// Committing inside an experiment moves its HEAD, and everything measured against HEAD then reports clean. The
|
|
383
|
+
// recorded base commit is the only fixed point, so committed work stays visible to status, export, and discard.
|
|
384
|
+
let headCommit;
|
|
385
|
+
let committedPaths = [];
|
|
386
|
+
let committedUnknown = false;
|
|
387
|
+
try {
|
|
388
|
+
headCommit = (await git(exec, record.worktreePath, ["rev-parse", "HEAD"])).trim();
|
|
389
|
+
if (headCommit !== record.baseCommit) {
|
|
390
|
+
const names = await git(exec, record.worktreePath, [
|
|
391
|
+
"diff",
|
|
392
|
+
"--name-only",
|
|
393
|
+
"-z",
|
|
394
|
+
record.baseCommit,
|
|
395
|
+
"HEAD",
|
|
396
|
+
]);
|
|
397
|
+
committedPaths = [...new Set(names.split("\0").filter(Boolean).map(sanitizePathLabel))].sort();
|
|
398
|
+
}
|
|
399
|
+
} catch {
|
|
400
|
+
// A missing or unreachable base object must not block closing the experiment; assume work may exist.
|
|
401
|
+
committedUnknown = true;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Ignored files are invisible to both a plain `status` and `git add -A`, so a worktree holding only ignored work
|
|
405
|
+
// looks clean, exports to an empty patch, and would be destroyed by discard without a second confirmation.
|
|
406
|
+
const output = await git(exec, record.worktreePath, [
|
|
407
|
+
"status",
|
|
408
|
+
"--porcelain=v1",
|
|
409
|
+
"-z",
|
|
410
|
+
"--untracked-files=all",
|
|
411
|
+
"--ignored=matching",
|
|
412
|
+
]);
|
|
413
|
+
const entries = parsePorcelainEntries(output);
|
|
414
|
+
const changedPaths = [
|
|
415
|
+
...new Set(entries.filter((entry) => entry.status !== "!!").map((entry) => entry.path)),
|
|
416
|
+
].sort();
|
|
417
|
+
const untracked = entries.filter((entry) => entry.status === "??").length;
|
|
418
|
+
const ignoredPaths = [...new Set(entries.filter((entry) => entry.status === "!!").map((entry) => entry.path))]
|
|
419
|
+
.sort()
|
|
420
|
+
.slice(0, MAX_IGNORED_PATHS);
|
|
421
|
+
|
|
422
|
+
return {
|
|
423
|
+
...record,
|
|
424
|
+
changedPaths,
|
|
425
|
+
untracked,
|
|
426
|
+
ignoredPaths,
|
|
427
|
+
ignored: ignoredPaths.length,
|
|
428
|
+
headCommit,
|
|
429
|
+
committedPaths,
|
|
430
|
+
committed: committedPaths.length,
|
|
431
|
+
committedUnknown,
|
|
432
|
+
// Anything that would be destroyed by a discard, whether or not a patch could carry it.
|
|
433
|
+
hasWork: changedPaths.length > 0 || ignoredPaths.length > 0 || committedPaths.length > 0 || committedUnknown,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export async function exportExperimentPatch({ exec, stateDir, record, outputPath, overwrite = false }) {
|
|
438
|
+
const root = experimentStateRoot(stateDir);
|
|
439
|
+
ensurePrivateDirectory(path.join(root, "exports"));
|
|
440
|
+
const destination = path.resolve(
|
|
441
|
+
outputPath || path.join(root, "exports", `${record.name}-${record.id.slice(0, 8)}.patch`),
|
|
442
|
+
);
|
|
443
|
+
if (fs.existsSync(destination) && !overwrite) {
|
|
444
|
+
throw new Error("Patch output already exists; explicit overwrite approval is required");
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
ensurePrivateDirectory(path.join(root, "tmp"));
|
|
448
|
+
const temporaryIndex = path.join(root, "tmp", `${record.id}-${randomUUID()}.index`);
|
|
449
|
+
const temporaryPatch = path.join(root, "tmp", `${record.id}-${randomUUID()}.patch`);
|
|
450
|
+
const environment = { ...process.env, GIT_INDEX_FILE: temporaryIndex };
|
|
451
|
+
try {
|
|
452
|
+
// Seeding the temporary index from the base commit and staging the working tree makes the diff cover every
|
|
453
|
+
// change since the experiment started, whether the human committed it or left it dirty.
|
|
454
|
+
await git(exec, record.worktreePath, ["read-tree", record.baseCommit], { env: environment });
|
|
455
|
+
await git(exec, record.worktreePath, ["add", "-A"], { env: environment, timeout: 120_000 });
|
|
456
|
+
await git(
|
|
457
|
+
exec,
|
|
458
|
+
record.worktreePath,
|
|
459
|
+
["diff", "--cached", "--binary", `--output=${temporaryPatch}`, record.baseCommit],
|
|
460
|
+
{ env: environment, timeout: 120_000 },
|
|
461
|
+
);
|
|
462
|
+
const bytes = fs.statSync(temporaryPatch).size;
|
|
463
|
+
if (bytes > MAX_PATCH_BYTES) {
|
|
464
|
+
throw new Error(`Exported patch exceeds the ${MAX_PATCH_BYTES}-byte safety bound`);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
atomicAdopt(temporaryPatch, destination, { mode: 0o600, privateParent: false, overwrite });
|
|
468
|
+
|
|
469
|
+
return { outputPath: destination, bytes };
|
|
470
|
+
} finally {
|
|
471
|
+
fs.rmSync(temporaryIndex, { force: true });
|
|
472
|
+
fs.rmSync(`${temporaryIndex}.lock`, { force: true });
|
|
473
|
+
fs.rmSync(temporaryPatch, { force: true });
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async function verifyWorktreeIdentity(exec, record) {
|
|
478
|
+
const canonical = fs.realpathSync.native(record.worktreePath);
|
|
479
|
+
if (canonical !== record.worktreePath) {
|
|
480
|
+
throw new Error("Registered worktree path identity changed");
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const commonOutput = await git(exec, canonical, ["rev-parse", "--git-common-dir"]);
|
|
484
|
+
const commonDir = fs.realpathSync.native(path.resolve(canonical, commonOutput.trim()));
|
|
485
|
+
if (commonDir !== record.commonDir) {
|
|
486
|
+
throw new Error("Registered worktree belongs to a different repository");
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
export async function discardExperiment({ exec, stateDir, record }) {
|
|
491
|
+
await verifyWorktreeIdentity(exec, record);
|
|
492
|
+
await updateRegistry(stateDir, (registry) => {
|
|
493
|
+
const current = registry.experiments.find((item) => item.id === record.id);
|
|
494
|
+
if (!current || current.status !== "active" || current.worktreePath !== record.worktreePath) {
|
|
495
|
+
throw new Error("Experiment changed before discard");
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
current.status = "closing";
|
|
499
|
+
current.updatedAt = new Date().toISOString();
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
await verifyWorktreeIdentity(exec, record);
|
|
503
|
+
await git(exec, record.repoRoot, ["worktree", "remove", "--force", record.worktreePath], { timeout: 120_000 });
|
|
504
|
+
await updateRegistry(stateDir, (registry) => {
|
|
505
|
+
registry.experiments = registry.experiments.filter((item) => item.id !== record.id);
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
export function parseWorktreeList(output) {
|
|
510
|
+
if (typeof output !== "string") {
|
|
511
|
+
throw new Error("Git worktree list is unavailable");
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const records = [];
|
|
515
|
+
for (const block of output.trim().split(/\n\n+/u)) {
|
|
516
|
+
if (!block.trim()) {
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const fields = Object.fromEntries(
|
|
521
|
+
block.split("\n").map((line) => {
|
|
522
|
+
const separator = line.indexOf(" ");
|
|
523
|
+
|
|
524
|
+
return separator < 0 ? [line, true] : [line.slice(0, separator), line.slice(separator + 1)];
|
|
525
|
+
}),
|
|
526
|
+
);
|
|
527
|
+
if (typeof fields.worktree !== "string") {
|
|
528
|
+
throw new Error("Malformed Git worktree list");
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
records.push(fields);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
return records;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function comparablePath(value) {
|
|
538
|
+
return process.platform === "win32" ? value.toLowerCase() : value;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
async function registeredWorktreePaths(exec, repoRoot) {
|
|
542
|
+
const listed = parseWorktreeList(await git(exec, repoRoot, ["worktree", "list", "--porcelain"]));
|
|
543
|
+
|
|
544
|
+
return new Set(listed.map((record) => comparablePath(path.resolve(record.worktree))));
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
export async function recoverExperiments({ exec, stateDir, repoRoot }) {
|
|
548
|
+
const paths = await registeredWorktreePaths(exec, repoRoot);
|
|
549
|
+
const known = readExperimentRegistry(stateDir).experiments.filter((record) => record.repoRoot === repoRoot);
|
|
550
|
+
|
|
551
|
+
return known.map((record) => {
|
|
552
|
+
const present = paths.has(comparablePath(record.worktreePath));
|
|
553
|
+
|
|
554
|
+
return {
|
|
555
|
+
record,
|
|
556
|
+
present,
|
|
557
|
+
// A directory that Git no longer tracks as a worktree is an orphan left by an interrupted creation. It can
|
|
558
|
+
// never be activated, so recovery has to offer releasing the record while leaving the files for the human.
|
|
559
|
+
orphanDirectory: !present && fs.existsSync(record.worktreePath),
|
|
560
|
+
needsRecovery: record.status !== "active" || !present,
|
|
561
|
+
};
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
export async function repairExperimentRecord(stateDir, id, action, { exec, repoRoot } = {}) {
|
|
566
|
+
if (typeof exec !== "function" || typeof repoRoot !== "string" || !path.isAbsolute(repoRoot)) {
|
|
567
|
+
throw new Error("Experiment recovery requires repository access");
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return updateRegistry(stateDir, async (registry) => {
|
|
571
|
+
const record = registry.experiments.find((item) => item.id === id);
|
|
572
|
+
if (!record) {
|
|
573
|
+
throw new Error("Experiment record no longer exists");
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
if (record.repoRoot !== repoRoot) {
|
|
577
|
+
throw new Error("Experiment record belongs to a different repository");
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// The choice that selected this action was made before an interactive prompt, and an external `git worktree`
|
|
581
|
+
// command during that prompt would invalidate it. Presence is re-derived here, inside the registry lock and
|
|
582
|
+
// immediately before the mutation, so a stale answer cannot drop a live record or adopt a replaced directory.
|
|
583
|
+
const present = (await registeredWorktreePaths(exec, repoRoot)).has(comparablePath(record.worktreePath));
|
|
584
|
+
const exists = fs.existsSync(record.worktreePath);
|
|
585
|
+
|
|
586
|
+
if (action === "activate") {
|
|
587
|
+
if (!present || !exists) {
|
|
588
|
+
throw new Error("Worktree is no longer registered with Git; run /experiment recover again");
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const canonical = fs.realpathSync.native(record.worktreePath);
|
|
592
|
+
const commonOutput = await git(exec, canonical, ["rev-parse", "--git-common-dir"]);
|
|
593
|
+
if (fs.realpathSync.native(path.resolve(canonical, commonOutput.trim())) !== record.commonDir) {
|
|
594
|
+
throw new Error("Registered worktree belongs to a different repository");
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
record.worktreePath = canonical;
|
|
598
|
+
record.status = "active";
|
|
599
|
+
record.updatedAt = new Date().toISOString();
|
|
600
|
+
|
|
601
|
+
return {};
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (action === "forget" || action === "release") {
|
|
605
|
+
if (present) {
|
|
606
|
+
throw new Error("Refusing to drop a record Git still tracks as a worktree");
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (action === "forget" && exists) {
|
|
610
|
+
throw new Error("Refusing to forget an existing directory; release the record instead");
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (action === "release" && !exists) {
|
|
614
|
+
throw new Error("Nothing to release; forget the record instead");
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
registry.experiments = registry.experiments.filter((item) => item.id !== id);
|
|
618
|
+
|
|
619
|
+
return { released: action === "release" ? record.worktreePath : undefined };
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
throw new Error("Unsupported recovery action");
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
export function defaultPatchPath(stateDir, record) {
|
|
627
|
+
return path.join(experimentStateRoot(stateDir), "exports", `${record.name}-${record.id.slice(0, 8)}.patch`);
|
|
628
|
+
}
|