@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.
- package/LICENSE +21 -0
- package/README.md +63 -0
- package/bin/selfpatch.js +1377 -0
- package/package.json +46 -0
- package/src/adapters/claude-code-skills.js +144 -0
- package/src/canonical.js +24 -0
- package/src/gate.js +232 -0
- package/src/index.js +36 -0
- package/src/ledger.js +202 -0
- package/src/propose.js +170 -0
- package/src/schema.js +344 -0
- package/src/sha256.js +93 -0
- package/src/verify.js +103 -0
package/bin/selfpatch.js
ADDED
|
@@ -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();
|