@open-agent-toolkit/cli 0.2.55 → 0.2.57

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 (52) hide show
  1. package/assets/agents/oat-phase-implementer.md +123 -9
  2. package/assets/bundle-metadata.json +1 -1
  3. package/assets/docs/cli-utilities/configuration.md +12 -2
  4. package/assets/docs/docs-tooling/commands.md +36 -5
  5. package/assets/docs/reference/oat-directory-structure.md +56 -4
  6. package/assets/docs/workflows/projects/implementation-execution.md +5 -1
  7. package/assets/docs/workflows/skills/explainer-kit.md +8 -5
  8. package/assets/public-package-versions.json +4 -4
  9. package/assets/skills/analyze/SKILL.md +18 -17
  10. package/assets/skills/oat-brainstorm/SKILL.md +2 -2
  11. package/assets/skills/oat-doctor/SKILL.md +49 -36
  12. package/assets/skills/oat-idea-summarize/SKILL.md +2 -2
  13. package/assets/skills/oat-project-autonomous/SKILL.md +17 -10
  14. package/assets/skills/oat-project-autonomous/references/gate-inventory.md +3 -3
  15. package/assets/skills/oat-project-complete/SKILL.md +18 -8
  16. package/assets/skills/oat-project-discover/SKILL.md +3 -3
  17. package/assets/skills/oat-project-document/references/docs/autonomy-contract.md +3 -3
  18. package/assets/skills/oat-project-implement/SKILL.md +1 -1
  19. package/assets/skills/oat-project-implement/references/completion-and-closeout.md +12 -3
  20. package/assets/skills/oat-project-implement/references/docs/autonomy-contract.md +3 -3
  21. package/assets/skills/oat-project-implement/references/phase-execution.md +218 -4
  22. package/assets/skills/oat-project-implement/references/plan-and-resume.md +5 -2
  23. package/assets/skills/oat-project-implement/scripts/capture-dirty-tree.mjs +1069 -0
  24. package/assets/skills/oat-project-import-plan/SKILL.md +12 -6
  25. package/assets/skills/oat-project-next/SKILL.md +7 -5
  26. package/assets/skills/oat-project-plan/SKILL.md +24 -12
  27. package/assets/skills/oat-project-pr-final/SKILL.md +2 -2
  28. package/assets/skills/oat-project-pr-final/references/docs/autonomy-contract.md +3 -3
  29. package/assets/skills/oat-project-quick-start/SKILL.md +11 -6
  30. package/assets/skills/oat-project-quick-start/references/docs/autonomy-contract.md +3 -3
  31. package/assets/skills/oat-project-retro/SKILL.md +4 -2
  32. package/assets/skills/oat-project-review-receive/SKILL.md +9 -5
  33. package/assets/skills/oat-project-revise/SKILL.md +6 -6
  34. package/dist/commands/config/index.d.ts +1 -0
  35. package/dist/commands/config/index.d.ts.map +1 -1
  36. package/dist/commands/config/index.js +51 -4
  37. package/dist/commands/docs/index-generate/generator.d.ts +43 -1
  38. package/dist/commands/docs/index-generate/generator.d.ts.map +1 -1
  39. package/dist/commands/docs/index-generate/generator.js +169 -3
  40. package/dist/commands/docs/index-generate/index.d.ts +8 -3
  41. package/dist/commands/docs/index-generate/index.d.ts.map +1 -1
  42. package/dist/commands/docs/index-generate/index.js +340 -24
  43. package/dist/commands/docs/init/scaffold.d.ts +11 -1
  44. package/dist/commands/docs/init/scaffold.d.ts.map +1 -1
  45. package/dist/commands/docs/init/scaffold.js +11 -2
  46. package/dist/config/oat-config.d.ts +14 -0
  47. package/dist/config/oat-config.d.ts.map +1 -1
  48. package/dist/config/oat-config.js +61 -0
  49. package/dist/fs/assets.d.ts +33 -4
  50. package/dist/fs/assets.d.ts.map +1 -1
  51. package/dist/fs/assets.js +127 -12
  52. package/package.json +2 -2
@@ -0,0 +1,1069 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Fail-closed capture of a lost child's uncommitted work.
4
+ *
5
+ * The root lifecycle runs this before it restores a phase worktree to its clean
6
+ * base, so that a fresh same-target continuation can apply, review, and commit
7
+ * the recovered work as its first action. Every failure mode is a hard stop
8
+ * with a named reason; nothing here performs a best-effort restore, and the
9
+ * script never writes to the worktree it captures.
10
+ */
11
+ import { execFile as execFileCallback } from 'node:child_process';
12
+ import { createHash } from 'node:crypto';
13
+ import { realpathSync } from 'node:fs';
14
+ import {
15
+ chmod,
16
+ copyFile,
17
+ lstat,
18
+ mkdir,
19
+ mkdtemp,
20
+ readdir,
21
+ readFile,
22
+ realpath,
23
+ rm,
24
+ stat,
25
+ writeFile,
26
+ } from 'node:fs/promises';
27
+ import { tmpdir } from 'node:os';
28
+ import { dirname, join, relative, resolve, sep } from 'node:path';
29
+ import { setTimeout as delay } from 'node:timers/promises';
30
+ import { fileURLToPath } from 'node:url';
31
+ import { promisify } from 'node:util';
32
+
33
+ const execFile = promisify(execFileCallback);
34
+
35
+ const NUL = '\u0000';
36
+ const MAX_GIT_BUFFER = 256 * 1024 * 1024;
37
+ const DEFAULT_QUIESCE_INTERVAL_MS = 750;
38
+ const MIN_QUIESCE_INTERVAL_MS = 25;
39
+ const PROOF_FILE = 'round-trip.proof';
40
+ const MANIFEST_FILE = 'manifest.json';
41
+
42
+ /** Named stop reasons. Callers report these verbatim; none is recoverable. */
43
+ export const CAPTURE_REASONS = Object.freeze({
44
+ activeWriter: 'active-writer',
45
+ unsupportedDirt: 'unsupported-dirt',
46
+ roundTripFailed: 'round-trip-failed',
47
+ artifactVerificationFailed: 'artifact-verification-failed',
48
+ invalidUsage: 'invalid-usage',
49
+ });
50
+
51
+ export const EXIT_CODES = Object.freeze({
52
+ 'active-writer': 2,
53
+ 'unsupported-dirt': 3,
54
+ 'round-trip-failed': 4,
55
+ 'artifact-verification-failed': 5,
56
+ 'invalid-usage': 64,
57
+ });
58
+
59
+ const RENAME_DETECTION_CONFIG = Object.freeze([
60
+ 'status.renames=true',
61
+ 'diff.renames=true',
62
+ ]);
63
+
64
+ const IN_PROGRESS_MARKERS = Object.freeze([
65
+ 'MERGE_HEAD',
66
+ 'CHERRY_PICK_HEAD',
67
+ 'REVERT_HEAD',
68
+ 'rebase-merge',
69
+ 'rebase-apply',
70
+ ]);
71
+
72
+ function captureError(reason, message, details = {}) {
73
+ const error = new Error(message);
74
+ error.code = 'E_CAPTURE_DIRTY_TREE';
75
+ error.reason = reason;
76
+ Object.assign(error, details);
77
+ return error;
78
+ }
79
+
80
+ async function git(
81
+ cwd,
82
+ args,
83
+ { binary = false, literalPathspecs = false, config = [] } = {},
84
+ ) {
85
+ // `--literal-pathspecs` exports GIT_LITERAL_PATHSPECS=1 to anything Git
86
+ // spawns, including repository hooks, so it is scoped to the one call that
87
+ // feeds status-derived filenames back to Git as a pathspec.
88
+ const { stdout } = await execFile(
89
+ 'git',
90
+ [
91
+ '--no-optional-locks',
92
+ ...(literalPathspecs ? ['--literal-pathspecs'] : []),
93
+ ...config.flatMap((setting) => ['-c', setting]),
94
+ ...args,
95
+ ],
96
+ {
97
+ cwd,
98
+ encoding: binary ? 'buffer' : 'utf8',
99
+ maxBuffer: MAX_GIT_BUFFER,
100
+ },
101
+ );
102
+ return stdout;
103
+ }
104
+
105
+ async function pathExists(path) {
106
+ try {
107
+ await stat(path);
108
+ return true;
109
+ } catch {
110
+ return false;
111
+ }
112
+ }
113
+
114
+ function sha256(bytes) {
115
+ return createHash('sha256').update(bytes).digest('hex');
116
+ }
117
+
118
+ /**
119
+ * Parse `git status --porcelain=v2 -z`. A `2` (rename/copy) record is followed
120
+ * by a second NUL-terminated field holding the original path, so the reader
121
+ * consumes that extra token rather than treating it as its own record. A path
122
+ * Git emits in bytes that are not valid UTF-8 cannot be recorded or restored
123
+ * losslessly here, so it is refused instead of silently mangled.
124
+ */
125
+ export function parsePorcelainStatus(buffer) {
126
+ const decoded = buffer.toString('utf8');
127
+ if (!Buffer.from(decoded, 'utf8').equals(buffer)) {
128
+ throw captureError(
129
+ CAPTURE_REASONS.unsupportedDirt,
130
+ 'Refusing to capture: the worktree holds a path that is not valid UTF-8, which cannot be recorded or restored losslessly.',
131
+ );
132
+ }
133
+
134
+ const tokens = decoded.split(NUL);
135
+ const entries = [];
136
+
137
+ for (let index = 0; index < tokens.length; index += 1) {
138
+ const record = tokens[index];
139
+ if (!record) continue;
140
+ const fields = record.split(' ');
141
+
142
+ if (record.startsWith('1 ')) {
143
+ entries.push({
144
+ kind: 'ordinary',
145
+ xy: fields[1],
146
+ sub: fields[2],
147
+ path: fields.slice(8).join(' '),
148
+ });
149
+ } else if (record.startsWith('2 ')) {
150
+ entries.push({
151
+ kind: 'rename',
152
+ xy: fields[1],
153
+ sub: fields[2],
154
+ path: fields.slice(9).join(' '),
155
+ origPath: tokens[index + 1] ?? '',
156
+ });
157
+ index += 1;
158
+ } else if (record.startsWith('u ')) {
159
+ entries.push({
160
+ kind: 'unmerged',
161
+ xy: fields[1],
162
+ sub: fields[2],
163
+ path: fields.slice(10).join(' '),
164
+ });
165
+ } else if (record.startsWith('? ')) {
166
+ entries.push({ kind: 'untracked', path: record.slice(2) });
167
+ } else if (record.startsWith('! ')) {
168
+ entries.push({ kind: 'ignored', path: record.slice(2) });
169
+ }
170
+ }
171
+
172
+ return entries;
173
+ }
174
+
175
+ /**
176
+ * Rename detection is forced on rather than inherited. With `status.renames`
177
+ * disabled a staged rename is reported as an ordinary delete plus an ordinary
178
+ * add, so whether the dirt is classified `unsupported-dirt` would otherwise
179
+ * depend on the repository's configuration rather than on the tree.
180
+ */
181
+ async function statusBytes(worktree) {
182
+ return await git(
183
+ worktree,
184
+ ['status', '--porcelain=v2', '-z', '--untracked-files=all'],
185
+ { binary: true, config: RENAME_DETECTION_CONFIG },
186
+ );
187
+ }
188
+
189
+ /**
190
+ * Content digest plus the one permission bit Git tracks. Comparing the full
191
+ * mode would produce false mismatches from umask differences between the
192
+ * captured worktree and a fresh checkout; the executable bit is the part Git
193
+ * actually carries, so it is the part the round trip must reproduce.
194
+ */
195
+ async function fingerprintPath(worktree, relativePath) {
196
+ const absolute = join(worktree, relativePath);
197
+ try {
198
+ const entry = await lstat(absolute);
199
+ const executable = (entry.mode & 0o111) === 0 ? '-' : 'x';
200
+ const digest = sha256(await readFile(absolute));
201
+ return `${relativePath} | ${digest} | ${executable}`;
202
+ } catch {
203
+ return `${relativePath} | absent | -`;
204
+ }
205
+ }
206
+
207
+ /**
208
+ * A worktree snapshot is `HEAD`, the porcelain status, and a fingerprint for
209
+ * every path the status names. Porcelain v2 carries HEAD and index object ids
210
+ * but no worktree object id, so status alone cannot see a writer that keeps
211
+ * appending to an already-modified file, and status plus digests alone cannot
212
+ * see a writer that modifies a clean path and then commits it. Together they
213
+ * can.
214
+ */
215
+ async function snapshotWorktree(worktree) {
216
+ const head = (await git(worktree, ['rev-parse', 'HEAD'])).trim();
217
+ const status = await statusBytes(worktree);
218
+ const entries = parsePorcelainStatus(status);
219
+ const paths = new Set();
220
+ for (const entry of entries) {
221
+ if (entry.kind === 'ignored') continue;
222
+ paths.add(entry.path);
223
+ if (entry.origPath) paths.add(entry.origPath);
224
+ }
225
+
226
+ const fingerprints = [];
227
+ for (const path of [...paths].sort()) {
228
+ fingerprints.push(await fingerprintPath(worktree, path));
229
+ }
230
+
231
+ return {
232
+ head,
233
+ status,
234
+ entries,
235
+ fingerprints: fingerprints.join('\n'),
236
+ };
237
+ }
238
+
239
+ function snapshotsMatch(first, second) {
240
+ return (
241
+ first.head === second.head &&
242
+ first.status.equals(second.status) &&
243
+ first.fingerprints === second.fingerprints
244
+ );
245
+ }
246
+
247
+ async function assertWriterQuiescent({ worktree, writerIdentity, intervalMs }) {
248
+ if (!writerIdentity) {
249
+ throw captureError(
250
+ CAPTURE_REASONS.activeWriter,
251
+ 'Refusing to capture: the former child writer identity was not supplied, so quiescence cannot be established.',
252
+ );
253
+ }
254
+
255
+ const pidMatch = /^pid:(\d+)$/.exec(writerIdentity);
256
+ if (pidMatch) {
257
+ let alive = false;
258
+ try {
259
+ process.kill(Number(pidMatch[1]), 0);
260
+ alive = true;
261
+ } catch {
262
+ alive = false;
263
+ }
264
+ if (alive) {
265
+ throw captureError(
266
+ CAPTURE_REASONS.activeWriter,
267
+ `Refusing to capture: former child process ${pidMatch[1]} is still running.`,
268
+ );
269
+ }
270
+ }
271
+
272
+ if (!Number.isFinite(intervalMs) || intervalMs < MIN_QUIESCE_INTERVAL_MS) {
273
+ throw captureError(
274
+ CAPTURE_REASONS.invalidUsage,
275
+ `The quiescence interval must be a finite value of at least ${MIN_QUIESCE_INTERVAL_MS}ms.`,
276
+ );
277
+ }
278
+
279
+ const first = await snapshotWorktree(worktree);
280
+ await delay(intervalMs);
281
+ const second = await snapshotWorktree(worktree);
282
+ if (!snapshotsMatch(first, second)) {
283
+ throw captureError(
284
+ CAPTURE_REASONS.activeWriter,
285
+ 'Refusing to capture: the worktree changed between two quiescence snapshots, so a writer is still active.',
286
+ );
287
+ }
288
+
289
+ return second;
290
+ }
291
+
292
+ async function assertNoInProgressOperation(worktree) {
293
+ const gitDir = resolve(
294
+ worktree,
295
+ (await git(worktree, ['rev-parse', '--git-dir'])).trim(),
296
+ );
297
+ for (const marker of IN_PROGRESS_MARKERS) {
298
+ if (await pathExists(join(gitDir, marker))) {
299
+ throw captureError(
300
+ CAPTURE_REASONS.unsupportedDirt,
301
+ `Refusing to capture: an in-progress ${marker} operation is not a supported dirt class.`,
302
+ { marker },
303
+ );
304
+ }
305
+ }
306
+ }
307
+
308
+ function withinBounds(path, boundedFiles) {
309
+ return boundedFiles.some(
310
+ (bound) => path === bound || path.startsWith(`${bound}/`),
311
+ );
312
+ }
313
+
314
+ function classifyDirt(entries, boundedFiles) {
315
+ const tracked = [];
316
+ const untracked = [];
317
+
318
+ for (const entry of entries) {
319
+ if (entry.kind === 'ignored') continue;
320
+
321
+ if (entry.kind === 'unmerged') {
322
+ throw captureError(
323
+ CAPTURE_REASONS.unsupportedDirt,
324
+ `Refusing to capture: unmerged path ${entry.path} is not a supported dirt class.`,
325
+ );
326
+ }
327
+
328
+ // A staged rename has no per-path restore: unstaging leaves the destination
329
+ // untracked and the source missing from the index, so the caller-owned
330
+ // restore below could not return the tree to its clean base.
331
+ if (entry.kind === 'rename') {
332
+ throw captureError(
333
+ CAPTURE_REASONS.unsupportedDirt,
334
+ `Refusing to capture: staged rename or copy ${entry.origPath} -> ${entry.path} is not a supported dirt class.`,
335
+ );
336
+ }
337
+
338
+ if (entry.kind === 'untracked') {
339
+ untracked.push(entry.path);
340
+ continue;
341
+ }
342
+
343
+ if (typeof entry.sub === 'string' && !entry.sub.startsWith('N')) {
344
+ throw captureError(
345
+ CAPTURE_REASONS.unsupportedDirt,
346
+ `Refusing to capture: submodule change at ${entry.path} is not a supported dirt class.`,
347
+ );
348
+ }
349
+
350
+ tracked.push(entry.path);
351
+ }
352
+
353
+ const overlap = tracked.filter((path) => untracked.includes(path));
354
+ if (overlap.length > 0) {
355
+ // One path cannot be both restored from HEAD and deleted as untracked; a
356
+ // staged deletion whose worktree file was recreated has no single action.
357
+ throw captureError(
358
+ CAPTURE_REASONS.unsupportedDirt,
359
+ `Refusing to capture: ${overlap.join(', ')} is both a tracked change and an untracked file, which has no single restore action.`,
360
+ );
361
+ }
362
+
363
+ const affectedPaths = [...new Set([...tracked, ...untracked])].sort();
364
+ const outOfBounds = affectedPaths.filter(
365
+ (path) => !withinBounds(path, boundedFiles),
366
+ );
367
+ if (outOfBounds.length > 0) {
368
+ throw captureError(
369
+ CAPTURE_REASONS.unsupportedDirt,
370
+ `Refusing to capture: ${outOfBounds.length} path(s) fall outside the phase bounded_files: ${outOfBounds.join(', ')}.`,
371
+ { outOfBounds },
372
+ );
373
+ }
374
+
375
+ return {
376
+ trackedPaths: [...new Set(tracked)].sort(),
377
+ untrackedPaths: [...new Set(untracked)].sort(),
378
+ affectedPaths,
379
+ };
380
+ }
381
+
382
+ /**
383
+ * The caller-owned restore, derived rather than guessed. A tracked path that
384
+ * exists in `HEAD` is reset from `HEAD`; a path staged as a new file has no
385
+ * `HEAD` content to restore, so it is unstaged and removed instead.
386
+ */
387
+ async function buildRestorePlan(worktree, trackedPaths, untrackedPaths) {
388
+ const inHead = new Set();
389
+ if (trackedPaths.length > 0) {
390
+ const listed = await git(
391
+ worktree,
392
+ ['ls-tree', '-r', '-z', '--name-only', 'HEAD', '--', ...trackedPaths],
393
+ { literalPathspecs: true },
394
+ );
395
+ for (const path of listed.split(NUL)) {
396
+ if (path) inHead.add(path);
397
+ }
398
+ }
399
+
400
+ return [
401
+ ...trackedPaths.map((path) => ({
402
+ path,
403
+ action: inHead.has(path) ? 'restore-from-head' : 'unstage-and-remove',
404
+ })),
405
+ ...untrackedPaths.map((path) => ({ path, action: 'remove-untracked' })),
406
+ ];
407
+ }
408
+
409
+ async function writeArtifactFile(
410
+ artifactDir,
411
+ relativePath,
412
+ bytes,
413
+ files,
414
+ { executable = false } = {},
415
+ ) {
416
+ const target = join(artifactDir, relativePath);
417
+ await mkdir(dirname(target), { recursive: true });
418
+ await writeFile(target, bytes, { flag: 'wx' });
419
+ if (executable) await chmod(target, 0o755);
420
+ files.push({
421
+ path: relativePath,
422
+ sha256: sha256(bytes),
423
+ bytes: bytes.length,
424
+ executable,
425
+ });
426
+ }
427
+
428
+ async function applyArtifact(artifactDir, manifest, targetWorktree) {
429
+ const indexPatch = manifest.files.find((file) => file.path === 'index.patch');
430
+ const worktreePatch = manifest.files.find(
431
+ (file) => file.path === 'worktree.patch',
432
+ );
433
+
434
+ if (indexPatch && indexPatch.bytes > 0) {
435
+ await git(targetWorktree, [
436
+ 'apply',
437
+ '--index',
438
+ '--whitespace=nowarn',
439
+ join(artifactDir, 'index.patch'),
440
+ ]);
441
+ }
442
+ if (worktreePatch && worktreePatch.bytes > 0) {
443
+ await git(targetWorktree, [
444
+ 'apply',
445
+ '--whitespace=nowarn',
446
+ join(artifactDir, 'worktree.patch'),
447
+ ]);
448
+ }
449
+ for (const path of manifest.untrackedPaths) {
450
+ const mirrored = join('untracked', path);
451
+ const target = join(targetWorktree, path);
452
+ await mkdir(dirname(target), { recursive: true });
453
+ await copyFile(join(artifactDir, mirrored), target);
454
+ // Restore the executable bit from the manifest, not from whatever mode the
455
+ // mirrored copy happens to carry on disk; only the manifest is digest
456
+ // anchored.
457
+ const recorded = manifest.files.find((file) => file.path === mirrored);
458
+ if (recorded) await chmod(target, recorded.executable ? 0o755 : 0o644);
459
+ }
460
+ }
461
+
462
+ /**
463
+ * Prove the artifact as written to disk replays into a pristine replica of the
464
+ * captured `HEAD` and reproduces the captured worktree exactly. Any mismatch is
465
+ * `round-trip-failed`; the captured worktree is never written to.
466
+ *
467
+ * The seal is written here and nowhere else, and the digest and size a capture
468
+ * hands to the continuation are the ones this function returns. A capture that
469
+ * skipped this proof would therefore have no reference to publish and no seal
470
+ * for `--verify` to accept.
471
+ */
472
+ export async function proveArtifactRoundTrip({ worktree, artifactDir }) {
473
+ const worktreeRoot = resolve(worktree);
474
+ const artifactRoot = resolve(artifactDir);
475
+ let manifestBytes;
476
+ let manifest;
477
+ try {
478
+ manifestBytes = await readFile(join(artifactRoot, MANIFEST_FILE));
479
+ manifest = JSON.parse(manifestBytes.toString('utf8'));
480
+ } catch (error) {
481
+ throw captureError(
482
+ CAPTURE_REASONS.roundTripFailed,
483
+ `Refusing to hand over the artifact: its manifest could not be read (${error.message}).`,
484
+ );
485
+ }
486
+
487
+ const snapshot = await snapshotWorktree(worktreeRoot);
488
+ await replayInto({
489
+ worktree: worktreeRoot,
490
+ artifactDir: artifactRoot,
491
+ manifest,
492
+ snapshot,
493
+ });
494
+
495
+ const manifestDigest = sha256(manifestBytes);
496
+ const size = (manifest.files ?? []).reduce(
497
+ (sum, file) => sum + file.bytes,
498
+ manifestBytes.length,
499
+ );
500
+ // Not exclusive: re-proving an artifact is legitimate and must be able to
501
+ // refresh the seal. The artifact directory itself is created exclusively, so
502
+ // there is never a foreign seal to clobber.
503
+ const proof = { manifestDigest, size, provenAt: new Date().toISOString() };
504
+ await writeFile(
505
+ join(artifactRoot, PROOF_FILE),
506
+ `${JSON.stringify(proof, null, 2)}\n`,
507
+ );
508
+
509
+ return {
510
+ ok: true,
511
+ head: manifest.head,
512
+ affectedPaths: manifest.affectedPaths ?? [],
513
+ manifestDigest,
514
+ size,
515
+ };
516
+ }
517
+
518
+ async function replayInto({ worktree, artifactDir, manifest, snapshot }) {
519
+ const scratch = await mkdtemp(join(tmpdir(), 'oat-capture-roundtrip-'));
520
+ const replica = join(scratch, 'replica');
521
+ let added = false;
522
+ let primary = null;
523
+ let cleanupError = null;
524
+
525
+ try {
526
+ await git(worktree, [
527
+ 'worktree',
528
+ 'add',
529
+ '--detach',
530
+ replica,
531
+ manifest.head,
532
+ ]);
533
+ added = true;
534
+ await applyArtifact(artifactDir, manifest, replica);
535
+
536
+ const replicaSnapshot = await snapshotWorktree(replica);
537
+ if (!snapshotsMatch(snapshot, replicaSnapshot)) {
538
+ throw captureError(
539
+ CAPTURE_REASONS.roundTripFailed,
540
+ 'Refusing to hand over the artifact: the replayed worktree does not match the captured worktree.',
541
+ );
542
+ }
543
+ } catch (error) {
544
+ primary = error.reason
545
+ ? error
546
+ : captureError(
547
+ CAPTURE_REASONS.roundTripFailed,
548
+ `Refusing to hand over the artifact: the round trip failed (${error.message}).`,
549
+ );
550
+ } finally {
551
+ if (added) {
552
+ try {
553
+ await git(worktree, ['worktree', 'remove', '--force', replica]);
554
+ } catch (error) {
555
+ cleanupError = error;
556
+ }
557
+ }
558
+ try {
559
+ await rm(scratch, { recursive: true, force: true });
560
+ } catch (error) {
561
+ cleanupError ??= error;
562
+ }
563
+ }
564
+
565
+ if (primary) throw primary;
566
+ if (cleanupError) {
567
+ // A stale replay registration would leave the captured repository holding a
568
+ // worktree the lifecycle never authorized. Fail closed instead.
569
+ throw captureError(
570
+ CAPTURE_REASONS.roundTripFailed,
571
+ `Refusing to hand over the artifact: the replay worktree could not be removed (${cleanupError.message}).`,
572
+ );
573
+ }
574
+ }
575
+
576
+ /**
577
+ * Lexical containment is not enough: a symlinked ancestor can point the
578
+ * artifact directory straight back into the worktree this script must not write
579
+ * to, and an existing directory could be silently overwritten. Require a fresh
580
+ * directory whose resolved location is outside the worktree.
581
+ */
582
+ export async function resolveArtifactLocation(artifactRoot) {
583
+ let ancestor = artifactRoot;
584
+ while (!(await pathExists(ancestor))) {
585
+ const parent = dirname(ancestor);
586
+ if (parent === ancestor) break;
587
+ ancestor = parent;
588
+ }
589
+ const realAncestor = await realpath(ancestor);
590
+ // `resolve(realAncestor, relative(...))`, never `slice(ancestor.length + 1)`:
591
+ // when the nearest existing ancestor is the filesystem root the extra `+ 1`
592
+ // eats the first character of the next component, corrupting the path this
593
+ // containment check is about to judge.
594
+ return {
595
+ ancestor,
596
+ realAncestor,
597
+ realArtifact:
598
+ ancestor === artifactRoot
599
+ ? realAncestor
600
+ : resolve(realAncestor, relative(ancestor, artifactRoot)),
601
+ };
602
+ }
603
+
604
+ async function assertResolvesOutsideWorktree(worktreeRoot, artifactRoot) {
605
+ const { realArtifact } = await resolveArtifactLocation(artifactRoot);
606
+ const realWorktree = await realpath(worktreeRoot);
607
+ if (
608
+ realArtifact === realWorktree ||
609
+ realArtifact.startsWith(realWorktree + sep)
610
+ ) {
611
+ throw captureError(
612
+ CAPTURE_REASONS.invalidUsage,
613
+ 'The artifact directory must resolve outside the worktree it captures.',
614
+ );
615
+ }
616
+ }
617
+
618
+ async function resolveArtifactRoot(worktreeRoot, artifactDir) {
619
+ const artifactRoot = resolve(artifactDir);
620
+ if (await pathExists(artifactRoot)) {
621
+ throw captureError(
622
+ CAPTURE_REASONS.invalidUsage,
623
+ 'The artifact directory must not already exist; capture creates it so that nothing can be overwritten.',
624
+ );
625
+ }
626
+
627
+ await assertResolvesOutsideWorktree(worktreeRoot, artifactRoot);
628
+ return artifactRoot;
629
+ }
630
+
631
+ /**
632
+ * Capture every index, worktree, and untracked component of a quiescent dirty
633
+ * worktree into an immutable artifact directory, prove the artifact replays
634
+ * byte-for-byte, seal it, and return the reference the caller records. The
635
+ * caller — not this script — performs the restore afterwards.
636
+ */
637
+ export async function captureDirtyTree({
638
+ worktree,
639
+ artifactDir,
640
+ writerIdentity,
641
+ boundedFiles = [],
642
+ quiesceIntervalMs = DEFAULT_QUIESCE_INTERVAL_MS,
643
+ }) {
644
+ if (!worktree || !artifactDir) {
645
+ throw captureError(
646
+ CAPTURE_REASONS.invalidUsage,
647
+ 'Both --worktree and --artifact-dir are required.',
648
+ );
649
+ }
650
+
651
+ // The phase file boundary is not optional. An empty list would capture — and
652
+ // therefore hand a continuation permission to re-commit — dirt from outside
653
+ // the phase, which the surrounding contract lists as a terminal stop.
654
+ if (!Array.isArray(boundedFiles) || boundedFiles.length === 0) {
655
+ throw captureError(
656
+ CAPTURE_REASONS.invalidUsage,
657
+ 'At least one --bounded-file value is required; repeat the flag once per in-phase path, because capture never runs without the phase file boundary.',
658
+ );
659
+ }
660
+
661
+ const worktreeRoot = resolve(worktree);
662
+ const artifactRoot = await resolveArtifactRoot(worktreeRoot, artifactDir);
663
+
664
+ await assertNoInProgressOperation(worktreeRoot);
665
+ const snapshot = await assertWriterQuiescent({
666
+ worktree: worktreeRoot,
667
+ writerIdentity,
668
+ intervalMs: quiesceIntervalMs,
669
+ });
670
+ const { trackedPaths, untrackedPaths, affectedPaths } = classifyDirt(
671
+ snapshot.entries,
672
+ boundedFiles,
673
+ );
674
+
675
+ // Untracked entries are captured as byte copies, which silently converts a
676
+ // symlink or a device node into a regular file. Refuse rather than restore
677
+ // something that is not what the lost child left behind.
678
+ const untrackedModes = new Map();
679
+ for (const path of untrackedPaths) {
680
+ const entry = await lstat(join(worktreeRoot, path));
681
+ if (!entry.isFile()) {
682
+ throw captureError(
683
+ CAPTURE_REASONS.unsupportedDirt,
684
+ `Refusing to capture: untracked path ${path} is not a regular file and cannot be copied faithfully.`,
685
+ );
686
+ }
687
+ untrackedModes.set(path, (entry.mode & 0o111) !== 0);
688
+ }
689
+
690
+ const indexPatch = await git(
691
+ worktreeRoot,
692
+ ['diff', '--cached', '--binary', '--no-color', '--no-ext-diff'],
693
+ { binary: true, config: RENAME_DETECTION_CONFIG },
694
+ );
695
+ const worktreePatch = await git(
696
+ worktreeRoot,
697
+ ['diff', '--binary', '--no-color', '--no-ext-diff'],
698
+ { binary: true, config: RENAME_DETECTION_CONFIG },
699
+ );
700
+ const indexStat = await git(
701
+ worktreeRoot,
702
+ ['diff', '--cached', '--stat', '--no-color'],
703
+ { config: RENAME_DETECTION_CONFIG },
704
+ );
705
+ const untrackedBytes = new Map();
706
+ for (const path of untrackedPaths) {
707
+ untrackedBytes.set(path, await readFile(join(worktreeRoot, path)));
708
+ }
709
+ const restorePlan = await buildRestorePlan(
710
+ worktreeRoot,
711
+ trackedPaths,
712
+ untrackedPaths,
713
+ );
714
+
715
+ // Nothing is written anywhere until the worktree is proven unchanged across
716
+ // the whole read, so an interrupted capture leaves no half-written artifact.
717
+ if (!snapshotsMatch(snapshot, await snapshotWorktree(worktreeRoot))) {
718
+ throw captureError(
719
+ CAPTURE_REASONS.activeWriter,
720
+ 'Refusing to capture: the worktree changed while it was being read, so a writer is still active.',
721
+ );
722
+ }
723
+
724
+ // Exclusive, non-recursive: a directory raced into existence after the
725
+ // precheck fails here rather than being adopted, and the caller must have
726
+ // already created the parent it chose.
727
+ try {
728
+ await mkdir(artifactRoot);
729
+ } catch (error) {
730
+ throw captureError(
731
+ CAPTURE_REASONS.invalidUsage,
732
+ `The artifact directory could not be created exclusively beneath an existing parent: ${error.message}`,
733
+ );
734
+ }
735
+ // Re-check after creation: the containment decision above was made against a
736
+ // path that did not exist yet, so a directory or symlink substituted in the
737
+ // meantime would otherwise go unnoticed.
738
+ await assertResolvesOutsideWorktree(worktreeRoot, artifactRoot);
739
+ const files = [];
740
+ await writeArtifactFile(artifactRoot, 'index.patch', indexPatch, files);
741
+ await writeArtifactFile(artifactRoot, 'worktree.patch', worktreePatch, files);
742
+ for (const path of untrackedPaths) {
743
+ await writeArtifactFile(
744
+ artifactRoot,
745
+ join('untracked', path),
746
+ untrackedBytes.get(path),
747
+ files,
748
+ { executable: untrackedModes.get(path) },
749
+ );
750
+ }
751
+
752
+ const components = [];
753
+ if (indexPatch.length > 0) components.push('index');
754
+ if (worktreePatch.length > 0) components.push('worktree');
755
+ if (untrackedPaths.length > 0) components.push('untracked');
756
+
757
+ const manifest = {
758
+ schemaVersion: 1,
759
+ capturedAt: new Date().toISOString(),
760
+ worktree: worktreeRoot,
761
+ head: snapshot.head,
762
+ writerIdentity,
763
+ components,
764
+ affectedPaths,
765
+ trackedPaths,
766
+ untrackedPaths,
767
+ restorePlan,
768
+ stat: indexStat,
769
+ files,
770
+ };
771
+ const manifestBytes = Buffer.from(
772
+ `${JSON.stringify(manifest, null, 2)}\n`,
773
+ 'utf8',
774
+ );
775
+ await writeFile(join(artifactRoot, MANIFEST_FILE), manifestBytes, {
776
+ flag: 'wx',
777
+ });
778
+ // The proof is what seals the artifact and what produces the digest and size
779
+ // this capture publishes, so an unproven artifact has neither a reference to
780
+ // hand over nor a seal `--verify` will accept.
781
+ const { manifestDigest, size } = await proveArtifactRoundTrip({
782
+ worktree: worktreeRoot,
783
+ artifactDir: artifactRoot,
784
+ });
785
+
786
+ return {
787
+ ok: true,
788
+ artifact: artifactRoot,
789
+ manifestDigest,
790
+ size,
791
+ components,
792
+ affectedPaths,
793
+ restorePlan,
794
+ stat: indexStat,
795
+ };
796
+ }
797
+
798
+ /**
799
+ * Re-verify an artifact before it is applied: the round-trip seal, the
800
+ * manifest, and every named file must be readable and must still match the
801
+ * digest and size the continuation brief carries, the artifact directory must
802
+ * hold nothing the manifest does not list, and the captured `HEAD` must equal
803
+ * the `HEAD` the caller is about to patch. A tampered, unreadable, unproven, or
804
+ * base-mismatched artifact stops here.
805
+ */
806
+ export async function verifyArtifact({
807
+ artifactDir,
808
+ manifestDigest,
809
+ size,
810
+ expectedHead,
811
+ }) {
812
+ const artifactRoot = resolve(artifactDir);
813
+
814
+ if (typeof manifestDigest !== 'string' || manifestDigest.length === 0) {
815
+ throw captureError(
816
+ CAPTURE_REASONS.artifactVerificationFailed,
817
+ 'The expected --manifest-digest from the continuation brief is required to verify an artifact.',
818
+ );
819
+ }
820
+ if (typeof size !== 'number' || !Number.isInteger(size) || size < 0) {
821
+ throw captureError(
822
+ CAPTURE_REASONS.artifactVerificationFailed,
823
+ `The expected --size from the continuation brief must be a non-negative integer; received ${JSON.stringify(size)}.`,
824
+ );
825
+ }
826
+ if (typeof expectedHead !== 'string' || expectedHead.length === 0) {
827
+ throw captureError(
828
+ CAPTURE_REASONS.artifactVerificationFailed,
829
+ 'The --expected-head the artifact will be applied onto is required; an artifact captured at a different base cannot be replayed safely.',
830
+ );
831
+ }
832
+
833
+ let proof;
834
+ try {
835
+ proof = JSON.parse(await readFile(join(artifactRoot, PROOF_FILE), 'utf8'));
836
+ } catch (error) {
837
+ throw captureError(
838
+ CAPTURE_REASONS.artifactVerificationFailed,
839
+ `Artifact round-trip seal is missing or unreadable, so the artifact was never proven to replay: ${error.message}`,
840
+ );
841
+ }
842
+ if (
843
+ typeof proof !== 'object' ||
844
+ proof === null ||
845
+ typeof proof.manifestDigest !== 'string' ||
846
+ typeof proof.size !== 'number'
847
+ ) {
848
+ throw captureError(
849
+ CAPTURE_REASONS.artifactVerificationFailed,
850
+ 'Artifact round-trip seal is malformed, so the artifact cannot be treated as proven.',
851
+ );
852
+ }
853
+
854
+ let manifestBytes;
855
+ try {
856
+ manifestBytes = await readFile(join(artifactRoot, MANIFEST_FILE));
857
+ } catch (error) {
858
+ throw captureError(
859
+ CAPTURE_REASONS.artifactVerificationFailed,
860
+ `Artifact manifest is unreadable: ${error.message}`,
861
+ );
862
+ }
863
+
864
+ const actualManifestDigest = sha256(manifestBytes);
865
+ for (const [label, expected, actual] of [
866
+ ['brief manifest digest', manifestDigest, actualManifestDigest],
867
+ ['sealed manifest digest', proof.manifestDigest, actualManifestDigest],
868
+ ['sealed size', proof.size, size],
869
+ ]) {
870
+ if (expected !== actual) {
871
+ throw captureError(
872
+ CAPTURE_REASONS.artifactVerificationFailed,
873
+ `Artifact ${label} mismatch: expected ${expected}, found ${actual}.`,
874
+ );
875
+ }
876
+ }
877
+
878
+ let manifest;
879
+ try {
880
+ manifest = JSON.parse(manifestBytes.toString('utf8'));
881
+ } catch (error) {
882
+ throw captureError(
883
+ CAPTURE_REASONS.artifactVerificationFailed,
884
+ `Artifact manifest is not valid JSON: ${error.message}`,
885
+ );
886
+ }
887
+
888
+ let total = manifestBytes.length;
889
+ for (const file of manifest.files ?? []) {
890
+ let bytes;
891
+ try {
892
+ bytes = await readFile(join(artifactRoot, file.path));
893
+ } catch (error) {
894
+ throw captureError(
895
+ CAPTURE_REASONS.artifactVerificationFailed,
896
+ `Artifact file ${file.path} is unreadable: ${error.message}`,
897
+ );
898
+ }
899
+ if (bytes.length !== file.bytes) {
900
+ throw captureError(
901
+ CAPTURE_REASONS.artifactVerificationFailed,
902
+ `Artifact file ${file.path} size mismatch: expected ${file.bytes}, found ${bytes.length}.`,
903
+ );
904
+ }
905
+ const digest = sha256(bytes);
906
+ if (digest !== file.sha256) {
907
+ throw captureError(
908
+ CAPTURE_REASONS.artifactVerificationFailed,
909
+ `Artifact file ${file.path} digest mismatch: expected ${file.sha256}, found ${digest}.`,
910
+ );
911
+ }
912
+ const executable =
913
+ ((await lstat(join(artifactRoot, file.path))).mode & 0o111) !== 0;
914
+ if (executable !== Boolean(file.executable)) {
915
+ throw captureError(
916
+ CAPTURE_REASONS.artifactVerificationFailed,
917
+ `Artifact file ${file.path} executable-bit mismatch: expected ${Boolean(file.executable)}, found ${executable}.`,
918
+ );
919
+ }
920
+ total += bytes.length;
921
+ }
922
+
923
+ if (total !== size) {
924
+ throw captureError(
925
+ CAPTURE_REASONS.artifactVerificationFailed,
926
+ `Artifact aggregate size mismatch: expected ${size}, found ${total}.`,
927
+ );
928
+ }
929
+
930
+ // The aggregate size only covers what the manifest lists, so enumerate the
931
+ // directory as well: an artifact holding anything the manifest does not name
932
+ // is not the immutable artifact the capture sealed.
933
+ const listed = (manifest.files ?? []).map((file) => file.path);
934
+ const allowed = new Set([MANIFEST_FILE, PROOF_FILE, ...listed]);
935
+ // Directories only exist to hold listed files, so the permitted set is
936
+ // exactly the parent chain of those files. Anything else — including an empty
937
+ // directory — is not part of the artifact the capture sealed.
938
+ const allowedDirectories = new Set();
939
+ for (const path of listed) {
940
+ let parent = dirname(path);
941
+ while (parent !== '.' && parent !== sep && parent !== '') {
942
+ allowedDirectories.add(parent);
943
+ parent = dirname(parent);
944
+ }
945
+ }
946
+ const present = await readdir(artifactRoot, {
947
+ recursive: true,
948
+ withFileTypes: true,
949
+ });
950
+ for (const entry of present) {
951
+ const relativePath = relative(
952
+ artifactRoot,
953
+ join(entry.parentPath ?? entry.path, entry.name),
954
+ );
955
+ const permitted = entry.isDirectory()
956
+ ? allowedDirectories.has(relativePath)
957
+ : allowed.has(relativePath);
958
+ if (!permitted) {
959
+ throw captureError(
960
+ CAPTURE_REASONS.artifactVerificationFailed,
961
+ `Artifact holds ${relativePath}, which the manifest does not list.`,
962
+ );
963
+ }
964
+ }
965
+
966
+ // `git apply` matches on hunk context, not on base identity, so a patch can
967
+ // apply cleanly at the wrong commit and silently produce wrong content.
968
+ // Integrity is not base agreement; check both.
969
+ if (manifest.head !== expectedHead) {
970
+ throw captureError(
971
+ CAPTURE_REASONS.artifactVerificationFailed,
972
+ `Artifact base mismatch: captured at ${manifest.head}, but the target HEAD is ${expectedHead}.`,
973
+ );
974
+ }
975
+
976
+ return {
977
+ ok: true,
978
+ artifact: artifactRoot,
979
+ manifestDigest: actualManifestDigest,
980
+ size: total,
981
+ components: manifest.components ?? [],
982
+ affectedPaths: manifest.affectedPaths ?? [],
983
+ restorePlan: manifest.restorePlan ?? [],
984
+ head: manifest.head ?? null,
985
+ };
986
+ }
987
+
988
+ function parseArguments(argv) {
989
+ const options = { boundedFiles: [], verify: false };
990
+
991
+ for (let index = 0; index < argv.length; index += 1) {
992
+ const flag = argv[index];
993
+ if (flag === '--verify') {
994
+ options.verify = true;
995
+ continue;
996
+ }
997
+ const value = argv[index + 1];
998
+ if (value === undefined) {
999
+ throw captureError(
1000
+ CAPTURE_REASONS.invalidUsage,
1001
+ `Missing value for ${flag}.`,
1002
+ );
1003
+ }
1004
+ index += 1;
1005
+ if (flag === '--worktree') options.worktree = value;
1006
+ else if (flag === '--artifact-dir') options.artifactDir = value;
1007
+ else if (flag === '--writer-identity') options.writerIdentity = value;
1008
+ else if (flag === '--manifest-digest') options.manifestDigest = value;
1009
+ else if (flag === '--expected-head') options.expectedHead = value;
1010
+ else if (flag === '--size') options.size = Number(value);
1011
+ else if (flag === '--quiesce-interval-ms') {
1012
+ options.quiesceIntervalMs = Number(value);
1013
+ }
1014
+ // Repeated `--bounded-file` only: a delimited list would split a filename
1015
+ // that legitimately contains the delimiter and could widen the boundary to
1016
+ // a shorter prefix path.
1017
+ else if (flag === '--bounded-file') options.boundedFiles.push(value);
1018
+ else {
1019
+ throw captureError(
1020
+ CAPTURE_REASONS.invalidUsage,
1021
+ `Unsupported argument: ${flag}.`,
1022
+ );
1023
+ }
1024
+ }
1025
+
1026
+ return options;
1027
+ }
1028
+
1029
+ async function main(argv) {
1030
+ try {
1031
+ const options = parseArguments(argv);
1032
+ const result = options.verify
1033
+ ? await verifyArtifact(options)
1034
+ : await captureDirtyTree(options);
1035
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
1036
+ return 0;
1037
+ } catch (error) {
1038
+ const reason = error.reason ?? CAPTURE_REASONS.invalidUsage;
1039
+ process.stderr.write(
1040
+ `${JSON.stringify({ ok: false, reason, message: error.message })}\n`,
1041
+ );
1042
+ return EXIT_CODES[reason] ?? 1;
1043
+ }
1044
+ }
1045
+
1046
+ /**
1047
+ * Direct invocation, compared as canonical paths on both sides. Comparing a raw
1048
+ * `process.argv[1]` against `import.meta.url` makes the CLI a silent no-op that
1049
+ * exits 0 whenever the skill is reached through a symlinked install root, and
1050
+ * canonicalizing only one side has the same effect under
1051
+ * `--preserve-symlinks-main`, which keeps the link in `import.meta.url`. Either
1052
+ * way the caller reads "exited 0" as "verified" — the fail-open shape the
1053
+ * contracts exist to prevent.
1054
+ */
1055
+ function isDirectInvocation(invokedPath) {
1056
+ if (!invokedPath) return false;
1057
+ try {
1058
+ return (
1059
+ realpathSync(fileURLToPath(import.meta.url)) ===
1060
+ realpathSync(resolve(invokedPath))
1061
+ );
1062
+ } catch {
1063
+ return false;
1064
+ }
1065
+ }
1066
+
1067
+ if (isDirectInvocation(process.argv[1])) {
1068
+ process.exitCode = await main(process.argv.slice(2));
1069
+ }