altimate-receipts 0.3.2 → 0.4.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/README.md +22 -38
- package/dist/cli.js +519 -66
- package/dist/cli.js.map +1 -1
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -45,14 +45,291 @@ import {
|
|
|
45
45
|
} from "./chunk-UHI6BGLE.js";
|
|
46
46
|
|
|
47
47
|
// src/cli.ts
|
|
48
|
-
import { spawnSync } from "child_process";
|
|
49
|
-
import {
|
|
50
|
-
|
|
48
|
+
import { spawnSync as spawnSync3 } from "child_process";
|
|
49
|
+
import {
|
|
50
|
+
existsSync as existsSync4,
|
|
51
|
+
mkdirSync as mkdirSync2,
|
|
52
|
+
readFileSync as readFileSync6,
|
|
53
|
+
readdirSync as readdirSync3,
|
|
54
|
+
realpathSync,
|
|
55
|
+
unlinkSync,
|
|
56
|
+
writeFileSync as writeFileSync3
|
|
57
|
+
} from "fs";
|
|
58
|
+
import { homedir } from "os";
|
|
59
|
+
import { join as join6, relative } from "path";
|
|
51
60
|
import { pathToFileURL } from "url";
|
|
52
61
|
|
|
62
|
+
// src/hook/installGitHook.ts
|
|
63
|
+
import { spawnSync } from "child_process";
|
|
64
|
+
import { chmodSync, existsSync, readFileSync, renameSync, writeFileSync } from "fs";
|
|
65
|
+
import { isAbsolute, join } from "path";
|
|
66
|
+
var MARKER = "altimate-receipts";
|
|
67
|
+
var HOOK_SCRIPT = `#!/bin/sh
|
|
68
|
+
# Verified-by-Receipts pre-push hook \u2014 generated by \`receipts install-hook\` (altimate-receipts).
|
|
69
|
+
# Attaches this branch's agent receipt before pushing. Best-effort: only the
|
|
70
|
+
# deliberate "receipt attached, push again" signal (exit 42) ever stops a push.
|
|
71
|
+
hookdir=$(dirname "$0")
|
|
72
|
+
if [ -x "$hookdir/pre-push.local" ]; then
|
|
73
|
+
"$hookdir/pre-push.local" "$@" </dev/null || exit $?
|
|
74
|
+
fi
|
|
75
|
+
command -v npx >/dev/null 2>&1 || exit 0
|
|
76
|
+
npx -y altimate-receipts@latest hook pre-push --agent git
|
|
77
|
+
[ "$?" -eq 42 ] && exit 1
|
|
78
|
+
exit 0
|
|
79
|
+
`;
|
|
80
|
+
function hooksDir(cwd) {
|
|
81
|
+
const r = spawnSync("git", ["rev-parse", "--git-path", "hooks"], { encoding: "utf8", cwd });
|
|
82
|
+
if (r.status !== 0) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
const p = r.stdout.trim();
|
|
86
|
+
return isAbsolute(p) ? p : join(cwd ?? process.cwd(), p);
|
|
87
|
+
}
|
|
88
|
+
function installGitHook(cwd) {
|
|
89
|
+
const dir = hooksDir(cwd);
|
|
90
|
+
if (!dir) {
|
|
91
|
+
return { ok: false, reason: "not a git repository" };
|
|
92
|
+
}
|
|
93
|
+
const target = join(dir, "pre-push");
|
|
94
|
+
const local = join(dir, "pre-push.local");
|
|
95
|
+
if (existsSync(target)) {
|
|
96
|
+
const current = readFileSync(target, "utf8");
|
|
97
|
+
if (current.includes(MARKER)) {
|
|
98
|
+
if (current === HOOK_SCRIPT) {
|
|
99
|
+
return { ok: true, action: "unchanged" };
|
|
100
|
+
}
|
|
101
|
+
writeFileSync(target, HOOK_SCRIPT);
|
|
102
|
+
chmodSync(target, 493);
|
|
103
|
+
return { ok: true, action: "installed" };
|
|
104
|
+
}
|
|
105
|
+
if (existsSync(local)) {
|
|
106
|
+
return {
|
|
107
|
+
ok: false,
|
|
108
|
+
reason: `${target} and ${local} both exist and aren't receipts hooks \u2014 resolve manually`
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
renameSync(target, local);
|
|
112
|
+
writeFileSync(target, HOOK_SCRIPT);
|
|
113
|
+
chmodSync(target, 493);
|
|
114
|
+
return { ok: true, action: "chained" };
|
|
115
|
+
}
|
|
116
|
+
writeFileSync(target, HOOK_SCRIPT);
|
|
117
|
+
chmodSync(target, 493);
|
|
118
|
+
return { ok: true, action: "installed" };
|
|
119
|
+
}
|
|
120
|
+
var PREPARE_CMD = "npx -y altimate-receipts@latest install-hook";
|
|
121
|
+
function wirePrepareScript(pkgPath) {
|
|
122
|
+
if (!existsSync(pkgPath)) {
|
|
123
|
+
return { ok: false, reason: `${pkgPath} not found \u2014 \`--prepare\` needs a package.json` };
|
|
124
|
+
}
|
|
125
|
+
const raw = readFileSync(pkgPath, "utf8");
|
|
126
|
+
let pkg;
|
|
127
|
+
try {
|
|
128
|
+
pkg = JSON.parse(raw);
|
|
129
|
+
} catch {
|
|
130
|
+
return { ok: false, reason: `${pkgPath} is not valid JSON \u2014 leaving it untouched` };
|
|
131
|
+
}
|
|
132
|
+
if (pkg.scripts === void 0) {
|
|
133
|
+
pkg.scripts = {};
|
|
134
|
+
}
|
|
135
|
+
const scripts = pkg.scripts;
|
|
136
|
+
const prepare = scripts.prepare;
|
|
137
|
+
if (prepare?.includes(PREPARE_CMD)) {
|
|
138
|
+
return { ok: true, changed: false };
|
|
139
|
+
}
|
|
140
|
+
scripts.prepare = prepare ? `${prepare} && ${PREPARE_CMD}` : PREPARE_CMD;
|
|
141
|
+
const indent = raw.match(/^(\s+)"/m)?.[1] ?? " ";
|
|
142
|
+
writeFileSync(pkgPath, `${JSON.stringify(pkg, null, indent)}
|
|
143
|
+
`);
|
|
144
|
+
return { ok: true, changed: true };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// src/hook/prePush.ts
|
|
148
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
149
|
+
import { existsSync as existsSync2 } from "fs";
|
|
150
|
+
import { join as join2 } from "path";
|
|
151
|
+
var REPUSH_EXIT = 42;
|
|
152
|
+
var ATTACH_SUBJECT = (branch) => `chore(receipts): attach agent receipt for ${branch}`;
|
|
153
|
+
function git(args, cwd) {
|
|
154
|
+
const r = spawnSync2("git", args, { encoding: "utf8", cwd });
|
|
155
|
+
return r.status === 0 ? r.stdout.trim() : "";
|
|
156
|
+
}
|
|
157
|
+
var WRAPPERS = /* @__PURE__ */ new Set(["command", "exec", "nohup", "time", "env"]);
|
|
158
|
+
var GIT_VALUE_FLAGS = /* @__PURE__ */ new Set(["-C", "-c", "--git-dir", "--work-tree", "--exec-path"]);
|
|
159
|
+
var TAG_REFSPEC = /^(refs\/tags\/|v\d+(\.\d+)*$)/;
|
|
160
|
+
function isGitPush(command) {
|
|
161
|
+
const blanked = command.replace(/\\["']/g, " ").replace(/'[^']*'/g, " ").replace(/"[^"]*"/g, " ");
|
|
162
|
+
for (const simple of blanked.split(/(?:&&|\|\||[;|\n])/)) {
|
|
163
|
+
const tokens = simple.trim().split(/\s+/).filter(Boolean);
|
|
164
|
+
let i = 0;
|
|
165
|
+
while (i < tokens.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i]) || WRAPPERS.has(tokens[i]))) {
|
|
166
|
+
i++;
|
|
167
|
+
}
|
|
168
|
+
if (tokens[i] !== "git") {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
i++;
|
|
172
|
+
while (i < tokens.length && tokens[i].startsWith("-")) {
|
|
173
|
+
const flag = tokens[i];
|
|
174
|
+
i++;
|
|
175
|
+
if (GIT_VALUE_FLAGS.has(flag)) {
|
|
176
|
+
i++;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (tokens[i] !== "push") {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const rest = tokens.slice(i + 1);
|
|
183
|
+
if (rest.includes("--dry-run") || rest.includes("-n") || rest.includes("--tags")) {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const positionals = rest.filter((t) => !t.startsWith("-"));
|
|
187
|
+
const refspecs = positionals.slice(1);
|
|
188
|
+
if (refspecs.length > 0 && refspecs.every((r) => TAG_REFSPEC.test(r))) {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
function gitStdinPushesBranch(stdin) {
|
|
196
|
+
return stdin.split("\n").some((line) => line.trim().startsWith("refs/heads/"));
|
|
197
|
+
}
|
|
198
|
+
function repoOptedIn(repoRoot) {
|
|
199
|
+
return existsSync2(join2(repoRoot, ".github", "workflows", "receipts.yml")) || existsSync2(join2(repoRoot, ".receipts"));
|
|
200
|
+
}
|
|
201
|
+
function headIsAttachCommit(branch, cwd) {
|
|
202
|
+
return git(["log", "-1", "--format=%s"], cwd) === ATTACH_SUBJECT(branch);
|
|
203
|
+
}
|
|
204
|
+
async function readStdin(stream = process.stdin) {
|
|
205
|
+
let data = "";
|
|
206
|
+
for await (const chunk of stream) {
|
|
207
|
+
data += chunk;
|
|
208
|
+
}
|
|
209
|
+
return data;
|
|
210
|
+
}
|
|
211
|
+
async function runHookPrePush(dialect, stdin, generate) {
|
|
212
|
+
try {
|
|
213
|
+
if (process.env.RECEIPTS_HOOK === "0") {
|
|
214
|
+
return { exit: 0 };
|
|
215
|
+
}
|
|
216
|
+
if ((process.env.RECEIPTS_STORE || "commit").toLowerCase() === "none") {
|
|
217
|
+
return { exit: 0 };
|
|
218
|
+
}
|
|
219
|
+
if (dialect === "claude") {
|
|
220
|
+
let payload;
|
|
221
|
+
try {
|
|
222
|
+
payload = JSON.parse(stdin);
|
|
223
|
+
} catch {
|
|
224
|
+
return { exit: 0 };
|
|
225
|
+
}
|
|
226
|
+
if (payload.tool_name !== "Bash" || !payload.tool_input?.command) {
|
|
227
|
+
return { exit: 0 };
|
|
228
|
+
}
|
|
229
|
+
if (payload.cwd && existsSync2(payload.cwd)) {
|
|
230
|
+
process.chdir(payload.cwd);
|
|
231
|
+
}
|
|
232
|
+
if (!isGitPush(payload.tool_input.command)) {
|
|
233
|
+
return { exit: 0 };
|
|
234
|
+
}
|
|
235
|
+
} else if (!gitStdinPushesBranch(stdin)) {
|
|
236
|
+
return { exit: 0 };
|
|
237
|
+
}
|
|
238
|
+
const repoRoot = git(["rev-parse", "--show-toplevel"]);
|
|
239
|
+
if (!repoRoot || !repoOptedIn(repoRoot)) {
|
|
240
|
+
return { exit: 0 };
|
|
241
|
+
}
|
|
242
|
+
const branch = git(["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
243
|
+
if (!branch || branch === "HEAD") {
|
|
244
|
+
return { exit: 0 };
|
|
245
|
+
}
|
|
246
|
+
if (dialect === "git" && headIsAttachCommit(branch)) {
|
|
247
|
+
return { exit: 0 };
|
|
248
|
+
}
|
|
249
|
+
const write = process.stderr.write.bind(process.stderr);
|
|
250
|
+
process.stderr.write = (() => true);
|
|
251
|
+
let generated;
|
|
252
|
+
try {
|
|
253
|
+
generated = await generate();
|
|
254
|
+
} finally {
|
|
255
|
+
process.stderr.write = write;
|
|
256
|
+
}
|
|
257
|
+
if (generated !== 0) {
|
|
258
|
+
return { exit: 0 };
|
|
259
|
+
}
|
|
260
|
+
if (!git(["status", "--porcelain", ".receipts/"])) {
|
|
261
|
+
return { exit: 0 };
|
|
262
|
+
}
|
|
263
|
+
spawnSync2("git", ["add", ".receipts/"], { encoding: "utf8" });
|
|
264
|
+
const commit = spawnSync2(
|
|
265
|
+
"git",
|
|
266
|
+
["commit", "--no-verify", "-m", ATTACH_SUBJECT(branch), "--", ".receipts/"],
|
|
267
|
+
{ encoding: "utf8" }
|
|
268
|
+
);
|
|
269
|
+
if (commit.status !== 0) {
|
|
270
|
+
return { exit: 0 };
|
|
271
|
+
}
|
|
272
|
+
if (dialect === "git") {
|
|
273
|
+
return {
|
|
274
|
+
exit: REPUSH_EXIT,
|
|
275
|
+
message: `\u{1F4CE} Receipts: attached an agent receipt for '${branch}' \u2014 run \`git push\` again to include it.`
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
return {
|
|
279
|
+
exit: 0,
|
|
280
|
+
message: `receipts: attached an agent receipt for '${branch}' \u2014 it rides this push.`
|
|
281
|
+
};
|
|
282
|
+
} catch {
|
|
283
|
+
return { exit: 0 };
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// src/hook/settingsMerge.ts
|
|
288
|
+
import { existsSync as existsSync3, mkdirSync, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
289
|
+
import { dirname } from "path";
|
|
290
|
+
var HOOK_COMMAND = "npx -y altimate-receipts@latest hook pre-push";
|
|
291
|
+
function mergeHookIntoSettings(path) {
|
|
292
|
+
let settings = {};
|
|
293
|
+
if (existsSync3(path)) {
|
|
294
|
+
let parsed;
|
|
295
|
+
try {
|
|
296
|
+
parsed = JSON.parse(readFileSync2(path, "utf8"));
|
|
297
|
+
} catch {
|
|
298
|
+
return { ok: false, reason: `${path} is not valid JSON \u2014 leaving it untouched` };
|
|
299
|
+
}
|
|
300
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
301
|
+
return { ok: false, reason: `${path} is not a JSON object \u2014 leaving it untouched` };
|
|
302
|
+
}
|
|
303
|
+
settings = parsed;
|
|
304
|
+
}
|
|
305
|
+
if (settings.hooks === void 0) {
|
|
306
|
+
settings.hooks = {};
|
|
307
|
+
}
|
|
308
|
+
const hooks = settings.hooks;
|
|
309
|
+
if (typeof hooks !== "object" || hooks === null || Array.isArray(hooks)) {
|
|
310
|
+
return { ok: false, reason: `${path} has a non-object "hooks" key \u2014 leaving it untouched` };
|
|
311
|
+
}
|
|
312
|
+
if (hooks.PreToolUse === void 0) {
|
|
313
|
+
hooks.PreToolUse = [];
|
|
314
|
+
}
|
|
315
|
+
const pre = hooks.PreToolUse;
|
|
316
|
+
if (!Array.isArray(pre)) {
|
|
317
|
+
return { ok: false, reason: `${path} has a non-array hooks.PreToolUse \u2014 leaving it untouched` };
|
|
318
|
+
}
|
|
319
|
+
const present = pre.some((e) => e?.hooks?.some((h) => h?.command === HOOK_COMMAND));
|
|
320
|
+
if (present) {
|
|
321
|
+
return { ok: true, changed: false };
|
|
322
|
+
}
|
|
323
|
+
pre.push({ matcher: "Bash", hooks: [{ type: "command", command: HOOK_COMMAND }] });
|
|
324
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
325
|
+
writeFileSync2(path, `${JSON.stringify(settings, null, 2)}
|
|
326
|
+
`);
|
|
327
|
+
return { ok: true, changed: true };
|
|
328
|
+
}
|
|
329
|
+
|
|
53
330
|
// src/receipt/assert.ts
|
|
54
|
-
import { readFileSync } from "fs";
|
|
55
|
-
import { join } from "path";
|
|
331
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
332
|
+
import { join as join3 } from "path";
|
|
56
333
|
var OPS = /* @__PURE__ */ new Set([
|
|
57
334
|
"eq",
|
|
58
335
|
"ne",
|
|
@@ -147,10 +424,10 @@ function validateAssertion(raw) {
|
|
|
147
424
|
};
|
|
148
425
|
}
|
|
149
426
|
function loadAsserts(repoRoot) {
|
|
150
|
-
const path =
|
|
427
|
+
const path = join3(repoRoot, ".receipts", "asserts.json");
|
|
151
428
|
let text;
|
|
152
429
|
try {
|
|
153
|
-
text =
|
|
430
|
+
text = readFileSync3(path, "utf8");
|
|
154
431
|
} catch {
|
|
155
432
|
return [];
|
|
156
433
|
}
|
|
@@ -374,8 +651,8 @@ function renderFieldScan(s) {
|
|
|
374
651
|
}
|
|
375
652
|
|
|
376
653
|
// src/report/log.ts
|
|
377
|
-
import { readFileSync as
|
|
378
|
-
import { join as
|
|
654
|
+
import { readFileSync as readFileSync4, readdirSync } from "fs";
|
|
655
|
+
import { join as join4 } from "path";
|
|
379
656
|
var SEV_ICON2 = { critical: "\u26D4", high: "\u26A0\uFE0F", medium: "\u{1F50D}", low: "\xB7" };
|
|
380
657
|
var SEV_RANK = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
381
658
|
var NON_RECEIPT = /(?:^|\/)(?:asserts(?:\.example)?|sample)\.json$/i;
|
|
@@ -399,7 +676,7 @@ function loadReceiptHistory(dir) {
|
|
|
399
676
|
for (const f of files) {
|
|
400
677
|
let input;
|
|
401
678
|
try {
|
|
402
|
-
input = JSON.parse(
|
|
679
|
+
input = JSON.parse(readFileSync4(join4(dir, f), "utf8"));
|
|
403
680
|
} catch {
|
|
404
681
|
continue;
|
|
405
682
|
}
|
|
@@ -457,6 +734,26 @@ ${lines.join("\n")}
|
|
|
457
734
|
`;
|
|
458
735
|
}
|
|
459
736
|
|
|
737
|
+
// src/report/prune.ts
|
|
738
|
+
var PROTECTED = /(?:^|\/)(?:asserts(?:\.example)?|sample)\.json$/i;
|
|
739
|
+
function branchSlug(branch) {
|
|
740
|
+
return branch.replace(/[/\\]/g, "-");
|
|
741
|
+
}
|
|
742
|
+
function planPrune(files, liveSlugs) {
|
|
743
|
+
const keep = [];
|
|
744
|
+
const remove = [];
|
|
745
|
+
for (const f of files) {
|
|
746
|
+
const base = f.split("/").pop() ?? f;
|
|
747
|
+
const slug = base.replace(/\.json$/i, "");
|
|
748
|
+
if (PROTECTED.test(base) || liveSlugs.has(slug)) {
|
|
749
|
+
keep.push(f);
|
|
750
|
+
} else {
|
|
751
|
+
remove.push(f);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
return { keep, remove };
|
|
755
|
+
}
|
|
756
|
+
|
|
460
757
|
// src/report/sarif.ts
|
|
461
758
|
var INFO_URI = "https://github.com/AltimateAI/altimate-receipts";
|
|
462
759
|
function levelOf(severity) {
|
|
@@ -542,8 +839,8 @@ function toSarif(receipt) {
|
|
|
542
839
|
}
|
|
543
840
|
|
|
544
841
|
// src/report/stats.ts
|
|
545
|
-
import { readFileSync as
|
|
546
|
-
import { join as
|
|
842
|
+
import { readFileSync as readFileSync5, readdirSync as readdirSync2 } from "fs";
|
|
843
|
+
import { join as join5 } from "path";
|
|
547
844
|
var NON_RECEIPT2 = /(?:^|\/)(?:asserts(?:\.example)?|sample)\.json$/i;
|
|
548
845
|
function computeStats(dir) {
|
|
549
846
|
let files;
|
|
@@ -561,7 +858,7 @@ function computeStats(dir) {
|
|
|
561
858
|
for (const f of files) {
|
|
562
859
|
let input;
|
|
563
860
|
try {
|
|
564
|
-
input = JSON.parse(
|
|
861
|
+
input = JSON.parse(readFileSync5(join5(dir, f), "utf8"));
|
|
565
862
|
} catch {
|
|
566
863
|
skipped++;
|
|
567
864
|
continue;
|
|
@@ -704,7 +1001,14 @@ Usage
|
|
|
704
1001
|
receipts eval Flag-rate of the detectors over your real local sessions (--last N, --json)
|
|
705
1002
|
receipts badge [receipt] shields.io endpoint JSON for a README/PR badge (--out f)
|
|
706
1003
|
receipts sarif [receipt] SARIF 2.1.0 for GitHub code-scanning (inline + Security tab; --out f)
|
|
707
|
-
receipts
|
|
1004
|
+
receipts prune [dir] Remove committed receipts for merged/deleted branches (--dry-run)
|
|
1005
|
+
receipts init One-command adopt: PR-check workflow + the repo-committed
|
|
1006
|
+
agent hook (--prepare also wires the git-hook floor)
|
|
1007
|
+
receipts hook pre-push (called by hooks, not humans) attach the receipt on push
|
|
1008
|
+
(--agent claude | git selects the payload dialect)
|
|
1009
|
+
receipts install-hook Write the self-updating pre-push hook into .git/hooks
|
|
1010
|
+
receipts setup-local Add the hook to YOUR ~/.claude/settings.json (only fires
|
|
1011
|
+
in repos that have adopted the Receipts workflow)
|
|
708
1012
|
receipts rederive <file> Reproduce the canonical Receipt from a transcript
|
|
709
1013
|
receipts assert [selector] Check the receipt against committed .receipts/asserts.json (CI gate)
|
|
710
1014
|
receipts mcp Start the MCP server (stdio) for IDEs/agents
|
|
@@ -756,9 +1060,13 @@ var COMMANDS = /* @__PURE__ */ new Set([
|
|
|
756
1060
|
"stats",
|
|
757
1061
|
"eval",
|
|
758
1062
|
"badge",
|
|
1063
|
+
"prune",
|
|
759
1064
|
"sarif",
|
|
760
1065
|
"init",
|
|
761
|
-
"assert"
|
|
1066
|
+
"assert",
|
|
1067
|
+
"hook",
|
|
1068
|
+
"install-hook",
|
|
1069
|
+
"setup-local"
|
|
762
1070
|
]);
|
|
763
1071
|
function parseArgs(argv) {
|
|
764
1072
|
const args = argv.slice(2);
|
|
@@ -772,7 +1080,9 @@ function parseArgs(argv) {
|
|
|
772
1080
|
handoff: false,
|
|
773
1081
|
redact: false,
|
|
774
1082
|
copy: false,
|
|
1083
|
+
dryRun: false,
|
|
775
1084
|
wholeSession: false,
|
|
1085
|
+
prepare: false,
|
|
776
1086
|
color: !process.env.NO_COLOR && process.stdout.isTTY === true
|
|
777
1087
|
};
|
|
778
1088
|
const positionals = [];
|
|
@@ -811,13 +1121,20 @@ function parseArgs(argv) {
|
|
|
811
1121
|
if (Number.isFinite(n) && n > 0) {
|
|
812
1122
|
parsed.last = Math.floor(n);
|
|
813
1123
|
}
|
|
1124
|
+
} else if (a === "--dry-run") {
|
|
1125
|
+
parsed.dryRun = true;
|
|
814
1126
|
} else if (a === "--whole-session") {
|
|
815
1127
|
parsed.wholeSession = true;
|
|
1128
|
+
} else if (a === "--prepare") {
|
|
1129
|
+
parsed.prepare = true;
|
|
816
1130
|
} else if (a === "--agent") {
|
|
817
1131
|
const next = args[i + 1];
|
|
818
1132
|
if (next && agentIds().includes(next)) {
|
|
819
1133
|
parsed.agent = next;
|
|
820
1134
|
i++;
|
|
1135
|
+
} else if (next === "claude" || next === "git") {
|
|
1136
|
+
parsed.hookDialect = next;
|
|
1137
|
+
i++;
|
|
821
1138
|
}
|
|
822
1139
|
} else if (a === "--no-color") {
|
|
823
1140
|
parsed.color = false;
|
|
@@ -872,8 +1189,20 @@ async function run(argv) {
|
|
|
872
1189
|
if (args.command === "sarif") {
|
|
873
1190
|
return runSarif(args.file, { out: args.out });
|
|
874
1191
|
}
|
|
1192
|
+
if (args.command === "prune") {
|
|
1193
|
+
return runPrune(args.file, { dryRun: args.dryRun });
|
|
1194
|
+
}
|
|
875
1195
|
if (args.command === "init") {
|
|
876
|
-
return runInit();
|
|
1196
|
+
return runInit({ prepare: args.prepare });
|
|
1197
|
+
}
|
|
1198
|
+
if (args.command === "hook") {
|
|
1199
|
+
return runHook(args.file, args.hookDialect ?? "claude");
|
|
1200
|
+
}
|
|
1201
|
+
if (args.command === "install-hook") {
|
|
1202
|
+
return runInstallHook();
|
|
1203
|
+
}
|
|
1204
|
+
if (args.command === "setup-local") {
|
|
1205
|
+
return runSetupLocal();
|
|
877
1206
|
}
|
|
878
1207
|
if (args.command === "rederive") {
|
|
879
1208
|
return runRederive(args.file, {
|
|
@@ -960,7 +1289,7 @@ Run a coding-agent session first, then try again.
|
|
|
960
1289
|
}
|
|
961
1290
|
const out = args.compact ? canonicalize(receipt) : JSON.stringify(receipt, null, 2);
|
|
962
1291
|
if (args.out) {
|
|
963
|
-
|
|
1292
|
+
writeFileSync3(args.out, `${out}
|
|
964
1293
|
`);
|
|
965
1294
|
process.stderr.write(`Receipt written to ${args.out}
|
|
966
1295
|
`);
|
|
@@ -983,8 +1312,8 @@ Run a coding-agent session first, then try again.
|
|
|
983
1312
|
process.stdout.write(renderCard({ summary, derived, findings }, { color: args.color }));
|
|
984
1313
|
return 0;
|
|
985
1314
|
}
|
|
986
|
-
function
|
|
987
|
-
const r =
|
|
1315
|
+
function git2(args) {
|
|
1316
|
+
const r = spawnSync3("git", args, { encoding: "utf8" });
|
|
988
1317
|
return r.status === 0 ? r.stdout.trim() : "";
|
|
989
1318
|
}
|
|
990
1319
|
var PR_SELECT_SCAN = 150;
|
|
@@ -1021,8 +1350,8 @@ async function pickForDiff(all, branch, repoRoot, files) {
|
|
|
1021
1350
|
return best ?? primary;
|
|
1022
1351
|
}
|
|
1023
1352
|
async function runPr(opts) {
|
|
1024
|
-
const branch = opts.branch ||
|
|
1025
|
-
const repoRoot =
|
|
1353
|
+
const branch = opts.branch || git2(["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
1354
|
+
const repoRoot = git2(["rev-parse", "--show-toplevel"]);
|
|
1026
1355
|
if (!branch || branch === "HEAD") {
|
|
1027
1356
|
process.stderr.write("receipts pr: not on a git branch (use --branch <name>).\n");
|
|
1028
1357
|
return 1;
|
|
@@ -1087,11 +1416,11 @@ Build the branch with a coding agent first, or run \`receipts --list\`.
|
|
|
1087
1416
|
`);
|
|
1088
1417
|
}
|
|
1089
1418
|
const safe = branch.replace(/[/\\]/g, "-");
|
|
1090
|
-
const dir =
|
|
1091
|
-
const out = opts.out ??
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1419
|
+
const dir = join6(repoRoot || ".", ".receipts");
|
|
1420
|
+
const out = opts.out ?? join6(dir, `${safe}.json`);
|
|
1421
|
+
mkdirSync2(dir, { recursive: true });
|
|
1422
|
+
writeFileSync3(out, json);
|
|
1423
|
+
git2(["add", out]);
|
|
1095
1424
|
const rel = repoRoot ? relative(repoRoot, out) : out;
|
|
1096
1425
|
process.stderr.write(
|
|
1097
1426
|
`receipts pr: wrote ${rel} (Grade ${receipt.predicate.grade}, ${scopeNote}) from "${summary.title ?? "untitled"}".
|
|
@@ -1118,8 +1447,8 @@ async function runGuardrails(opts) {
|
|
|
1118
1447
|
const rules = collectGuardrails(findingSets);
|
|
1119
1448
|
const block = renderGuardrailsBlock(rules, opts.json ? "json" : "md");
|
|
1120
1449
|
if (opts.out) {
|
|
1121
|
-
const existing =
|
|
1122
|
-
|
|
1450
|
+
const existing = existsSync4(opts.out) ? readFileSync6(opts.out, "utf8") : "";
|
|
1451
|
+
writeFileSync3(opts.out, upsertGuardrailsSection(existing, block));
|
|
1123
1452
|
process.stderr.write(
|
|
1124
1453
|
`guardrails: wrote ${rules.length} rule(s) to ${opts.out} (from ${findingSets.length} session${findingSets.length === 1 ? "" : "s"}).
|
|
1125
1454
|
`
|
|
@@ -1151,8 +1480,8 @@ async function runTrends(opts) {
|
|
|
1151
1480
|
const trends = computeTrends(inputs, requested);
|
|
1152
1481
|
if (opts.out) {
|
|
1153
1482
|
const block = renderTrends(trends, "md");
|
|
1154
|
-
const existing =
|
|
1155
|
-
|
|
1483
|
+
const existing = existsSync4(opts.out) ? readFileSync6(opts.out, "utf8") : "";
|
|
1484
|
+
writeFileSync3(opts.out, upsertTrendsSection(existing, block));
|
|
1156
1485
|
process.stderr.write(
|
|
1157
1486
|
`trends: wrote section to ${opts.out} (from ${trends.window.used} sessions).
|
|
1158
1487
|
`
|
|
@@ -1178,7 +1507,7 @@ function runEnvelope(file) {
|
|
|
1178
1507
|
return 1;
|
|
1179
1508
|
}
|
|
1180
1509
|
try {
|
|
1181
|
-
const receipt = JSON.parse(
|
|
1510
|
+
const receipt = JSON.parse(readFileSync6(file, "utf8"));
|
|
1182
1511
|
process.stdout.write(`${JSON.stringify(toDsseEnvelope(receipt), null, 2)}
|
|
1183
1512
|
`);
|
|
1184
1513
|
return 0;
|
|
@@ -1195,7 +1524,7 @@ async function runVerify(file, opts = {}) {
|
|
|
1195
1524
|
}
|
|
1196
1525
|
let input;
|
|
1197
1526
|
try {
|
|
1198
|
-
input = JSON.parse(
|
|
1527
|
+
input = JSON.parse(readFileSync6(file, "utf8"));
|
|
1199
1528
|
} catch (err) {
|
|
1200
1529
|
process.stderr.write(`Could not read ${file}: ${err instanceof Error ? err.message : err}
|
|
1201
1530
|
`);
|
|
@@ -1249,8 +1578,8 @@ function runDiff(fileA, fileB, opts = {}) {
|
|
|
1249
1578
|
);
|
|
1250
1579
|
return 1;
|
|
1251
1580
|
}
|
|
1252
|
-
pathA =
|
|
1253
|
-
pathB =
|
|
1581
|
+
pathA = join6(".receipts", `${hist[1].name}.json`);
|
|
1582
|
+
pathB = join6(".receipts", `${hist[0].name}.json`);
|
|
1254
1583
|
process.stdout.write(`receipts diff: ${hist[1].name} \u2192 ${hist[0].name} (most recent two)
|
|
1255
1584
|
|
|
1256
1585
|
`);
|
|
@@ -1264,7 +1593,7 @@ function runDiff(fileA, fileB, opts = {}) {
|
|
|
1264
1593
|
const read = (f) => {
|
|
1265
1594
|
let input;
|
|
1266
1595
|
try {
|
|
1267
|
-
input = JSON.parse(
|
|
1596
|
+
input = JSON.parse(readFileSync6(f, "utf8"));
|
|
1268
1597
|
} catch (err) {
|
|
1269
1598
|
process.stderr.write(`Could not read ${f}: ${err instanceof Error ? err.message : err}
|
|
1270
1599
|
`);
|
|
@@ -1287,7 +1616,7 @@ function runDiff(fileA, fileB, opts = {}) {
|
|
|
1287
1616
|
const output = opts.json ? `${JSON.stringify(delta, null, 2)}
|
|
1288
1617
|
` : renderDiff(delta);
|
|
1289
1618
|
if (opts.out) {
|
|
1290
|
-
|
|
1619
|
+
writeFileSync3(opts.out, output);
|
|
1291
1620
|
process.stdout.write(`receipts diff: wrote ${opts.out}
|
|
1292
1621
|
`);
|
|
1293
1622
|
} else {
|
|
@@ -1301,7 +1630,7 @@ function runLog(dir, opts = {}) {
|
|
|
1301
1630
|
const output = opts.json ? `${JSON.stringify(shown, null, 2)}
|
|
1302
1631
|
` : renderLog(shown, all.length);
|
|
1303
1632
|
if (opts.out) {
|
|
1304
|
-
|
|
1633
|
+
writeFileSync3(opts.out, output);
|
|
1305
1634
|
process.stdout.write(`receipts log: wrote ${opts.out}
|
|
1306
1635
|
`);
|
|
1307
1636
|
} else {
|
|
@@ -1314,7 +1643,7 @@ function runStats(dir, opts = {}) {
|
|
|
1314
1643
|
const output = opts.json ? `${JSON.stringify(stats, null, 2)}
|
|
1315
1644
|
` : renderStats(stats);
|
|
1316
1645
|
if (opts.out) {
|
|
1317
|
-
|
|
1646
|
+
writeFileSync3(opts.out, output);
|
|
1318
1647
|
process.stdout.write(`receipts stats: wrote ${opts.out}
|
|
1319
1648
|
`);
|
|
1320
1649
|
} else {
|
|
@@ -1323,10 +1652,10 @@ function runStats(dir, opts = {}) {
|
|
|
1323
1652
|
return 0;
|
|
1324
1653
|
}
|
|
1325
1654
|
function runBadge(file, opts = {}) {
|
|
1326
|
-
const repoRoot =
|
|
1327
|
-
const branch =
|
|
1328
|
-
const path = file ?? (branch && branch !== "HEAD" ?
|
|
1329
|
-
if (!path || !
|
|
1655
|
+
const repoRoot = git2(["rev-parse", "--show-toplevel"]) || ".";
|
|
1656
|
+
const branch = git2(["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
1657
|
+
const path = file ?? (branch && branch !== "HEAD" ? join6(repoRoot, ".receipts", `${branch.replace(/[/\\]/g, "-")}.json`) : void 0);
|
|
1658
|
+
if (!path || !existsSync4(path)) {
|
|
1330
1659
|
process.stderr.write(
|
|
1331
1660
|
`receipts badge: no receipt found${path ? ` at ${path}` : ""}. Run \`receipts pr\` first, or pass a receipt path.
|
|
1332
1661
|
`
|
|
@@ -1335,7 +1664,7 @@ function runBadge(file, opts = {}) {
|
|
|
1335
1664
|
}
|
|
1336
1665
|
let input;
|
|
1337
1666
|
try {
|
|
1338
|
-
input = JSON.parse(
|
|
1667
|
+
input = JSON.parse(readFileSync6(path, "utf8"));
|
|
1339
1668
|
} catch {
|
|
1340
1669
|
process.stderr.write(`receipts badge: could not parse ${path}
|
|
1341
1670
|
`);
|
|
@@ -1350,7 +1679,7 @@ function runBadge(file, opts = {}) {
|
|
|
1350
1679
|
const output = `${JSON.stringify(badgeEndpoint(res.receipt.predicate), null, 2)}
|
|
1351
1680
|
`;
|
|
1352
1681
|
if (opts.out) {
|
|
1353
|
-
|
|
1682
|
+
writeFileSync3(opts.out, output);
|
|
1354
1683
|
process.stdout.write(`receipts badge: wrote ${opts.out}
|
|
1355
1684
|
`);
|
|
1356
1685
|
} else {
|
|
@@ -1359,10 +1688,10 @@ function runBadge(file, opts = {}) {
|
|
|
1359
1688
|
return 0;
|
|
1360
1689
|
}
|
|
1361
1690
|
function runSarif(file, opts = {}) {
|
|
1362
|
-
const repoRoot =
|
|
1363
|
-
const branch =
|
|
1364
|
-
const path = file ?? (branch && branch !== "HEAD" ?
|
|
1365
|
-
if (!path || !
|
|
1691
|
+
const repoRoot = git2(["rev-parse", "--show-toplevel"]) || ".";
|
|
1692
|
+
const branch = git2(["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
1693
|
+
const path = file ?? (branch && branch !== "HEAD" ? join6(repoRoot, ".receipts", `${branch.replace(/[/\\]/g, "-")}.json`) : void 0);
|
|
1694
|
+
if (!path || !existsSync4(path)) {
|
|
1366
1695
|
process.stderr.write(
|
|
1367
1696
|
`receipts sarif: no receipt found${path ? ` at ${path}` : ""}. Run \`receipts pr\` first, or pass a receipt path.
|
|
1368
1697
|
`
|
|
@@ -1371,7 +1700,7 @@ function runSarif(file, opts = {}) {
|
|
|
1371
1700
|
}
|
|
1372
1701
|
let input;
|
|
1373
1702
|
try {
|
|
1374
|
-
input = JSON.parse(
|
|
1703
|
+
input = JSON.parse(readFileSync6(path, "utf8"));
|
|
1375
1704
|
} catch {
|
|
1376
1705
|
process.stderr.write(`receipts sarif: could not parse ${path}
|
|
1377
1706
|
`);
|
|
@@ -1386,7 +1715,7 @@ function runSarif(file, opts = {}) {
|
|
|
1386
1715
|
const output = `${JSON.stringify(toSarif(res.receipt), null, 2)}
|
|
1387
1716
|
`;
|
|
1388
1717
|
if (opts.out) {
|
|
1389
|
-
|
|
1718
|
+
writeFileSync3(opts.out, output);
|
|
1390
1719
|
process.stdout.write(`receipts sarif: wrote ${opts.out}
|
|
1391
1720
|
`);
|
|
1392
1721
|
} else {
|
|
@@ -1394,6 +1723,59 @@ function runSarif(file, opts = {}) {
|
|
|
1394
1723
|
}
|
|
1395
1724
|
return 0;
|
|
1396
1725
|
}
|
|
1726
|
+
function runPrune(dir, opts = {}) {
|
|
1727
|
+
const repoRoot = git2(["rev-parse", "--show-toplevel"]) || ".";
|
|
1728
|
+
const dpath = dir ?? join6(repoRoot, ".receipts");
|
|
1729
|
+
let files;
|
|
1730
|
+
try {
|
|
1731
|
+
files = readdirSync3(dpath).filter((f) => f.endsWith(".json"));
|
|
1732
|
+
} catch {
|
|
1733
|
+
process.stderr.write(`receipts prune: no ${dpath} directory.
|
|
1734
|
+
`);
|
|
1735
|
+
return 0;
|
|
1736
|
+
}
|
|
1737
|
+
const ls = git2(["ls-remote", "--heads", "origin"]);
|
|
1738
|
+
if (ls === null) {
|
|
1739
|
+
process.stderr.write(
|
|
1740
|
+
"receipts prune: could not list remote branches (offline / no 'origin'?) \u2014 aborting, nothing removed.\n"
|
|
1741
|
+
);
|
|
1742
|
+
return 1;
|
|
1743
|
+
}
|
|
1744
|
+
const liveSlugs = new Set(
|
|
1745
|
+
ls.split("\n").map((l) => l.split("refs/heads/")[1]).filter(Boolean).map((b) => branchSlug(b.trim()))
|
|
1746
|
+
);
|
|
1747
|
+
const { keep, remove } = planPrune(files, liveSlugs);
|
|
1748
|
+
if (remove.length === 0) {
|
|
1749
|
+
process.stdout.write(`receipts prune: nothing to prune (${keep.length} receipt(s) kept).
|
|
1750
|
+
`);
|
|
1751
|
+
return 0;
|
|
1752
|
+
}
|
|
1753
|
+
if (opts.dryRun) {
|
|
1754
|
+
process.stdout.write(
|
|
1755
|
+
`receipts prune (dry-run): would remove ${remove.length}/${files.length} receipt(s), keep ${keep.length}:
|
|
1756
|
+
`
|
|
1757
|
+
);
|
|
1758
|
+
for (const f of remove) {
|
|
1759
|
+
process.stdout.write(` - ${f}
|
|
1760
|
+
`);
|
|
1761
|
+
}
|
|
1762
|
+
return 0;
|
|
1763
|
+
}
|
|
1764
|
+
for (const f of remove) {
|
|
1765
|
+
const p = join6(dpath, f);
|
|
1766
|
+
if (git2(["rm", "-f", "--", p]) === null) {
|
|
1767
|
+
try {
|
|
1768
|
+
unlinkSync(p);
|
|
1769
|
+
} catch {
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
process.stdout.write(
|
|
1774
|
+
`receipts prune: removed ${remove.length} receipt(s) for merged/deleted branches; kept ${keep.length}.
|
|
1775
|
+
`
|
|
1776
|
+
);
|
|
1777
|
+
return 0;
|
|
1778
|
+
}
|
|
1397
1779
|
async function runEval(opts = {}) {
|
|
1398
1780
|
const limit = opts.limit && opts.limit > 0 ? opts.limit : 200;
|
|
1399
1781
|
const summaries = (await listSessions(opts.agent)).slice(0, limit);
|
|
@@ -1414,17 +1796,53 @@ async function runEval(opts = {}) {
|
|
|
1414
1796
|
` : renderFieldScan(scan));
|
|
1415
1797
|
return 0;
|
|
1416
1798
|
}
|
|
1417
|
-
function runInit() {
|
|
1799
|
+
function runInit(opts = {}) {
|
|
1800
|
+
const lines = [];
|
|
1418
1801
|
const v = getVersion();
|
|
1419
|
-
const
|
|
1802
|
+
const major = /^(\d+)\.\d+\.\d+/.exec(v)?.[1];
|
|
1803
|
+
const tag = major ? `v${major}` : "v0";
|
|
1420
1804
|
const dir = ".github/workflows";
|
|
1421
1805
|
const path = `${dir}/receipts.yml`;
|
|
1422
|
-
if (
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1806
|
+
if (existsSync4(path)) {
|
|
1807
|
+
lines.push(`receipts init: ${path} already exists \u2014 leaving it untouched.`);
|
|
1808
|
+
} else {
|
|
1809
|
+
mkdirSync2(dir, { recursive: true });
|
|
1810
|
+
writeFileSync3(path, workflowContent(tag, v));
|
|
1811
|
+
lines.push(`receipts init: wrote ${path} (tracking ${tag}, quiet + non-blocking).`);
|
|
1812
|
+
}
|
|
1813
|
+
const settingsPath = join6(".claude", "settings.json");
|
|
1814
|
+
const merged = mergeHookIntoSettings(settingsPath);
|
|
1815
|
+
if (!merged.ok) {
|
|
1816
|
+
lines.push(`receipts init: ${merged.reason}.`);
|
|
1817
|
+
} else if (merged.changed) {
|
|
1818
|
+
lines.push(`receipts init: added the receipts pre-push hook to ${settingsPath}.`);
|
|
1819
|
+
} else {
|
|
1820
|
+
lines.push(`receipts init: ${settingsPath} already has the receipts hook.`);
|
|
1821
|
+
}
|
|
1822
|
+
const ignorePath = join6(".claude", ".gitignore");
|
|
1823
|
+
if (!existsSync4(ignorePath)) {
|
|
1824
|
+
writeFileSync3(ignorePath, "*\n!settings.json\n!.gitignore\n");
|
|
1825
|
+
lines.push(`receipts init: wrote ${ignorePath} (commit settings.json, ignore the rest).`);
|
|
1826
|
+
}
|
|
1827
|
+
if (opts.prepare) {
|
|
1828
|
+
const wired = wirePrepareScript("package.json");
|
|
1829
|
+
if (!wired.ok) {
|
|
1830
|
+
lines.push(`receipts init: ${wired.reason}.`);
|
|
1831
|
+
} else if (wired.changed) {
|
|
1832
|
+
lines.push(
|
|
1833
|
+
"receipts init: wired `prepare` in package.json \u2014 `npm install` now self-installs the git hook."
|
|
1834
|
+
);
|
|
1835
|
+
} else {
|
|
1836
|
+
lines.push("receipts init: package.json `prepare` already wired.");
|
|
1837
|
+
}
|
|
1426
1838
|
}
|
|
1427
|
-
|
|
1839
|
+
lines.push(" Commit these, open a PR, and receipts attach themselves from then on.");
|
|
1840
|
+
process.stdout.write(`${lines.join("\n")}
|
|
1841
|
+
`);
|
|
1842
|
+
return 0;
|
|
1843
|
+
}
|
|
1844
|
+
function workflowContent(tag, v) {
|
|
1845
|
+
return `name: Verified by Receipts
|
|
1428
1846
|
|
|
1429
1847
|
# Deterministic "what did the coding agent actually do?" check on PRs. Quiet + non-blocking
|
|
1430
1848
|
# pilot: acts only when a branch commits an agent Receipt (.receipts/<branch>.json);
|
|
@@ -1443,21 +1861,56 @@ permissions:
|
|
|
1443
1861
|
|
|
1444
1862
|
jobs:
|
|
1445
1863
|
receipts:
|
|
1864
|
+
# Tracks the ${tag} major tag (auto-gets minor/patch features). Pin a full version
|
|
1865
|
+
# (e.g. @v${v}) or a commit SHA for immutability.
|
|
1446
1866
|
uses: AltimateAI/altimate-receipts/.github/workflows/receipts.reusable.yml@${tag}
|
|
1447
1867
|
with:
|
|
1448
1868
|
require-receipt: false # never fail a PR that has no receipt (soft pilot)
|
|
1449
1869
|
notify-when-missing: false # stay silent unless a receipt is present
|
|
1450
1870
|
# block-on: "" # informational check; never blocks a merge
|
|
1451
1871
|
`;
|
|
1452
|
-
|
|
1453
|
-
|
|
1872
|
+
}
|
|
1873
|
+
async function runHook(kind, dialect) {
|
|
1874
|
+
if (kind !== "pre-push") {
|
|
1875
|
+
process.stderr.write("Usage: receipts hook pre-push [--agent claude|git]\n");
|
|
1876
|
+
return 1;
|
|
1877
|
+
}
|
|
1878
|
+
const stdin = await readStdin();
|
|
1879
|
+
const res = await runHookPrePush(dialect, stdin, async () => runPr({}));
|
|
1880
|
+
if (res.message) {
|
|
1881
|
+
process.stderr.write(`${res.message}
|
|
1882
|
+
`);
|
|
1883
|
+
}
|
|
1884
|
+
return res.exit;
|
|
1885
|
+
}
|
|
1886
|
+
function runInstallHook() {
|
|
1887
|
+
const res = installGitHook();
|
|
1888
|
+
if (!res.ok) {
|
|
1889
|
+
process.stderr.write(`receipts install-hook: ${res.reason}.
|
|
1890
|
+
`);
|
|
1891
|
+
return 1;
|
|
1892
|
+
}
|
|
1893
|
+
const note = {
|
|
1894
|
+
installed: "installed .git/hooks/pre-push",
|
|
1895
|
+
unchanged: ".git/hooks/pre-push already installed",
|
|
1896
|
+
chained: "installed .git/hooks/pre-push (existing hook preserved as pre-push.local, runs first)"
|
|
1897
|
+
}[res.action];
|
|
1898
|
+
process.stdout.write(`receipts install-hook: ${note}.
|
|
1899
|
+
`);
|
|
1900
|
+
return 0;
|
|
1901
|
+
}
|
|
1902
|
+
function runSetupLocal() {
|
|
1903
|
+
const path = join6(homedir(), ".claude", "settings.json");
|
|
1904
|
+
const res = mergeHookIntoSettings(path);
|
|
1905
|
+
if (!res.ok) {
|
|
1906
|
+
process.stderr.write(`receipts setup-local: ${res.reason}.
|
|
1907
|
+
`);
|
|
1908
|
+
return 1;
|
|
1909
|
+
}
|
|
1454
1910
|
process.stdout.write(
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
" Generate receipts locally with the pre-push hook \u2014 see docs/onboarding-internal.md.",
|
|
1459
|
-
""
|
|
1460
|
-
].join("\n")
|
|
1911
|
+
res.changed ? `receipts setup-local: added the receipts hook to ${path} \u2014 active only in repos with the Receipts workflow.
|
|
1912
|
+
` : `receipts setup-local: ${path} already has the receipts hook.
|
|
1913
|
+
`
|
|
1461
1914
|
);
|
|
1462
1915
|
return 0;
|
|
1463
1916
|
}
|
|
@@ -1478,7 +1931,7 @@ async function runRederive(file, opts = {}) {
|
|
|
1478
1931
|
return 0;
|
|
1479
1932
|
}
|
|
1480
1933
|
async function runAssert(opts) {
|
|
1481
|
-
const repoRoot =
|
|
1934
|
+
const repoRoot = git2(["rev-parse", "--show-toplevel"]) || ".";
|
|
1482
1935
|
let asserts;
|
|
1483
1936
|
try {
|
|
1484
1937
|
asserts = loadAsserts(repoRoot);
|