@avee1234/selfpatch 0.1.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.
@@ -0,0 +1,1377 @@
1
+ #!/usr/bin/env node
2
+ // selfpatch CLI. v0.1 ships `validate`, `propose`, `verify`, and `gate`; the
3
+ // dispatcher is structured so `ledger` slots in later.
4
+ //
5
+ // Exit codes:
6
+ // validate: 0 — all files valid; 1 — a well-formed file failed validation;
7
+ // 2 — usage error, file not found, or invalid JSON.
8
+ // propose: 0 — a valid self-patch was produced; 2 — usage/IO error or the
9
+ // inputs could not form a valid self-patch.
10
+ // verify: 0 — verified (candidate green, not regressed); 1 — a valid patch
11
+ // that did not verify (candidate red and/or regressed); 2 — usage/IO
12
+ // error, invalid-JSON/invalid patch, or an unexecutable check.
13
+ // gate: 0 — approve; 1 — human-gate (needs a human); 3 — block (policy
14
+ // denied); 2 — usage/IO error, invalid JSON, or an invalid patch or
15
+ // policy. (2 means "couldn't evaluate", consistent across subcommands.)
16
+ // apply: 0 — the patch was applied and recorded; 2 — usage/IO error,
17
+ // invalid patch, drift (before_after target ≠ diff.before), or a
18
+ // unified diff that would not apply cleanly. Fails closed: on a 2 the
19
+ // surface is left untouched and nothing is recorded.
20
+ // revert: 0 — the pre-patch surface was restored and recorded; 1 — nothing
21
+ // to revert (never applied, or already reverted); 2 — usage/IO
22
+ // error, a broken (tampered) chain, or an ambiguous id prefix.
23
+ // log: 0 — chain intact (including an empty history); 1 — chain broken
24
+ // (tamper detected); 2 — usage/IO error.
25
+ // guard-skill-edit: the worst outcome across all changed skills wins (fail
26
+ // closed). 0 — no changes, or every changed skill approved (and
27
+ // applied when --apply); 1 — at least one skill needs a human
28
+ // (effective human-gate, or an unsupported removal) and none blocked;
29
+ // 3 — at least one skill blocked (protected surface, over blast
30
+ // radius, or failed/regressed its held-out check); 2 — usage/IO
31
+ // error, missing/unreadable dir, missing/invalid policy, a patch that
32
+ // fails the schema, or an unrunnable check.
33
+ //
34
+ // SECURITY: `verify` runs the patch's `eval_contract.command` as a real shell
35
+ // command (`shell: true`). It executes arbitrary code the proposer authored —
36
+ // only run it inside the propose→verify→gate pipeline, against a world you
37
+ // control. `gate` runs no commands — it is a pure read of patch + policy.
38
+ // `guard-skill-edit` runs the propose→verify→gate→(apply) pipeline over a skills
39
+ // diff; the eval command it runs is the OPERATOR-supplied `--eval` (never
40
+ // anything from the skills diff), so it introduces no new code-execution surface
41
+ // beyond `verify`'s.
42
+
43
+ import {
44
+ readFileSync,
45
+ writeFileSync,
46
+ appendFileSync,
47
+ mkdirSync,
48
+ existsSync,
49
+ rmSync,
50
+ mkdtempSync,
51
+ readdirSync,
52
+ } from "node:fs";
53
+ import { spawnSync } from "node:child_process";
54
+ import { join, dirname, isAbsolute, resolve, relative } from "node:path";
55
+ import { tmpdir } from "node:os";
56
+ import process from "node:process";
57
+ import {
58
+ validateSelfPatch,
59
+ validatePolicy,
60
+ proposeSelfPatch,
61
+ proposeSkillEdits,
62
+ skillTargetPath,
63
+ ProposeError,
64
+ verifySelfPatch,
65
+ gate,
66
+ blobRef,
67
+ buildAppliedEntry,
68
+ buildRevertedEntry,
69
+ verifyChain,
70
+ findRevertable,
71
+ LedgerError,
72
+ } from "../src/index.js";
73
+
74
+ const USAGE = `Usage:
75
+ selfpatch validate <file...> [--json] [--as patch|policy]
76
+ selfpatch propose --layer L --target T --rationale R --author A --eval CMD
77
+ [--diff FILE | --before FILE --after FILE]
78
+ [--meta FILE] [--requires auto|human-gate|forbidden]
79
+ [--surface S]... [--created ISO] [--compact]
80
+ selfpatch verify <patch.json> [--cwd DIR] [--baseline DIR] [--timeout MS] [--json]
81
+ selfpatch gate <patch.json> --policy <policy.json> [--json]
82
+ selfpatch apply <patch.json> [--dir DIR] [--reason TEXT] [--json]
83
+ selfpatch revert <id> [--dir DIR] [--json]
84
+ selfpatch log [--dir DIR] [--id ID] [--json]
85
+ selfpatch guard-skill-edit <before-dir> <after-dir> --policy <policy.json>
86
+ [--skills-root DIR] [--eval CMD] [--author A] [--rationale R]
87
+ [--requires auto|human-gate|forbidden] [--dir DIR]
88
+ [--cwd DIR] [--baseline DIR] [--timeout MS] [--apply] [--json]
89
+
90
+ validate Validate self-patch or policy JSON files.
91
+ Auto-detects: a document with \`protected_surfaces\` is a policy,
92
+ otherwise a self-patch. Override with --as.
93
+ --json Emit the raw { valid, errors } result (array for multiple files).
94
+ --as KIND Force validator: "patch" or "policy".
95
+
96
+ propose Assemble a raw self-edit into a validated self-patch JSON with a
97
+ deterministic content-hash id, printed to stdout. Applies nothing.
98
+ --diff FILE Unified diff patch text (use "-" for stdin). Omit all
99
+ diff flags to read a unified diff from stdin.
100
+ --before/--after Two versions of the surface (before_after diff).
101
+ --meta FILE JSON object of metadata; individual flags override it.
102
+ --requires Approval level (default: human-gate).
103
+ --surface S Blast-radius surface (repeatable). Default: [target].
104
+ --created ISO Override the timestamp (default: now).
105
+ --compact Emit single-line JSON (for piping).
106
+
107
+ verify Run a self-patch's held-out check (eval_contract) OUTSIDE the loop
108
+ and report whether the candidate is green and whether it regressed
109
+ a check that was green before. Applies nothing. The check command
110
+ runs in a real shell — only run against a world you control.
111
+ <patch.json> The self-patch to verify ("-" for stdin). Validated first.
112
+ --cwd DIR Candidate (after) world (default: current directory).
113
+ --baseline DIR Baseline (before) world (default: same as --cwd, so a
114
+ regression can only fire when a distinct baseline is set).
115
+ --timeout MS Bound each command run; a timed-out check is red.
116
+ --json Emit the raw { passed, regressed, checks } result.
117
+ Exit: 0 verified; 1 red/regressed; 2 usage/IO/invalid/unrunnable.
118
+
119
+ gate Decide approve | human-gate | block for a self-patch under a
120
+ policy, with an explicit reason. Runs no commands — a pure read of
121
+ the patch + policy. Blocks a patch that touches a protected surface
122
+ or exceeds the policy's blast-radius limits; routes to a human when
123
+ the effective \`requires\` is human-gate; else approves.
124
+ <patch.json> The self-patch to gate ("-" for stdin). Validated first.
125
+ --policy, -p FILE The policy JSON ("-" for stdin). Required. Validated first.
126
+ --json Emit the raw { decision, reason } result.
127
+ Exit: 0 approve; 1 human-gate; 3 block; 2 usage/IO/invalid.
128
+
129
+ apply Apply an approved self-patch to its target surface and append a
130
+ hash-linked \`applied\` entry to the ledger. Validates the patch
131
+ first (invalid ⇒ no mutation). Snapshots the real pre- and
132
+ post-images to a content-addressed object store, so \`revert\` is
133
+ exact and format-agnostic. Does NOT re-gate or re-verify.
134
+ <patch.json> The self-patch to apply ("-" for stdin). Validated first.
135
+ --dir DIR The ledger directory (default: .selfpatch).
136
+ --reason TEXT A free-text note recorded on the entry (e.g. the gate
137
+ decision that authorized the apply).
138
+ --json Emit the new ledger entry as JSON.
139
+ Exit: 0 applied; 2 usage/IO/invalid/drift/unappliable.
140
+
141
+ revert Restore the pre-patch surface of a patch's most recent, un-reverted
142
+ apply and append a hash-linked \`reverted\` entry. Restores a stored
143
+ snapshot (byte-exact for both diff formats), not a reverse-diff.
144
+ Verifies the chain first — refuses to act on a tampered ledger.
145
+ <id> A self-patch id (sha256:…; a unique prefix is accepted).
146
+ --dir DIR The ledger directory (default: .selfpatch).
147
+ --json Emit the new ledger entry as JSON.
148
+ Exit: 0 reverted; 1 nothing to revert; 2 usage/IO/tampered/ambiguous.
149
+
150
+ log Print the ledger history and verify the hash chain on read (reading
151
+ the history IS the integrity check). A missing ledger is an empty
152
+ history, not an error.
153
+ --dir DIR The ledger directory (default: .selfpatch).
154
+ --id ID Filter to one patch id's entries (its apply/revert history).
155
+ --json Emit { entries, integrity: { valid, brokenAt } }.
156
+ Exit: 0 intact/empty; 1 chain broken; 2 usage/IO.
157
+
158
+ guard-skill-edit Govern the factory's retro loop: diff two \`.agents/skills\`
159
+ trees into one scaffolding self-patch per changed skill, then run
160
+ propose → verify → gate over each and report a per-skill verdict.
161
+ With --apply, an approved patch is applied to its cwd target and
162
+ appended to the ledger. Protected skills (per the policy) are
163
+ blocked; a removed skill is surfaced for a human, never actuated.
164
+ <before-dir> The committed (HEAD) skills tree.
165
+ <after-dir> The proposed skills tree.
166
+ --policy, -p FILE The protected-surface policy JSON. Required. Validated first.
167
+ --skills-root DIR The logical repo path the targets carry (default:
168
+ .agents/skills). A logical prefix, not a host path.
169
+ --eval CMD The held-out check each patch runs (default: npm test).
170
+ --author A Patch author (default: agent://foundry-retro).
171
+ --rationale R Override the per-skill default rationale.
172
+ --requires Baseline approval level (default: human-gate).
173
+ --dir DIR The ledger directory for --apply (default: .selfpatch).
174
+ --cwd DIR Candidate (after) world for the check (default: cwd).
175
+ --baseline DIR Baseline (before) world for the check (default: --cwd).
176
+ --timeout MS Bound each check run; a timed-out check is red.
177
+ --apply Apply every approved patch to its cwd target + ledger.
178
+ --json Emit the per-skill verdict array.
179
+ Exit: worst outcome wins — 0 all approved/no changes; 1 needs a
180
+ human; 3 blocked; 2 usage/IO/invalid/unrunnable.`;
181
+
182
+ function fail(message) {
183
+ process.stderr.write(message + "\n");
184
+ process.exit(2);
185
+ }
186
+
187
+ function parseArgs(argv) {
188
+ const files = [];
189
+ let json = false;
190
+ let as = null;
191
+ for (let i = 0; i < argv.length; i++) {
192
+ const arg = argv[i];
193
+ if (arg === "--json") {
194
+ json = true;
195
+ } else if (arg === "--as") {
196
+ as = argv[++i];
197
+ if (as !== "patch" && as !== "policy") {
198
+ fail(`error: --as must be "patch" or "policy"\n\n${USAGE}`);
199
+ }
200
+ } else if (arg.startsWith("--as=")) {
201
+ as = arg.slice("--as=".length);
202
+ if (as !== "patch" && as !== "policy") {
203
+ fail(`error: --as must be "patch" or "policy"\n\n${USAGE}`);
204
+ }
205
+ } else if (arg.startsWith("-")) {
206
+ fail(`error: unknown flag ${arg}\n\n${USAGE}`);
207
+ } else {
208
+ files.push(arg);
209
+ }
210
+ }
211
+ return { files, json, as };
212
+ }
213
+
214
+ function pickValidator(doc, as) {
215
+ if (as === "patch") return validateSelfPatch;
216
+ if (as === "policy") return validatePolicy;
217
+ // Auto-detect: `protected_surfaces` present ⇒ policy, else patch.
218
+ if (doc && typeof doc === "object" && !Array.isArray(doc) && "protected_surfaces" in doc) {
219
+ return validatePolicy;
220
+ }
221
+ return validateSelfPatch;
222
+ }
223
+
224
+ // Read a file argument, or stdin when the path is "-".
225
+ function readSource(path, label) {
226
+ try {
227
+ return readFileSync(path === "-" ? 0 : path, "utf8");
228
+ } catch (e) {
229
+ fail(`error: cannot read ${label} ${path}: ${e.code === "ENOENT" ? "no such file" : e.message}`);
230
+ }
231
+ }
232
+
233
+ const PROPOSE_FLAGS = new Set([
234
+ "--layer", "--target", "--rationale", "--author", "--eval",
235
+ "--requires", "--created", "--diff", "--before", "--after", "--meta",
236
+ "--surface", "--compact",
237
+ ]);
238
+
239
+ // `--compact` takes no value; `--surface` may repeat, accumulating into an array.
240
+ const PROPOSE_BOOL_FLAGS = new Set(["--compact"]);
241
+ const PROPOSE_LIST_FLAGS = new Set(["--surface"]);
242
+
243
+ // Metadata keys a `--meta` JSON file may supply; individual flags override them.
244
+ const META_FILE_KEYS = [
245
+ "layer", "target", "rationale", "author", "eval", "requires", "created",
246
+ ];
247
+
248
+ function parseProposeArgs(argv) {
249
+ const opts = {};
250
+ for (let i = 0; i < argv.length; i++) {
251
+ const arg = argv[i];
252
+ let key = arg;
253
+ let inlineValue = null;
254
+ const eq = arg.indexOf("=");
255
+ if (arg.startsWith("--") && eq !== -1) {
256
+ key = arg.slice(0, eq);
257
+ inlineValue = arg.slice(eq + 1);
258
+ }
259
+ if (!PROPOSE_FLAGS.has(key)) {
260
+ fail(`error: unknown flag ${arg}\n\n${USAGE}`);
261
+ }
262
+ if (PROPOSE_BOOL_FLAGS.has(key)) {
263
+ opts[key.slice(2)] = true;
264
+ continue;
265
+ }
266
+ const value = inlineValue !== null ? inlineValue : argv[++i];
267
+ if (value === undefined) {
268
+ fail(`error: ${key} needs a value\n\n${USAGE}`);
269
+ }
270
+ if (PROPOSE_LIST_FLAGS.has(key)) {
271
+ (opts[key.slice(2)] ??= []).push(value);
272
+ } else {
273
+ opts[key.slice(2)] = value;
274
+ }
275
+ }
276
+ return opts;
277
+ }
278
+
279
+ function runPropose(argv) {
280
+ const o = parseProposeArgs(argv);
281
+
282
+ // A `--meta` file supplies metadata defaults; individual flags override them.
283
+ if (o.meta !== undefined) {
284
+ const raw = readSource(o.meta, "meta");
285
+ let fileMeta;
286
+ try {
287
+ fileMeta = JSON.parse(raw);
288
+ } catch (e) {
289
+ fail(`error: --meta ${o.meta}: invalid JSON: ${e.message}`);
290
+ }
291
+ if (fileMeta === null || typeof fileMeta !== "object" || Array.isArray(fileMeta)) {
292
+ fail(`error: --meta ${o.meta}: expected a JSON object`);
293
+ }
294
+ for (const key of META_FILE_KEYS) {
295
+ if (o[key] === undefined && fileMeta[key] !== undefined) {
296
+ o[key] = fileMeta[key];
297
+ }
298
+ }
299
+ delete o.meta;
300
+ }
301
+
302
+ for (const req of ["layer", "target", "rationale", "author", "eval"]) {
303
+ if (typeof o[req] !== "string" || o[req].trim() === "") {
304
+ fail(`error: propose requires --${req}\n\n${USAGE}`);
305
+ }
306
+ }
307
+
308
+ const hasDiff = o.diff !== undefined;
309
+ const hasBeforeAfter = o.before !== undefined || o.after !== undefined;
310
+ if (hasDiff && hasBeforeAfter) {
311
+ fail(`error: propose takes either --diff or --before/--after, not both\n\n${USAGE}`);
312
+ }
313
+ if (hasBeforeAfter && (o.before === undefined || o.after === undefined)) {
314
+ fail(`error: before_after needs both --before and --after\n\n${USAGE}`);
315
+ }
316
+
317
+ // The `edit` is the diff itself: a plain unified-diff string, or a
318
+ // before_after object.
319
+ let edit;
320
+ if (hasDiff) {
321
+ edit = readSource(o.diff, "diff");
322
+ } else if (hasBeforeAfter) {
323
+ edit = {
324
+ format: "before_after",
325
+ before: readSource(o.before, "before"),
326
+ after: readSource(o.after, "after"),
327
+ };
328
+ } else {
329
+ // Neither diff flag given ⇒ read a unified diff from stdin.
330
+ edit = readSource("-", "diff");
331
+ }
332
+
333
+ const meta = {
334
+ author: o.author,
335
+ layer: o.layer,
336
+ target: o.target,
337
+ rationale: o.rationale,
338
+ command: o.eval,
339
+ };
340
+ if (o.surface !== undefined) meta.surfaces = o.surface;
341
+ if (o.requires !== undefined) meta.requires = o.requires;
342
+ if (o.created !== undefined) meta.created = o.created;
343
+
344
+ let patch;
345
+ try {
346
+ patch = proposeSelfPatch(edit, meta);
347
+ } catch (e) {
348
+ // A validation failure (ProposeError, carries `.errors`) prints the same
349
+ // error lines as `validate`. Per the propose contract, both a validation
350
+ // failure and a usage/IO problem exit 2 (propose defines only 0 and 2).
351
+ if (Array.isArray(e.errors)) {
352
+ process.stderr.write(e.message + "\n");
353
+ process.exit(2);
354
+ }
355
+ fail(`error: ${e.message}`);
356
+ }
357
+
358
+ const out = o.compact
359
+ ? JSON.stringify(patch)
360
+ : JSON.stringify(patch, null, 2);
361
+ process.stdout.write(out + "\n");
362
+ process.exit(0);
363
+ }
364
+
365
+ const VERIFY_FLAGS = new Set(["--cwd", "--baseline", "--timeout", "--json"]);
366
+ const VERIFY_BOOL_FLAGS = new Set(["--json"]);
367
+
368
+ function parseVerifyArgs(argv) {
369
+ const opts = {};
370
+ const positionals = [];
371
+ for (let i = 0; i < argv.length; i++) {
372
+ const arg = argv[i];
373
+ if (!arg.startsWith("--")) {
374
+ positionals.push(arg);
375
+ continue;
376
+ }
377
+ let key = arg;
378
+ let inlineValue = null;
379
+ const eq = arg.indexOf("=");
380
+ if (eq !== -1) {
381
+ key = arg.slice(0, eq);
382
+ inlineValue = arg.slice(eq + 1);
383
+ }
384
+ if (!VERIFY_FLAGS.has(key)) {
385
+ fail(`error: unknown flag ${arg}\n\n${USAGE}`);
386
+ }
387
+ if (VERIFY_BOOL_FLAGS.has(key)) {
388
+ opts[key.slice(2)] = true;
389
+ continue;
390
+ }
391
+ const value = inlineValue !== null ? inlineValue : argv[++i];
392
+ if (value === undefined) {
393
+ fail(`error: ${key} needs a value\n\n${USAGE}`);
394
+ }
395
+ opts[key.slice(2)] = value;
396
+ }
397
+ return { opts, positionals };
398
+ }
399
+
400
+ // Build the side-effecting run-check seam shared by `verify` and
401
+ // `guard-skill-edit`: run the check command in a real shell against the
402
+ // candidate (`after`) world or the baseline (`before`) world. A spawn error
403
+ // (non-timeout, e.g. cwd missing) maps to exitCode:null (red) so a bad command
404
+ // reports a failed check, not a crash.
405
+ function makeRunCheck({ cwdDir, baselineDir, timeout }) {
406
+ return (command, phase) => {
407
+ const r = spawnSync(command, {
408
+ shell: true,
409
+ cwd: phase === "before" ? baselineDir : cwdDir,
410
+ encoding: "utf8",
411
+ timeout,
412
+ });
413
+ const timedOut = r.error != null && r.error.code === "ETIMEDOUT";
414
+ return {
415
+ exitCode: r.status,
416
+ signal: r.signal,
417
+ timedOut,
418
+ stdout: r.stdout,
419
+ stderr: r.stderr,
420
+ };
421
+ };
422
+ }
423
+
424
+ function runVerify(argv) {
425
+ const { opts, positionals } = parseVerifyArgs(argv);
426
+ if (positionals.length === 0) {
427
+ fail(`error: verify needs a patch file\n\n${USAGE}`);
428
+ }
429
+ if (positionals.length > 1) {
430
+ fail(`error: verify takes a single patch file\n\n${USAGE}`);
431
+ }
432
+
433
+ let timeout;
434
+ if (opts.timeout !== undefined) {
435
+ timeout = Number(opts.timeout);
436
+ if (!Number.isFinite(timeout) || timeout < 0) {
437
+ fail(`error: --timeout must be a non-negative number of milliseconds\n\n${USAGE}`);
438
+ }
439
+ }
440
+
441
+ const raw = readSource(positionals[0], "patch");
442
+ let patch;
443
+ try {
444
+ patch = JSON.parse(raw);
445
+ } catch (e) {
446
+ fail(`error: ${positionals[0]}: invalid JSON: ${e.message}`);
447
+ }
448
+
449
+ // Never run a check for an invalid patch — validate first, print the same
450
+ // error lines as `validate`, and exit 2.
451
+ const result = validateSelfPatch(patch);
452
+ if (!result.valid) {
453
+ process.stderr.write(`✗ ${positionals[0]} — ${result.errors.length} error(s)\n`);
454
+ for (const er of result.errors) {
455
+ const loc = er.path === "" ? "(root)" : er.path;
456
+ process.stderr.write(` ${loc}: ${er.message} [${er.code}]\n`);
457
+ }
458
+ process.exit(2);
459
+ }
460
+
461
+ const cwdDir = opts.cwd !== undefined ? opts.cwd : process.cwd();
462
+ const baselineDir = opts.baseline !== undefined ? opts.baseline : cwdDir;
463
+
464
+ // The only side-effecting seam: run the check command in a real shell against
465
+ // the chosen world.
466
+ const runCheck = makeRunCheck({ cwdDir, baselineDir, timeout });
467
+
468
+ let verdict;
469
+ try {
470
+ verdict = verifySelfPatch(patch, { runCheck });
471
+ } catch (e) {
472
+ // An unverifiable patch (VerifyError — e.g. an eval_contract yielding no
473
+ // runnable command) fails closed at exit 2, never silently "passed".
474
+ fail(`error: ${e.message}`);
475
+ }
476
+
477
+ if (opts.json) {
478
+ process.stdout.write(JSON.stringify(verdict, null, 2) + "\n");
479
+ } else {
480
+ const overall = verdict.passed && !verdict.regressed
481
+ ? "✓ verified"
482
+ : verdict.regressed
483
+ ? "✗ regressed"
484
+ : "✗ failed";
485
+ process.stdout.write(overall + "\n");
486
+ for (const c of verdict.checks) {
487
+ const b = c.before && c.before.exitCode !== undefined ? c.before.exitCode : "?";
488
+ const a = c.after && c.after.exitCode !== undefined ? c.after.exitCode : "?";
489
+ const mark = c.regressed ? "✗ regressed" : c.passed ? "✓" : "✗";
490
+ process.stdout.write(` ${mark} ${c.command} (before: ${b}, after: ${a})\n`);
491
+ }
492
+ }
493
+
494
+ process.exit(verdict.passed && !verdict.regressed ? 0 : 1);
495
+ }
496
+
497
+ const GATE_FLAGS = new Set(["--policy", "--json"]);
498
+ const GATE_BOOL_FLAGS = new Set(["--json"]);
499
+
500
+ function parseGateArgs(argv) {
501
+ const opts = {};
502
+ const positionals = [];
503
+ for (let i = 0; i < argv.length; i++) {
504
+ const arg = argv[i];
505
+ // `-p` is a documented short alias for `--policy`.
506
+ if (arg === "-p") {
507
+ const value = argv[++i];
508
+ if (value === undefined) fail(`error: -p needs a value\n\n${USAGE}`);
509
+ opts.policy = value;
510
+ continue;
511
+ }
512
+ if (!arg.startsWith("--")) {
513
+ positionals.push(arg);
514
+ continue;
515
+ }
516
+ let key = arg;
517
+ let inlineValue = null;
518
+ const eq = arg.indexOf("=");
519
+ if (eq !== -1) {
520
+ key = arg.slice(0, eq);
521
+ inlineValue = arg.slice(eq + 1);
522
+ }
523
+ if (!GATE_FLAGS.has(key)) {
524
+ fail(`error: unknown flag ${arg}\n\n${USAGE}`);
525
+ }
526
+ if (GATE_BOOL_FLAGS.has(key)) {
527
+ opts[key.slice(2)] = true;
528
+ continue;
529
+ }
530
+ const value = inlineValue !== null ? inlineValue : argv[++i];
531
+ if (value === undefined) {
532
+ fail(`error: ${key} needs a value\n\n${USAGE}`);
533
+ }
534
+ opts[key.slice(2)] = value;
535
+ }
536
+ return { opts, positionals };
537
+ }
538
+
539
+ // Read a JSON file (or stdin for "-"), parse it, and validate it with the given
540
+ // validator. Any read/parse/validation failure exits 2 — "couldn't evaluate" —
541
+ // so `gate` never decides on a malformed patch or policy (fail closed).
542
+ function readValidatedJson(path, label, validator) {
543
+ const raw = readSource(path, label);
544
+ let doc;
545
+ try {
546
+ doc = JSON.parse(raw);
547
+ } catch (e) {
548
+ fail(`error: ${path}: invalid JSON: ${e.message}`);
549
+ }
550
+ const result = validator(doc);
551
+ if (!result.valid) {
552
+ process.stderr.write(`✗ ${label} ${path} — ${result.errors.length} error(s)\n`);
553
+ for (const er of result.errors) {
554
+ const loc = er.path === "" ? "(root)" : er.path;
555
+ process.stderr.write(` ${loc}: ${er.message} [${er.code}]\n`);
556
+ }
557
+ process.exit(2);
558
+ }
559
+ return doc;
560
+ }
561
+
562
+ function runGate(argv) {
563
+ const { opts, positionals } = parseGateArgs(argv);
564
+ if (positionals.length === 0) {
565
+ fail(`error: gate needs a patch file\n\n${USAGE}`);
566
+ }
567
+ if (positionals.length > 1) {
568
+ fail(`error: gate takes a single patch file\n\n${USAGE}`);
569
+ }
570
+ if (opts.policy === undefined) {
571
+ fail(`error: gate requires --policy <policy.json>\n\n${USAGE}`);
572
+ }
573
+ if (positionals[0] === "-" && opts.policy === "-") {
574
+ fail(`error: only one of the patch or --policy may be read from stdin\n\n${USAGE}`);
575
+ }
576
+
577
+ const patch = readValidatedJson(positionals[0], "patch", validateSelfPatch);
578
+ const policy = readValidatedJson(opts.policy, "policy", validatePolicy);
579
+
580
+ let verdict;
581
+ try {
582
+ verdict = gate(patch, policy);
583
+ } catch (e) {
584
+ // A GateError (non-object input) fails closed at exit 2 — never a silent
585
+ // approve. Validation above makes this unreachable in practice.
586
+ fail(`error: ${e.message}`);
587
+ }
588
+
589
+ if (opts.json) {
590
+ process.stdout.write(JSON.stringify(verdict, null, 2) + "\n");
591
+ } else {
592
+ const mark =
593
+ verdict.decision === "approve" ? "✓" : verdict.decision === "human-gate" ? "⚠" : "✗";
594
+ process.stdout.write(`${mark} ${verdict.decision} — ${verdict.reason}\n`);
595
+ }
596
+
597
+ const code = verdict.decision === "approve" ? 0 : verdict.decision === "human-gate" ? 1 : 3;
598
+ process.exit(code);
599
+ }
600
+
601
+ // --- ledger: apply / revert / log -------------------------------------------
602
+ //
603
+ // The pure chain/selection logic lives in src/ledger.js; everything below is the
604
+ // CLI's injected I/O — the on-disk ledger (append-only JSONL), the
605
+ // content-addressed object store, and the target surfaces.
606
+
607
+ // Resolve the ledger's on-disk layout under `--dir` (default `.selfpatch`).
608
+ function ledgerPaths(dir) {
609
+ const root = dir || ".selfpatch";
610
+ return {
611
+ root,
612
+ ledgerFile: join(root, "ledger.jsonl"),
613
+ objectsDir: join(root, "objects"),
614
+ };
615
+ }
616
+
617
+ // Parse `ledger.jsonl` into an entries array (missing file ⇒ []). A malformed
618
+ // line is a corrupt ledger ⇒ exit 2 (never act on a ledger we cannot read).
619
+ function readLedger(paths) {
620
+ let raw;
621
+ try {
622
+ raw = readFileSync(paths.ledgerFile, "utf8");
623
+ } catch (e) {
624
+ if (e.code === "ENOENT") return [];
625
+ fail(`error: cannot read ledger ${paths.ledgerFile}: ${e.message}`);
626
+ }
627
+ const entries = [];
628
+ const lines = raw.split("\n");
629
+ for (let i = 0; i < lines.length; i++) {
630
+ if (lines[i].trim() === "") continue;
631
+ try {
632
+ entries.push(JSON.parse(lines[i]));
633
+ } catch (e) {
634
+ fail(`error: ${paths.ledgerFile}: malformed JSON on line ${i + 1}: ${e.message}`);
635
+ }
636
+ }
637
+ return entries;
638
+ }
639
+
640
+ // Append one entry line. Creates `--dir` on first write; never rewrites or
641
+ // truncates the file — the ledger is append-only in practice.
642
+ function appendEntry(paths, entry) {
643
+ mkdirSync(paths.root, { recursive: true });
644
+ appendFileSync(paths.ledgerFile, JSON.stringify(entry) + "\n");
645
+ }
646
+
647
+ // Object store, keyed by the hex after the `sha256:` prefix.
648
+ function blobPath(paths, ref) {
649
+ return join(paths.objectsDir, ref.slice("sha256:".length));
650
+ }
651
+
652
+ // Write-once: identical snapshots dedupe, and an existing blob is never mutated.
653
+ // `null` content ⇒ `null` ref (an absent surface).
654
+ function writeBlob(paths, content) {
655
+ if (content === null || content === undefined) return null;
656
+ const ref = blobRef(content);
657
+ const p = blobPath(paths, ref);
658
+ if (!existsSync(p)) {
659
+ mkdirSync(paths.objectsDir, { recursive: true });
660
+ writeFileSync(p, content);
661
+ }
662
+ return ref;
663
+ }
664
+
665
+ function readBlob(paths, ref) {
666
+ try {
667
+ return readFileSync(blobPath(paths, ref), "utf8");
668
+ } catch (e) {
669
+ fail(`error: cannot read snapshot ${ref}: ${e.message}`);
670
+ }
671
+ }
672
+
673
+ // Resolve a patch target to an absolute path, refusing anything that escapes the
674
+ // cwd (an absolute path or a `..` climb). A self-patch must not relocate the
675
+ // ledger or write outside the tree — fail closed on a suspicious target.
676
+ function resolveTarget(target) {
677
+ if (typeof target !== "string" || target.trim() === "") {
678
+ fail("error: patch.target must be a non-empty path");
679
+ }
680
+ if (isAbsolute(target)) {
681
+ fail(`error: refusing to write outside the tree: ${target} (absolute path)`);
682
+ }
683
+ const abs = resolve(process.cwd(), target);
684
+ const rel = relative(process.cwd(), abs);
685
+ if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) {
686
+ fail(`error: refusing to write outside the tree: ${target}`);
687
+ }
688
+ return abs;
689
+ }
690
+
691
+ // Read the current surface bytes, or `null` when the file is absent.
692
+ function readSurface(abs) {
693
+ try {
694
+ return readFileSync(abs, "utf8");
695
+ } catch (e) {
696
+ if (e.code === "ENOENT") return null;
697
+ fail(`error: cannot read target ${abs}: ${e.message}`);
698
+ }
699
+ }
700
+
701
+ function writeSurface(abs, content) {
702
+ mkdirSync(dirname(abs), { recursive: true });
703
+ writeFileSync(abs, content);
704
+ }
705
+
706
+ function removeSurface(abs) {
707
+ rmSync(abs, { force: true });
708
+ }
709
+
710
+ // Derive the path a unified diff targets from its `+++ ` header, stripping the
711
+ // one leading component that `git apply -p1` removes (the `a/`/`b/` prefix), so
712
+ // we can seed a scratch tree at that exact path.
713
+ function unifiedTargetPath(patchText) {
714
+ for (const line of patchText.split("\n")) {
715
+ if (!line.startsWith("+++ ")) continue;
716
+ let p = line.slice(4).trim();
717
+ const tab = p.indexOf("\t");
718
+ if (tab !== -1) p = p.slice(0, tab);
719
+ if (p === "/dev/null") continue;
720
+ const slash = p.indexOf("/");
721
+ return slash === -1 ? p : p.slice(slash + 1);
722
+ }
723
+ return null;
724
+ }
725
+
726
+ // The only new side-effecting seam: materialize a `unified` diff via the system
727
+ // `git apply`. Seeds a throwaway tree with the pre-image at the diff's target
728
+ // path, applies the patch, and reads the result back. Throws (⇒ caller exits 2,
729
+ // surface untouched) if git is unavailable or the patch does not apply cleanly.
730
+ function applyUnified(before, patchText) {
731
+ const targetPath = unifiedTargetPath(patchText);
732
+ if (targetPath === null) {
733
+ throw new Error("could not determine the target path from the unified diff");
734
+ }
735
+ const scratch = mkdtempSync(join(tmpdir(), "selfpatch-apply-"));
736
+ try {
737
+ const filePath = join(scratch, targetPath);
738
+ if (relative(scratch, filePath).startsWith("..")) {
739
+ throw new Error("unified diff target escapes the scratch tree");
740
+ }
741
+ mkdirSync(dirname(filePath), { recursive: true });
742
+ writeFileSync(filePath, before ?? "");
743
+ const patchFile = join(scratch, "__selfpatch.diff");
744
+ writeFileSync(patchFile, patchText);
745
+ const r = spawnSync("git", ["apply", "--unsafe-paths", "-p1", patchFile], {
746
+ cwd: scratch,
747
+ encoding: "utf8",
748
+ });
749
+ if (r.error) throw new Error(`git apply unavailable: ${r.error.message}`);
750
+ if (r.status !== 0) {
751
+ throw new Error(`git apply failed: ${(r.stderr || "").trim() || `exit ${r.status}`}`);
752
+ }
753
+ return readFileSync(filePath, "utf8");
754
+ } finally {
755
+ rmSync(scratch, { recursive: true, force: true });
756
+ }
757
+ }
758
+
759
+ // Shared flag parser for the ledger subcommands (`--k v` / `--k=v`, positionals).
760
+ function parseLedgerArgs(argv, flags, boolFlags) {
761
+ const opts = {};
762
+ const positionals = [];
763
+ for (let i = 0; i < argv.length; i++) {
764
+ const arg = argv[i];
765
+ if (!arg.startsWith("--")) {
766
+ positionals.push(arg);
767
+ continue;
768
+ }
769
+ let key = arg;
770
+ let inlineValue = null;
771
+ const eq = arg.indexOf("=");
772
+ if (eq !== -1) {
773
+ key = arg.slice(0, eq);
774
+ inlineValue = arg.slice(eq + 1);
775
+ }
776
+ if (!flags.has(key)) {
777
+ fail(`error: unknown flag ${arg}\n\n${USAGE}`);
778
+ }
779
+ if (boolFlags.has(key)) {
780
+ opts[key.slice(2)] = true;
781
+ continue;
782
+ }
783
+ const value = inlineValue !== null ? inlineValue : argv[++i];
784
+ if (value === undefined) {
785
+ fail(`error: ${key} needs a value\n\n${USAGE}`);
786
+ }
787
+ opts[key.slice(2)] = value;
788
+ }
789
+ return { opts, positionals };
790
+ }
791
+
792
+ // The short display hash: the 12 hex chars after the `sha256:` prefix.
793
+ function shortHash(ref) {
794
+ if (typeof ref !== "string") return "-";
795
+ return ref.replace(/^sha256:/, "").slice(0, 12) || "-";
796
+ }
797
+
798
+ const APPLY_FLAGS = new Set(["--dir", "--reason", "--json"]);
799
+ const REVERT_FLAGS = new Set(["--dir", "--json"]);
800
+ const LOG_FLAGS = new Set(["--dir", "--id", "--json"]);
801
+ const LEDGER_BOOL_FLAGS = new Set(["--json"]);
802
+
803
+ // The apply actuator shared by `apply` and `guard-skill-edit --apply`. Given a
804
+ // VALID self-patch, resolves its target in the cwd, derives the post-image
805
+ // (before_after drift-guard / unified via `applyUnified`), snapshots the pre-
806
+ // and post-images to the object store, writes the surface, and appends a
807
+ // hash-linked `applied` entry to the ledger under `dir`. Returns the new entry.
808
+ // Throws a plain `Error` on drift or an unappliable/unsupported diff (the caller
809
+ // decides the exit code — `apply` exits 2, `guard-skill-edit` marks the patch
810
+ // blocked); the injected IO helpers still exit 2 directly on read/write errors.
811
+ function applyPatchToLedger(patch, { dir, reason } = {}) {
812
+ const paths = ledgerPaths(dir);
813
+ const absTarget = resolveTarget(patch.target);
814
+ const pre = readSurface(absTarget); // the real pre-image (null ⇒ absent)
815
+ const diff = patch.diff;
816
+
817
+ // Derive the post-image from the diff. Fail closed on drift or an unappliable
818
+ // unified diff — never write a surface we cannot faithfully record.
819
+ let post;
820
+ if (diff.format === "before_after") {
821
+ const current = pre === null ? "" : pre;
822
+ if (current !== diff.before) {
823
+ throw new Error(
824
+ `target ${patch.target} has drifted from diff.before; refusing to apply`
825
+ );
826
+ }
827
+ post = diff.after;
828
+ } else if (diff.format === "unified") {
829
+ try {
830
+ post = applyUnified(pre, diff.patch);
831
+ } catch (e) {
832
+ throw new Error(`cannot apply unified diff to ${patch.target}: ${e.message}`);
833
+ }
834
+ } else {
835
+ throw new Error(`unsupported diff.format ${JSON.stringify(diff.format)}`);
836
+ }
837
+
838
+ // Read the ledger BEFORE touching the surface — an unreadable/corrupt ledger
839
+ // must fail closed (exit 2) with the surface untouched and nothing recorded.
840
+ const entries = readLedger(paths);
841
+ const last = entries[entries.length - 1];
842
+
843
+ // Snapshot both images, then write the surface, then record — the object store
844
+ // holds the bytes; the entry references them by hash.
845
+ const preRef = writeBlob(paths, pre);
846
+ const postRef = writeBlob(paths, post);
847
+ writeSurface(absTarget, post);
848
+
849
+ const entry = buildAppliedEntry({
850
+ patch,
851
+ target: patch.target,
852
+ pre: preRef,
853
+ post: postRef,
854
+ ts: new Date().toISOString(),
855
+ seq: entries.length,
856
+ prev: last ? last.hash : null,
857
+ reason,
858
+ });
859
+ appendEntry(paths, entry);
860
+ return entry;
861
+ }
862
+
863
+ function runApply(argv) {
864
+ const { opts, positionals } = parseLedgerArgs(argv, APPLY_FLAGS, LEDGER_BOOL_FLAGS);
865
+ if (positionals.length === 0) {
866
+ fail(`error: apply needs a patch file\n\n${USAGE}`);
867
+ }
868
+ if (positionals.length > 1) {
869
+ fail(`error: apply takes a single patch file\n\n${USAGE}`);
870
+ }
871
+
872
+ const raw = readSource(positionals[0], "patch");
873
+ let patch;
874
+ try {
875
+ patch = JSON.parse(raw);
876
+ } catch (e) {
877
+ fail(`error: ${positionals[0]}: invalid JSON: ${e.message}`);
878
+ }
879
+
880
+ // Never mutate a surface for an invalid patch — validate first, print the same
881
+ // error lines as `validate`, and exit 2 (fail closed).
882
+ const result = validateSelfPatch(patch);
883
+ if (!result.valid) {
884
+ process.stderr.write(`✗ ${positionals[0]} — ${result.errors.length} error(s)\n`);
885
+ for (const er of result.errors) {
886
+ const loc = er.path === "" ? "(root)" : er.path;
887
+ process.stderr.write(` ${loc}: ${er.message} [${er.code}]\n`);
888
+ }
889
+ process.exit(2);
890
+ }
891
+
892
+ let entry;
893
+ try {
894
+ entry = applyPatchToLedger(patch, { dir: opts.dir, reason: opts.reason });
895
+ } catch (e) {
896
+ // Drift (before_after target ≠ diff.before) or an unappliable/unsupported
897
+ // diff fails closed at exit 2 — the surface is left untouched, nothing
898
+ // recorded. IO problems already exit 2 via the injected helpers.
899
+ fail(`error: ${e.message}`);
900
+ }
901
+
902
+ if (opts.json) {
903
+ process.stdout.write(JSON.stringify(entry, null, 2) + "\n");
904
+ } else {
905
+ process.stdout.write(
906
+ `applied ${shortHash(entry.hash)} — ${entry.patch_id} → ${entry.target}\n`
907
+ );
908
+ }
909
+ process.exit(0);
910
+ }
911
+
912
+ function runRevert(argv) {
913
+ const { opts, positionals } = parseLedgerArgs(argv, REVERT_FLAGS, LEDGER_BOOL_FLAGS);
914
+ if (positionals.length === 0) {
915
+ fail(`error: revert needs a patch id\n\n${USAGE}`);
916
+ }
917
+ if (positionals.length > 1) {
918
+ fail(`error: revert takes a single patch id\n\n${USAGE}`);
919
+ }
920
+ const id = positionals[0];
921
+
922
+ const paths = ledgerPaths(opts.dir);
923
+ const entries = readLedger(paths);
924
+
925
+ // Refuse to act on a tampered ledger — reading it IS the integrity check.
926
+ const integrity = verifyChain(entries);
927
+ if (!integrity.valid) {
928
+ fail(`error: ledger chain broken at seq ${integrity.brokenAt}; refusing to act on a tampered ledger`);
929
+ }
930
+
931
+ let found;
932
+ try {
933
+ found = findRevertable(entries, id);
934
+ } catch (e) {
935
+ if (e instanceof LedgerError) {
936
+ fail(`error: ${e.message}`);
937
+ }
938
+ throw e;
939
+ }
940
+ if (found.entry === null) {
941
+ const why =
942
+ found.reason === "already-reverted"
943
+ ? "its most recent apply is already reverted"
944
+ : "no applied entry for that id";
945
+ process.stdout.write(`nothing to revert — ${why}\n`);
946
+ process.exit(1);
947
+ }
948
+
949
+ const applied = found.entry;
950
+ const absTarget = resolveTarget(applied.target);
951
+
952
+ // Snapshot the current bytes (the post-apply state) as the reverted entry's
953
+ // `pre`, then restore the applied entry's pre-image.
954
+ const beforeRevert = readSurface(absTarget);
955
+ const preRef = writeBlob(paths, beforeRevert);
956
+
957
+ if (applied.pre === null) {
958
+ // The apply created this file — undo means delete it.
959
+ removeSurface(absTarget);
960
+ } else {
961
+ writeSurface(absTarget, readBlob(paths, applied.pre));
962
+ }
963
+ const postRef = applied.pre; // the restored content's ref (or null ⇒ deleted)
964
+
965
+ const last = entries[entries.length - 1];
966
+ const entry = buildRevertedEntry({
967
+ patchId: applied.patch_id,
968
+ target: applied.target,
969
+ reverts: applied.hash,
970
+ pre: preRef,
971
+ post: postRef,
972
+ ts: new Date().toISOString(),
973
+ seq: entries.length,
974
+ prev: last ? last.hash : null,
975
+ });
976
+ appendEntry(paths, entry);
977
+
978
+ if (opts.json) {
979
+ process.stdout.write(JSON.stringify(entry, null, 2) + "\n");
980
+ } else {
981
+ process.stdout.write(
982
+ `reverted ${shortHash(entry.hash)} — ${entry.patch_id} → ${entry.target}\n`
983
+ );
984
+ }
985
+ process.exit(0);
986
+ }
987
+
988
+ function runLog(argv) {
989
+ const { opts, positionals } = parseLedgerArgs(argv, LOG_FLAGS, LEDGER_BOOL_FLAGS);
990
+ if (positionals.length > 0) {
991
+ fail(`error: log takes no positional arguments\n\n${USAGE}`);
992
+ }
993
+
994
+ const paths = ledgerPaths(opts.dir);
995
+ const entries = readLedger(paths);
996
+ // Verify the FULL chain (before any --id filtering, which would break indices).
997
+ const integrity = verifyChain(entries);
998
+
999
+ const shown =
1000
+ opts.id !== undefined
1001
+ ? entries.filter((e) => typeof e.patch_id === "string" && e.patch_id.startsWith(opts.id))
1002
+ : entries;
1003
+
1004
+ if (opts.json) {
1005
+ process.stdout.write(
1006
+ JSON.stringify({ entries: shown, integrity }, null, 2) + "\n"
1007
+ );
1008
+ } else {
1009
+ // Newest-first for a human skim (git log feel).
1010
+ for (let i = shown.length - 1; i >= 0; i--) {
1011
+ const e = shown[i];
1012
+ const rev =
1013
+ e.event === "reverted" && e.reverts ? ` ⟲ reverts ${shortHash(e.reverts)}` : "";
1014
+ process.stdout.write(
1015
+ `${e.seq} ${e.event} ${shortHash(e.hash)} ${shortHash(e.patch_id)} ${e.target} ${e.ts}${rev}\n`
1016
+ );
1017
+ }
1018
+ process.stdout.write(
1019
+ integrity.valid
1020
+ ? `✓ chain intact (${entries.length} entries)\n`
1021
+ : `✗ chain broken at seq ${integrity.brokenAt}\n`
1022
+ );
1023
+ }
1024
+ process.exit(integrity.valid ? 0 : 1);
1025
+ }
1026
+
1027
+ // --- guard-skill-edit: the Foundry dogfood pipeline -------------------------
1028
+ //
1029
+ // Diff two `.agents/skills` trees into one scaffolding self-patch per changed
1030
+ // skill (the pure adapter), then run the shipped verify → gate → (optional) apply
1031
+ // over each and report a per-skill verdict. The eval command and policy are
1032
+ // operator-supplied — never anything from the skills diff.
1033
+
1034
+ // Recursively walk `dir` into a `{ posixRelPath → content }` map (UTF-8). Uses
1035
+ // hand-rolled recursion (not readdirSync's `recursive` option) to stay Node ≥18
1036
+ // compatible, and POSIX-joins rel paths so `target`s are stable across OSes. A
1037
+ // missing/unreadable dir ⇒ exit 2 (fail closed).
1038
+ function readTree(dir) {
1039
+ const out = {};
1040
+ const walk = (abs, prefix) => {
1041
+ let entries;
1042
+ try {
1043
+ entries = readdirSync(abs, { withFileTypes: true });
1044
+ } catch (e) {
1045
+ fail(
1046
+ `error: cannot read directory ${abs}: ${e.code === "ENOENT" ? "no such directory" : e.message}`
1047
+ );
1048
+ }
1049
+ for (const ent of entries) {
1050
+ const childAbs = join(abs, ent.name);
1051
+ const rel = prefix === "" ? ent.name : `${prefix}/${ent.name}`;
1052
+ if (ent.isDirectory()) {
1053
+ walk(childAbs, rel);
1054
+ } else if (ent.isFile()) {
1055
+ let content;
1056
+ try {
1057
+ content = readFileSync(childAbs, "utf8");
1058
+ } catch (e) {
1059
+ fail(`error: cannot read ${childAbs}: ${e.message}`);
1060
+ }
1061
+ out[rel] = content;
1062
+ }
1063
+ // Symlinks / other node types are skipped — the skills tree is regular files.
1064
+ }
1065
+ };
1066
+ walk(dir, "");
1067
+ return out;
1068
+ }
1069
+
1070
+ const GUARD_FLAGS = new Set([
1071
+ "--policy", "--skills-root", "--eval", "--author", "--rationale",
1072
+ "--requires", "--dir", "--cwd", "--baseline", "--timeout", "--apply", "--json",
1073
+ ]);
1074
+ const GUARD_BOOL_FLAGS = new Set(["--apply", "--json"]);
1075
+
1076
+ function parseGuardArgs(argv) {
1077
+ const opts = {};
1078
+ const positionals = [];
1079
+ for (let i = 0; i < argv.length; i++) {
1080
+ const arg = argv[i];
1081
+ // `-p` is the documented short alias for `--policy` (as in `gate`).
1082
+ if (arg === "-p") {
1083
+ const value = argv[++i];
1084
+ if (value === undefined) fail(`error: -p needs a value\n\n${USAGE}`);
1085
+ opts.policy = value;
1086
+ continue;
1087
+ }
1088
+ if (!arg.startsWith("--")) {
1089
+ positionals.push(arg);
1090
+ continue;
1091
+ }
1092
+ let key = arg;
1093
+ let inlineValue = null;
1094
+ const eq = arg.indexOf("=");
1095
+ if (eq !== -1) {
1096
+ key = arg.slice(0, eq);
1097
+ inlineValue = arg.slice(eq + 1);
1098
+ }
1099
+ if (!GUARD_FLAGS.has(key)) {
1100
+ fail(`error: unknown flag ${arg}\n\n${USAGE}`);
1101
+ }
1102
+ if (GUARD_BOOL_FLAGS.has(key)) {
1103
+ opts[key.slice(2)] = true;
1104
+ continue;
1105
+ }
1106
+ const value = inlineValue !== null ? inlineValue : argv[++i];
1107
+ if (value === undefined) {
1108
+ fail(`error: ${key} needs a value\n\n${USAGE}`);
1109
+ }
1110
+ opts[key.slice(2)] = value;
1111
+ }
1112
+ return { opts, positionals };
1113
+ }
1114
+
1115
+ function runGuardSkillEdit(argv) {
1116
+ const { opts, positionals } = parseGuardArgs(argv);
1117
+ if (positionals.length < 2) {
1118
+ fail(`error: guard-skill-edit needs <before-dir> <after-dir>\n\n${USAGE}`);
1119
+ }
1120
+ if (positionals.length > 2) {
1121
+ fail(`error: guard-skill-edit takes exactly <before-dir> <after-dir>\n\n${USAGE}`);
1122
+ }
1123
+ if (opts.policy === undefined) {
1124
+ fail(`error: guard-skill-edit requires --policy <policy.json>\n\n${USAGE}`);
1125
+ }
1126
+
1127
+ let timeout;
1128
+ if (opts.timeout !== undefined) {
1129
+ timeout = Number(opts.timeout);
1130
+ if (!Number.isFinite(timeout) || timeout < 0) {
1131
+ fail(`error: --timeout must be a non-negative number of milliseconds\n\n${USAGE}`);
1132
+ }
1133
+ }
1134
+
1135
+ const before = readTree(positionals[0]);
1136
+ const after = readTree(positionals[1]);
1137
+ const policy = readValidatedJson(opts.policy, "policy", validatePolicy);
1138
+
1139
+ const skillsRoot = opts["skills-root"] !== undefined ? opts["skills-root"] : ".agents/skills";
1140
+
1141
+ // Build one scaffolding self-patch per changed skill. A patch that fails the #1
1142
+ // schema surfaces as a ProposeError ⇒ exit 2 (fail closed).
1143
+ let proposal;
1144
+ try {
1145
+ proposal = proposeSkillEdits(before, after, {
1146
+ skillsRoot,
1147
+ author: opts.author !== undefined ? opts.author : "agent://foundry-retro",
1148
+ rationale: opts.rationale,
1149
+ evalCommand: opts.eval !== undefined ? opts.eval : "npm test",
1150
+ requires: opts.requires !== undefined ? opts.requires : "human-gate",
1151
+ });
1152
+ } catch (e) {
1153
+ if (Array.isArray(e.errors)) {
1154
+ process.stderr.write(e.message + "\n");
1155
+ process.exit(2);
1156
+ }
1157
+ fail(`error: ${e.message}`);
1158
+ }
1159
+
1160
+ const { patches, changes } = proposal;
1161
+ if (patches.length === 0 && changes.removed.length === 0) {
1162
+ process.stdout.write("no skill changes\n");
1163
+ process.exit(0);
1164
+ }
1165
+
1166
+ const cwdDir = opts.cwd !== undefined ? opts.cwd : process.cwd();
1167
+ const baselineDir = opts.baseline !== undefined ? opts.baseline : cwdDir;
1168
+ const runCheck = makeRunCheck({ cwdDir, baselineDir, timeout });
1169
+
1170
+ const rows = [];
1171
+
1172
+ for (const patch of patches) {
1173
+ // Verify the held-out check outside the loop, then gate under the policy.
1174
+ let verdict;
1175
+ try {
1176
+ verdict = verifySelfPatch(patch, { runCheck });
1177
+ } catch (e) {
1178
+ // An unrunnable check fails closed at exit 2 — never a silent pass.
1179
+ fail(`error: ${e.message}`);
1180
+ }
1181
+ const gateVerdict = gate(patch, policy);
1182
+
1183
+ // Fail-closed precedence: a red/regressed check OR a gate block ⇒ blocked;
1184
+ // an effective human-gate ⇒ needs-human; else approved.
1185
+ let outcome;
1186
+ let reason;
1187
+ if (!verdict.passed || verdict.regressed) {
1188
+ outcome = "blocked";
1189
+ reason = verdict.regressed
1190
+ ? "regressed its held-out check"
1191
+ : "failed its held-out check";
1192
+ } else if (gateVerdict.decision === "block") {
1193
+ outcome = "blocked";
1194
+ reason = gateVerdict.reason;
1195
+ } else if (gateVerdict.decision === "human-gate") {
1196
+ outcome = "needs-human";
1197
+ reason = gateVerdict.reason;
1198
+ } else {
1199
+ outcome = "approved";
1200
+ reason = gateVerdict.reason;
1201
+ }
1202
+
1203
+ let applied = false;
1204
+ if (opts.apply && outcome === "approved") {
1205
+ try {
1206
+ applyPatchToLedger(patch, {
1207
+ dir: opts.dir,
1208
+ reason: `guard-skill-edit: gate approved (${gateVerdict.reason})`,
1209
+ });
1210
+ applied = true;
1211
+ } catch (e) {
1212
+ // Drift/unappliable at apply time fails this patch closed (blocked); the
1213
+ // live surface is untouched and nothing is recorded.
1214
+ outcome = "blocked";
1215
+ reason = `apply refused: ${e.message}`;
1216
+ }
1217
+ }
1218
+
1219
+ rows.push({
1220
+ target: patch.target,
1221
+ patch_id: patch.id,
1222
+ verify: { passed: verdict.passed, regressed: verdict.regressed },
1223
+ gate: { decision: gateVerdict.decision, reason: gateVerdict.reason },
1224
+ outcome,
1225
+ reason,
1226
+ applied,
1227
+ });
1228
+ }
1229
+
1230
+ // A removed skill is surfaced for a human — never auto-actuated in v0.1 (a
1231
+ // `before_after` patch with after:"" would blank the file, not delete it).
1232
+ for (const rel of changes.removed) {
1233
+ rows.push({
1234
+ target: skillTargetPath(skillsRoot, rel),
1235
+ patch_id: null,
1236
+ verify: null,
1237
+ gate: null,
1238
+ outcome: "needs-human",
1239
+ reason: "removal unsupported in v0.1 — needs a human",
1240
+ applied: false,
1241
+ });
1242
+ }
1243
+
1244
+ if (opts.json) {
1245
+ const out = rows.map((r) => ({
1246
+ target: r.target,
1247
+ patch_id: r.patch_id,
1248
+ verify: r.verify,
1249
+ gate: r.gate,
1250
+ outcome: r.outcome,
1251
+ applied: r.applied,
1252
+ }));
1253
+ process.stdout.write(JSON.stringify(out, null, 2) + "\n");
1254
+ } else {
1255
+ for (const r of rows) {
1256
+ const mark =
1257
+ r.outcome === "approved" ? "✓" : r.outcome === "needs-human" ? "⚠" : "✗";
1258
+ const v = r.verify
1259
+ ? r.verify.regressed
1260
+ ? "regressed"
1261
+ : r.verify.passed
1262
+ ? "pass"
1263
+ : "fail"
1264
+ : "—";
1265
+ const d = r.gate ? r.gate.decision : "—";
1266
+ const appliedNote = r.applied ? " [applied]" : "";
1267
+ process.stdout.write(
1268
+ `${mark} ${r.target} — verify:${v} gate:${d} → ${r.outcome} (${r.reason})${appliedNote}\n`
1269
+ );
1270
+ }
1271
+ }
1272
+
1273
+ // Worst outcome wins (fail closed): any blocked ⇒ 3; else any needs-human ⇒ 1;
1274
+ // else 0. (2 is reserved for the usage/IO/schema/unrunnable failures above.)
1275
+ const anyBlocked = rows.some((r) => r.outcome === "blocked");
1276
+ const anyHuman = rows.some((r) => r.outcome === "needs-human");
1277
+ process.exit(anyBlocked ? 3 : anyHuman ? 1 : 0);
1278
+ }
1279
+
1280
+ function main() {
1281
+ const argv = process.argv.slice(2);
1282
+ const sub = argv[0];
1283
+
1284
+ if (!sub || sub === "--help" || sub === "-h") {
1285
+ process.stdout.write(USAGE + "\n");
1286
+ process.exit(sub ? 0 : 2);
1287
+ }
1288
+
1289
+ if (sub === "propose") {
1290
+ runPropose(argv.slice(1));
1291
+ return;
1292
+ }
1293
+
1294
+ if (sub === "verify") {
1295
+ runVerify(argv.slice(1));
1296
+ return;
1297
+ }
1298
+
1299
+ if (sub === "gate") {
1300
+ runGate(argv.slice(1));
1301
+ return;
1302
+ }
1303
+
1304
+ if (sub === "apply") {
1305
+ runApply(argv.slice(1));
1306
+ return;
1307
+ }
1308
+
1309
+ if (sub === "revert") {
1310
+ runRevert(argv.slice(1));
1311
+ return;
1312
+ }
1313
+
1314
+ if (sub === "log") {
1315
+ runLog(argv.slice(1));
1316
+ return;
1317
+ }
1318
+
1319
+ if (sub === "guard-skill-edit") {
1320
+ runGuardSkillEdit(argv.slice(1));
1321
+ return;
1322
+ }
1323
+
1324
+ if (sub !== "validate") {
1325
+ fail(`error: unknown subcommand "${sub}"\n\n${USAGE}`);
1326
+ }
1327
+
1328
+ const { files, json, as } = parseArgs(argv.slice(1));
1329
+ if (files.length === 0) {
1330
+ fail(`error: validate needs at least one file\n\n${USAGE}`);
1331
+ }
1332
+
1333
+ const results = [];
1334
+ let anyInvalid = false;
1335
+
1336
+ for (const file of files) {
1337
+ let raw;
1338
+ try {
1339
+ raw = readFileSync(file, "utf8");
1340
+ } catch (e) {
1341
+ fail(`error: cannot read ${file}: ${e.code === "ENOENT" ? "no such file" : e.message}`);
1342
+ }
1343
+
1344
+ let doc;
1345
+ try {
1346
+ doc = JSON.parse(raw);
1347
+ } catch (e) {
1348
+ fail(`error: ${file}: invalid JSON: ${e.message}`);
1349
+ }
1350
+
1351
+ const validator = pickValidator(doc, as);
1352
+ const result = validator(doc);
1353
+ results.push(result);
1354
+ if (!result.valid) anyInvalid = true;
1355
+
1356
+ if (!json) {
1357
+ if (result.valid) {
1358
+ process.stdout.write(`✓ ${file} valid\n`);
1359
+ } else {
1360
+ process.stdout.write(`✗ ${file} — ${result.errors.length} error(s)\n`);
1361
+ for (const er of result.errors) {
1362
+ const loc = er.path === "" ? "(root)" : er.path;
1363
+ process.stdout.write(` ${loc}: ${er.message} [${er.code}]\n`);
1364
+ }
1365
+ }
1366
+ }
1367
+ }
1368
+
1369
+ if (json) {
1370
+ const out = results.length === 1 ? results[0] : results;
1371
+ process.stdout.write(JSON.stringify(out, null, 2) + "\n");
1372
+ }
1373
+
1374
+ process.exit(anyInvalid ? 1 : 0);
1375
+ }
1376
+
1377
+ main();