@stdd/plugin 0.9.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.
Files changed (80) hide show
  1. package/.claude-plugin/plugin.json +9 -0
  2. package/.codex-plugin/plugin.json +21 -0
  3. package/LICENSE +21 -0
  4. package/README.md +47 -0
  5. package/extensions/stdd.mjs +77 -0
  6. package/hooks/claude-hooks.json +28 -0
  7. package/hooks/codex-hooks.json +28 -0
  8. package/package.json +38 -0
  9. package/runtime/adapters/README.md +158 -0
  10. package/runtime/cli/check.mjs +555 -0
  11. package/runtime/cli/ci.mjs +190 -0
  12. package/runtime/cli/claude-hooks.mjs +689 -0
  13. package/runtime/cli/config.mjs +27 -0
  14. package/runtime/cli/evidence.mjs +249 -0
  15. package/runtime/cli/generated-files.mjs +1693 -0
  16. package/runtime/cli/held-fs.mjs +415 -0
  17. package/runtime/cli/init.mjs +883 -0
  18. package/runtime/cli/ledger.mjs +1470 -0
  19. package/runtime/cli/lib.mjs +909 -0
  20. package/runtime/cli/path-bytes.mjs +83 -0
  21. package/runtime/cli/policy.mjs +112 -0
  22. package/runtime/cli/recorders.mjs +188 -0
  23. package/runtime/cli/review-fs.mjs +825 -0
  24. package/runtime/cli/review.mjs +1065 -0
  25. package/runtime/cli/runtime.mjs +32 -0
  26. package/runtime/cli/scope.mjs +185 -0
  27. package/runtime/cli/snapshot.mjs +897 -0
  28. package/runtime/cli/state-validation.mjs +168 -0
  29. package/runtime/cli/status.mjs +580 -0
  30. package/runtime/cli/stdd.mjs +536 -0
  31. package/runtime/cli/worker-fs.mjs +971 -0
  32. package/runtime/cli/worker-metadata.mjs +139 -0
  33. package/runtime/cli/worker.mjs +779 -0
  34. package/runtime/method/README.md +634 -0
  35. package/runtime/method/reference-commands.md +147 -0
  36. package/runtime/method/reference-generated-state.md +151 -0
  37. package/runtime/method/reference-integration.md +233 -0
  38. package/runtime/package.json +65 -0
  39. package/runtime/playbooks/brainstorming.md +46 -0
  40. package/runtime/playbooks/debugging.md +36 -0
  41. package/runtime/playbooks/delegate-slice.md +129 -0
  42. package/runtime/playbooks/finish-change.md +46 -0
  43. package/runtime/playbooks/implement.md +26 -0
  44. package/runtime/playbooks/investigation.md +33 -0
  45. package/runtime/playbooks/managed-playbooks.json +14 -0
  46. package/runtime/playbooks/planning.md +177 -0
  47. package/runtime/playbooks/pr-green.md +50 -0
  48. package/runtime/playbooks/start-change.md +37 -0
  49. package/runtime/playbooks/worktrees.md +45 -0
  50. package/runtime/prebuilds/stdd-fs/darwin-arm64/stdd-fs +0 -0
  51. package/runtime/prebuilds/stdd-fs/darwin-x64/stdd-fs +0 -0
  52. package/runtime/prebuilds/stdd-fs/linux-arm64/stdd-fs +0 -0
  53. package/runtime/prebuilds/stdd-fs/linux-x64/stdd-fs +0 -0
  54. package/runtime/prebuilds/stdd-fs/manifest.json +47 -0
  55. package/runtime/prebuilds/stdd-fs/win32-arm64/stdd-fs.exe +0 -0
  56. package/runtime/prebuilds/stdd-fs/win32-x64/stdd-fs.exe +0 -0
  57. package/runtime/sdk/adapters.mjs +279 -0
  58. package/runtime/sdk/file-observation.mjs +12 -0
  59. package/runtime/sdk/index.d.ts +140 -0
  60. package/runtime/sdk/index.mjs +31 -0
  61. package/runtime/sdk/native-fs.mjs +1235 -0
  62. package/runtime/sdk/path.mjs +71 -0
  63. package/runtime/sdk/text.mjs +42 -0
  64. package/runtime/sdk/workflow.mjs +294 -0
  65. package/runtime/templates/deferred-design.md +47 -0
  66. package/runtime/templates/github-stdd.yml +42 -0
  67. package/runtime/templates/gitlab-stdd.yml +72 -0
  68. package/runtime/templates/pr-description.md +35 -0
  69. package/scripts/adopting-root.mjs +42 -0
  70. package/scripts/stdd-hook.mjs +72 -0
  71. package/skills/stdd-brainstorming/SKILL.md +48 -0
  72. package/skills/stdd-debugging/SKILL.md +38 -0
  73. package/skills/stdd-delegate-slice/SKILL.md +118 -0
  74. package/skills/stdd-finish-change/SKILL.md +40 -0
  75. package/skills/stdd-implement/SKILL.md +28 -0
  76. package/skills/stdd-investigation/SKILL.md +35 -0
  77. package/skills/stdd-planning/SKILL.md +165 -0
  78. package/skills/stdd-pr-green/SKILL.md +52 -0
  79. package/skills/stdd-start-change/SKILL.md +39 -0
  80. package/skills/stdd-worktrees/SKILL.md +46 -0
@@ -0,0 +1,897 @@
1
+ // --- checkout, dirty, worker, and review observations ---
2
+ //
3
+ // Owns every filesystem observation the loop compares against itself: the
4
+ // checkout and dirty-path fingerprints, the managed-worker tree walk, and the
5
+ // review material a brief is built from. Bytes leave this module only through a
6
+ // verified descriptor. It has no dependency on the entry module.
7
+ import { execFileSync } from "node:child_process";
8
+ import { createHash } from "node:crypto";
9
+ import fs from "node:fs";
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ import { sameFileObservation } from "../sdk/file-observation.mjs";
13
+ import {
14
+ currentTaskPlan,
15
+ isStateExemptPath,
16
+ isTrustedLedgerInternalTemp,
17
+ STATE_EXEMPT,
18
+ } from "./ledger.mjs";
19
+ import { deferredSectionRange, sha256 } from "./lib.mjs";
20
+ import {
21
+ absPathBuf,
22
+ bufferPathIsWithin,
23
+ parentPathBuf,
24
+ pathForMatch,
25
+ realPathBuf,
26
+ splitNul,
27
+ viewPath,
28
+ } from "./path-bytes.mjs";
29
+ import { fail, MAX_SUBPROCESS_BUFFER, subprocessError } from "./runtime.mjs";
30
+ import { readWorkerPathState, sameWorkerState, workerViewPath } from "./worker-fs.mjs";
31
+ import { readWorkerMetadata, WORKER_METADATA_REL } from "./worker-metadata.mjs";
32
+
33
+ export const DIRTY_FINGERPRINT_READ_LIMIT = 40_000;
34
+
35
+ function snapshotStatMetadata(st) {
36
+ return [st.dev, st.ino, st.uid, st.gid, st.mode, st.nlink, st.size, st.mtimeNs, st.ctimeNs].join(":");
37
+ }
38
+
39
+ function snapshotOwnerIsCurrent(st) {
40
+ return typeof process.getuid !== "function" || st.uid === BigInt(process.getuid());
41
+ }
42
+
43
+ function sameSnapshotFileObservation(left, right) {
44
+ return sameFileObservation(left, right) && left.uid === right.uid && left.gid === right.gid;
45
+ }
46
+
47
+ function unsafeSnapshotFingerprint(kind, st) {
48
+ return `${kind}:${st ? snapshotStatMetadata(st) : "unstattable"}`;
49
+ }
50
+
51
+ /** Historical raw-byte fingerprint, now read only through a verified fd. */
52
+ function fingerprintRawSnapshotDescriptor(descriptor) {
53
+ const hash = createHash("sha256");
54
+ const chunk = Buffer.alloc(64 * 1024);
55
+ let position = 0;
56
+ for (;;) {
57
+ const count = fs.readSync(descriptor, chunk, 0, chunk.length, position);
58
+ if (count === 0) break;
59
+ hash.update(chunk.subarray(0, count));
60
+ position += count;
61
+ }
62
+ return `sha256:${hash.digest("hex")}`;
63
+ }
64
+
65
+ /**
66
+ * Brief-inspection fingerprint for one already-verified descriptor.
67
+ * Oversized files bind a bounded prefix to identity and change metadata;
68
+ * no inspection descriptor reads beyond the inline bound.
69
+ */
70
+ function fingerprintBoundedReviewDescriptor(descriptor, opened, retainBytes = false) {
71
+ const oversized = opened.size > BigInt(DIRTY_FINGERPRINT_READ_LIMIT);
72
+ const expected = Number(
73
+ opened.size < BigInt(DIRTY_FINGERPRINT_READ_LIMIT)
74
+ ? opened.size
75
+ : BigInt(DIRTY_FINGERPRINT_READ_LIMIT),
76
+ );
77
+ const hash = createHash("sha256");
78
+ if (oversized) {
79
+ hash.update(`stdd-bounded-file-v1:${snapshotStatMetadata(opened)}\n`);
80
+ }
81
+ const retained = retainBytes ? Buffer.alloc(expected) : null;
82
+ const chunk = Buffer.alloc(Math.min(64 * 1024, Math.max(expected, 1)));
83
+ let position = 0;
84
+ while (position < expected) {
85
+ const count = fs.readSync(
86
+ descriptor,
87
+ chunk,
88
+ 0,
89
+ Math.min(chunk.length, expected - position),
90
+ position,
91
+ );
92
+ if (count === 0) break;
93
+ hash.update(chunk.subarray(0, count));
94
+ if (retained !== null) chunk.copy(retained, position, 0, count);
95
+ position += count;
96
+ }
97
+ return {
98
+ fingerprint: `sha256:${hash.digest("hex")}`,
99
+ bytes: retained?.subarray(0, position) ?? null,
100
+ complete: position === expected,
101
+ oversized,
102
+ };
103
+ }
104
+
105
+ function fingerprintDirtyPath(abs, observed, boundedReview) {
106
+ if (observed.isSymbolicLink()) {
107
+ try {
108
+ const target = fs.readlinkSync(abs, "buffer");
109
+ const after = fs.lstatSync(abs, { bigint: true });
110
+ if (!after.isSymbolicLink() || !sameSnapshotFileObservation(observed, after)) {
111
+ return unsafeSnapshotFingerprint("unsafe", observed);
112
+ }
113
+ return sha256(`link:${target.toString("latin1")}`);
114
+ } catch {
115
+ return unsafeSnapshotFingerprint("unsafe", observed);
116
+ }
117
+ }
118
+ if (!observed.isFile() || observed.nlink !== 1n || !snapshotOwnerIsCurrent(observed)) {
119
+ return unsafeSnapshotFingerprint("unsafe", observed);
120
+ }
121
+
122
+ let descriptor = null;
123
+ try {
124
+ descriptor = fs.openSync(
125
+ abs,
126
+ fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK,
127
+ );
128
+ const opened = fs.fstatSync(descriptor, { bigint: true });
129
+ const pathAtOpen = fs.lstatSync(abs, { bigint: true });
130
+ if (
131
+ !opened.isFile() ||
132
+ opened.nlink !== 1n ||
133
+ !snapshotOwnerIsCurrent(opened) ||
134
+ pathAtOpen.isSymbolicLink() ||
135
+ !pathAtOpen.isFile() ||
136
+ pathAtOpen.nlink !== 1n ||
137
+ !snapshotOwnerIsCurrent(pathAtOpen) ||
138
+ !sameSnapshotFileObservation(observed, opened) ||
139
+ !sameSnapshotFileObservation(opened, pathAtOpen)
140
+ ) {
141
+ return unsafeSnapshotFingerprint("unsafe", observed);
142
+ }
143
+ const fingerprint = boundedReview
144
+ ? fingerprintBoundedReviewDescriptor(descriptor, opened)
145
+ : {
146
+ fingerprint: fingerprintRawSnapshotDescriptor(descriptor),
147
+ complete: true,
148
+ };
149
+ const after = fs.fstatSync(descriptor, { bigint: true });
150
+ const finalPath = fs.lstatSync(abs, { bigint: true });
151
+ if (
152
+ !fingerprint.complete ||
153
+ finalPath.isSymbolicLink() ||
154
+ !finalPath.isFile() ||
155
+ finalPath.nlink !== 1n ||
156
+ !snapshotOwnerIsCurrent(after) ||
157
+ !snapshotOwnerIsCurrent(finalPath) ||
158
+ !sameSnapshotFileObservation(opened, after) ||
159
+ !sameSnapshotFileObservation(after, finalPath)
160
+ ) {
161
+ return unsafeSnapshotFingerprint("unsafe", observed);
162
+ }
163
+ return fingerprint.fingerprint;
164
+ } catch (err) {
165
+ return unsafeSnapshotFingerprint(err.code === "EACCES" ? "unreadable" : "unsafe", observed);
166
+ } finally {
167
+ if (descriptor !== null) {
168
+ try {
169
+ fs.closeSync(descriptor);
170
+ } catch {
171
+ // The descriptor is no longer part of the snapshot after the verified read.
172
+ }
173
+ }
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Content hashes of every dirty (staged, unstaged, untracked) path.
179
+ * Deleted paths and untracked directories hash to null — equality of
180
+ * nulls still distinguishes inherited state from a slice's own edits.
181
+ */
182
+ export function dirtySnapshot(cwd, { boundedReview = false } = {}) {
183
+ const worker = readWorkerMetadata(cwd);
184
+ if (worker) {
185
+ try {
186
+ return workerDirtySnapshot(worker.root, worker);
187
+ } catch (err) {
188
+ fail(err.message);
189
+ }
190
+ }
191
+ // -z: NUL-delimited, no C-style octal quoting — read as raw bytes so a
192
+ // non-UTF-8 filename is never folded to U+FFFD (which would collapse
193
+ // distinct paths and look up files at the wrong location, letting an
194
+ // untracked doc's content change without staling a review).
195
+ // --untracked-files=all: a wholly untracked directory must list every
196
+ // file inside it, or edits there would never change the snapshot
197
+ const tokens = splitNul(
198
+ execFileSync("git", ["-C", cwd, "status", "--porcelain", "-z", "--untracked-files=all"], {
199
+ stdio: ["ignore", "pipe", "pipe"],
200
+ maxBuffer: MAX_SUBPROCESS_BUFFER,
201
+ }),
202
+ );
203
+ // null-prototype: a file literally named `__proto__` (or `constructor`)
204
+ // must be an own data property, not a write through Object.prototype's
205
+ // setter — otherwise its fingerprint vanishes and edits never stale
206
+ const dirty = Object.create(null);
207
+ for (let i = 0; i < tokens.length; i++) {
208
+ const entry = tokens[i];
209
+ if (entry.length === 0) continue;
210
+ const xy = entry.subarray(0, 2).toString("latin1"); // status bytes are ASCII
211
+ const p = pathForMatch(entry.subarray(3)); // latin1, byte-exact key
212
+ // a rename/copy entry is followed by the origin path token — the
213
+ // current path is what the snapshot tracks
214
+ if (/[RC]/.test(xy)) i++;
215
+ if (isStateExemptPath(cwd, p)) continue;
216
+ // the filesystem path is built from raw bytes, not a decoded string,
217
+ // so a non-UTF-8 name is stat/read at its true location
218
+ const abs = absPathBuf(cwd, p);
219
+ // lstat: a stable symlink is fingerprinted by its target PATH — the
220
+ // change is the link itself. Regular bytes are read only through a
221
+ // no-follow, nonblocking descriptor tied back to this observation.
222
+ let st = null;
223
+ let statError = null;
224
+ try {
225
+ st = p.endsWith("/") ? null : fs.lstatSync(abs, { bigint: true });
226
+ } catch (err) {
227
+ statError = err;
228
+ st = null;
229
+ }
230
+ dirty[p] =
231
+ st === null
232
+ ? statError && statError.code !== "ENOENT"
233
+ ? unsafeSnapshotFingerprint("unreadable", null)
234
+ : null
235
+ : fingerprintDirtyPath(abs, st, boundedReview);
236
+ }
237
+ return dirty;
238
+ }
239
+
240
+ /**
241
+ * Bind a recorded loop fact to the exact checkout that produced it. HEAD is
242
+ * included deliberately: committing after verification requires a fresh run
243
+ * on the commit that will actually be reviewed and pushed.
244
+ */
245
+ export function checkoutSnapshot(cwd) {
246
+ let head;
247
+ try {
248
+ head = execFileSync("git", ["-C", cwd, "rev-parse", "HEAD"], {
249
+ encoding: "utf8",
250
+ stdio: ["ignore", "pipe", "pipe"],
251
+ }).trim();
252
+ } catch {
253
+ head = readWorkerMetadata(cwd)?.source.head ?? "(no HEAD)";
254
+ }
255
+ const dirty = dirtySnapshot(cwd);
256
+ return sha256(`${head}\n${JSON.stringify(dirty)}`);
257
+ }
258
+
259
+ function workerIgnoredPaths(root, relativePaths, gitDir) {
260
+ if (relativePaths.length === 0) return new Set();
261
+ try {
262
+ const input = Buffer.concat(relativePaths.map((relative) => Buffer.from(`${relative}\0`)));
263
+ const output = execFileSync(
264
+ "git",
265
+ [`--git-dir=${gitDir}`, `--work-tree=${root}`, "check-ignore", "--no-index", "-z", "--stdin"],
266
+ { cwd: root, input, stdio: ["pipe", "pipe", "pipe"], maxBuffer: MAX_SUBPROCESS_BUFFER },
267
+ );
268
+ return new Set(splitNul(output).map((entry) => entry.toString("utf8")));
269
+ } catch (err) {
270
+ if (err.status === 1) return new Set();
271
+ throw new Error(`cannot evaluate sandbox ignore rules: ${err.message}`);
272
+ }
273
+ }
274
+
275
+ function workerTreeFiles(root, metadata) {
276
+ const files = new Set();
277
+ const baselinePaths = Object.keys(metadata.baseline.files);
278
+ const baselinePrefixes = new Set();
279
+ for (const baseline of baselinePaths) {
280
+ let prefix = path.posix.dirname(baseline);
281
+ while (prefix !== ".") {
282
+ baselinePrefixes.add(prefix);
283
+ prefix = path.posix.dirname(prefix);
284
+ }
285
+ }
286
+ const gitDir = fs.mkdtempSync(path.join(os.tmpdir(), "stdd-worker-ignore-"));
287
+ const walk = (directory, prefix = "") => {
288
+ const entries = fs.readdirSync(directory, { withFileTypes: true, encoding: "buffer" });
289
+ const names = entries.map((entry) => {
290
+ const name = entry.name.toString("utf8");
291
+ if (!Buffer.from(name, "utf8").equals(entry.name)) {
292
+ throw new Error("managed worker sandbox does not support non-UTF-8 paths");
293
+ }
294
+ return name;
295
+ });
296
+ const relatives = names.map((name) => (prefix ? `${prefix}/${name}` : name));
297
+ const ignored = workerIgnoredPaths(root, relatives, gitDir);
298
+ for (let index = 0; index < entries.length; index++) {
299
+ const entry = entries[index];
300
+ const relative = relatives[index];
301
+ const carriesBaseline =
302
+ Object.hasOwn(metadata.baseline.files, relative) || baselinePrefixes.has(relative);
303
+ if (ignored.has(relative) && !carriesBaseline) continue;
304
+ if (relative.split("/").includes(".git")) {
305
+ throw new Error(`managed worker sandbox must not contain .git: ${workerViewPath(relative)}`);
306
+ }
307
+ if (isStateExemptPath(root, relative) || relative === WORKER_METADATA_REL) continue;
308
+ const absolute = path.join(directory, names[index]);
309
+ if (entry.isDirectory()) walk(absolute, relative);
310
+ else files.add(relative);
311
+ }
312
+ };
313
+ try {
314
+ execFileSync("git", ["init", "--bare", "-q", gitDir], { stdio: "ignore" });
315
+ walk(root);
316
+ return [...files].sort();
317
+ } finally {
318
+ fs.rmSync(gitDir, { recursive: true, force: true });
319
+ }
320
+ }
321
+
322
+ export function workerCurrentStates(root, metadata) {
323
+ const paths = new Set([...Object.keys(metadata.baseline.files), ...workerTreeFiles(root, metadata)]);
324
+ const states = Object.create(null);
325
+ for (const relative of paths) states[relative] = readWorkerPathState(root, relative).state;
326
+ return states;
327
+ }
328
+
329
+ export function workerDirtySnapshot(root, metadata) {
330
+ const current = workerCurrentStates(root, metadata);
331
+ const dirty = Object.create(null);
332
+ for (const relative of new Set([...Object.keys(metadata.baseline.files), ...Object.keys(current)])) {
333
+ const before = metadata.baseline.files[relative] ?? null;
334
+ const after = current[relative] ?? null;
335
+ if (!sameWorkerState(before, after)) {
336
+ dirty[relative] = after?.hash ?? null;
337
+ }
338
+ }
339
+ return dirty;
340
+ }
341
+
342
+ // Only the WORKING artifacts are exempt from review evidence — recording
343
+ // events must never invalidate a review. Tracked .stdd/ deliverables
344
+ // (config, generated kit) stay under review like any other file.
345
+ const REVIEW_EXEMPT = STATE_EXEMPT;
346
+
347
+ function reviewExemptPathspecs(cwd) {
348
+ const trustedTemps = [];
349
+ try {
350
+ for (const name of fs.readdirSync(path.join(cwd, ".stdd"))) {
351
+ const relative = `.stdd/${name}`;
352
+ if (isTrustedLedgerInternalTemp(cwd, relative)) trustedTemps.push(relative);
353
+ }
354
+ } catch {
355
+ // An absent/unreadable state directory contributes no trusted exemption.
356
+ }
357
+ return [...REVIEW_EXEMPT, ...trustedTemps].map((p) => `:(exclude,literal)${p}`);
358
+ }
359
+
360
+ /**
361
+ * The diff under review. `strict` aborts on an unresolvable base — a
362
+ * review of an unavailable diff proves nothing and must not be recordable;
363
+ * status/gate callers stay tolerant and get a placeholder instead.
364
+ */
365
+ function reviewDiff(cwd, baseRef, strict) {
366
+ try {
367
+ return execFileSync(
368
+ "git",
369
+ [
370
+ "-C",
371
+ cwd,
372
+ "diff",
373
+ "--no-ext-diff",
374
+ "--no-textconv",
375
+ "--full-index",
376
+ "--end-of-options",
377
+ baseRef,
378
+ "--",
379
+ ".",
380
+ ...reviewExemptPathspecs(cwd),
381
+ ],
382
+ {
383
+ stdio: ["ignore", "pipe", "pipe"],
384
+ maxBuffer: MAX_SUBPROCESS_BUFFER,
385
+ },
386
+ );
387
+ } catch {
388
+ if (strict) {
389
+ fail(
390
+ `cannot diff against "${baseRef}" — fetch the base ref or fix "baseRef" in .stdd/config.json`,
391
+ );
392
+ }
393
+ return Buffer.from("(unresolvable base)");
394
+ }
395
+ }
396
+
397
+ /**
398
+ * The plan as snapshot material: checkbox marks are normalized away — they are
399
+ * claims graded by the ledger — and so is the `## Deferred` section, which
400
+ * holds recorded scope cuts rather than specification. A session that finds
401
+ * something after an approval is told to defer it instead of editing; that
402
+ * move must not destroy the approval it protects. The reviewer still receives
403
+ * the whole plan file in the brief. Editing the plan's words DOES stale a
404
+ * review: the verdict is a comparison against exactly that specification.
405
+ */
406
+ function normalizedPlanContent(plan) {
407
+ if (plan === null) return "(no plan for the active task)";
408
+ // Line endings are normalized before anything else, and unconditionally:
409
+ // doing it only on the branch that finds a section would make the first
410
+ // `stdd defer` on a CRLF plan look like an edit.
411
+ const lines = plan.replaceAll("\r\n", "\n").split("\n");
412
+ const section = deferredSectionRange(lines);
413
+ const kept =
414
+ section === null ? lines : [...lines.slice(0, section.start), ...lines.slice(section.end)];
415
+ return (
416
+ kept
417
+ .join("\n")
418
+ .replace(/^(\s*[-*+]\s+)\[[ xX]\]/gm, "$1[ ]")
419
+ // Trailing newlines are not specification, and creating the section on
420
+ // a plan that lacked a final newline leaves one behind. Adding a line
421
+ // after the last one still reads as the edit it is.
422
+ .replace(/\n+$/, "")
423
+ );
424
+ }
425
+
426
+ /**
427
+ * Every path whose working-tree content differs from `baseRef`, mapped to a
428
+ * content fingerprint. The set is the union of the tracked diff and the
429
+ * untracked entries of `git status`, which makes it invariant under `git add`
430
+ * and `git commit`: staging moves a path between those two inputs and
431
+ * committing empties the second, but neither changes a byte on disk. Keys stay
432
+ * byte-exact latin1, so a non-UTF-8 name is never folded to U+FFFD and two
433
+ * distinct paths never collapse into one.
434
+ *
435
+ * The executable bit rides along because the fingerprint hashes content only,
436
+ * and git itself distinguishes exactly `100644` from `100755`.
437
+ */
438
+ function changedContentFingerprints(cwd, baseRef, strict) {
439
+ const paths = new Set(); // latin1, byte-exact keys
440
+ const remember = (p) => {
441
+ if (!isStateExemptPath(cwd, p)) paths.add(p);
442
+ };
443
+ try {
444
+ for (const entry of splitNul(
445
+ execFileSync(
446
+ "git",
447
+ [
448
+ "-C",
449
+ cwd,
450
+ "diff",
451
+ "--name-only",
452
+ "-z",
453
+ // Rename detection makes the path set depend on the index: an
454
+ // unstaged rename reads as a deletion plus an untracked file,
455
+ // and a staged one collapses to the destination alone. Reporting
456
+ // both sides always keeps the set invariant under `git add`.
457
+ "--no-renames",
458
+ "--end-of-options",
459
+ baseRef,
460
+ "--",
461
+ ".",
462
+ ...reviewExemptPathspecs(cwd),
463
+ ],
464
+ { stdio: ["ignore", "pipe", "pipe"], maxBuffer: MAX_SUBPROCESS_BUFFER },
465
+ ),
466
+ )) {
467
+ if (entry.length > 0) remember(pathForMatch(entry));
468
+ }
469
+ } catch (err) {
470
+ if (strict) {
471
+ fail(
472
+ `cannot diff against "${baseRef}" — fetch the base ref or fix "baseRef" in .stdd/config.json`,
473
+ );
474
+ }
475
+ return { "(unresolvable base ref)": subprocessError(err) };
476
+ }
477
+ const statusTokens = splitNul(
478
+ execFileSync("git", ["-C", cwd, "status", "--porcelain", "-z", "--untracked-files=all"], {
479
+ stdio: ["ignore", "pipe", "pipe"],
480
+ maxBuffer: MAX_SUBPROCESS_BUFFER,
481
+ }),
482
+ );
483
+ for (let i = 0; i < statusTokens.length; i++) {
484
+ const entry = statusTokens[i];
485
+ if (entry.length === 0) continue;
486
+ const xy = entry.subarray(0, 2).toString("latin1"); // status bytes are ASCII
487
+ // a rename/copy entry is followed by its origin path token; consume it
488
+ // here or the next iteration would read that path as a status entry
489
+ if (/[RC]/.test(xy)) {
490
+ i++;
491
+ continue;
492
+ }
493
+ if (xy === "??") remember(pathForMatch(entry.subarray(3)));
494
+ }
495
+
496
+ const gitlinks = gitlinkRecords(cwd, baseRef);
497
+ // null-prototype for the same reason dirtySnapshot uses one: a file named
498
+ // `__proto__` must be an own data property, not a write through the setter
499
+ const changed = Object.create(null);
500
+ for (const key of [...paths].sort()) {
501
+ const abs = absPathBuf(cwd, key);
502
+ let st = null;
503
+ let statError = null;
504
+ try {
505
+ st = key.endsWith("/") ? null : fs.lstatSync(abs, { bigint: true });
506
+ } catch (err) {
507
+ statError = err;
508
+ st = null;
509
+ }
510
+ if (st === null) {
511
+ changed[key] =
512
+ statError && statError.code !== "ENOENT" ? unsafeSnapshotFingerprint("unreadable", null) : null;
513
+ continue;
514
+ }
515
+ if (st.isDirectory()) {
516
+ // A gitlink differs from base by the commit it points at, which no
517
+ // filesystem fingerprint of the directory can see. Git resolves the
518
+ // worktree side of the pointer, so its raw record is the content.
519
+ changed[key] = gitlinkFingerprint(cwd, key, gitlinks.has(key), gitlinks.get(key), st);
520
+ continue;
521
+ }
522
+ const fingerprint = fingerprintDirtyPath(abs, st, false);
523
+ // A sentinel is stored bare so the strict rejection below still
524
+ // recognizes it by prefix.
525
+ if (fingerprint.startsWith("unsafe:") || fingerprint.startsWith("unreadable:")) {
526
+ changed[key] = fingerprint;
527
+ continue;
528
+ }
529
+ // The object type leads, because a symlink's fingerprint hashes the
530
+ // string `link:<target>` and a regular file holding exactly those bytes
531
+ // would otherwise be indistinguishable from it. git records exactly
532
+ // 100644 or 100755 and derives that from the OWNER execute bit alone, so
533
+ // group and other execute are not part of what the snapshot compares.
534
+ const kind = st.isSymbolicLink() ? "link" : "blob";
535
+ changed[key] = `${kind}:${fingerprint}:${(st.mode & 0o100n) === 0n ? "-" : "x"}`;
536
+ }
537
+ return changed;
538
+ }
539
+
540
+ /**
541
+ * One gitlink's snapshot record: always the submodule's checked-out HEAD, and
542
+ * never git's raw destination id. That id is the indexed pointer and is
543
+ * all-zeros while the submodule is out of sync, so reading it would give the
544
+ * same worktree pointer two spellings and let `git add` alone stale a review.
545
+ *
546
+ * A directory is only a gitlink when git says so. Without that gate an
547
+ * ordinary directory left where a tracked file used to be would resolve
548
+ * through the parent repository and hash as a plausible pointer instead of
549
+ * being refused. Anything unresolvable stays unsafe, which strict dispatch
550
+ * rejects rather than approving a tree it could not read.
551
+ */
552
+ function gitlinkFingerprint(cwd, latin1, isGitlink, indexed, st) {
553
+ if (!isGitlink) return unsafeSnapshotFingerprint("unsafe", st);
554
+ const head = submoduleHead(cwd, latin1);
555
+ // One spelling for one pointer. A checked-out submodule answers with its
556
+ // own HEAD, which is what the reviewer's diff shows and what neither
557
+ // staging nor committing can change. Git's own id is the indexed pointer:
558
+ // it is all-zeros while the checkout is out of sync, so it serves only the
559
+ // uninitialized case, where there is no checkout to ask and the recorded
560
+ // pointer is the whole truth.
561
+ // The reviewer's diff carries git's `-dirty` marker for a submodule whose
562
+ // worktree has uncommitted work, so the snapshot carries it too.
563
+ if (head !== null) return `gitlink:${head}:${submoduleIsDirty(cwd, latin1) ? "dirty" : "clean"}`;
564
+ if (indexed) return `gitlink:${indexed}:absent`;
565
+ return unsafeSnapshotFingerprint("unsafe", st);
566
+ }
567
+
568
+ /** Whether the submodule checkout has uncommitted work, as git's marker reads it. */
569
+ function submoduleIsDirty(cwd, latin1) {
570
+ const directory = submoduleDirectory(cwd, latin1);
571
+ if (directory === null) return true;
572
+ try {
573
+ return (
574
+ execFileSync("git", ["-C", directory, "status", "--porcelain", "--untracked-files=all"], {
575
+ stdio: ["ignore", "pipe", "pipe"],
576
+ maxBuffer: MAX_SUBPROCESS_BUFFER,
577
+ }).length > 0
578
+ );
579
+ } catch {
580
+ return true;
581
+ }
582
+ }
583
+
584
+ /**
585
+ * The submodule's directory as a string git can take after `-C`, or null when
586
+ * the name is not valid UTF-8 and therefore cannot be handed over without
587
+ * corruption.
588
+ */
589
+ function submoduleDirectory(cwd, latin1) {
590
+ const bytes = Buffer.from(latin1, "latin1");
591
+ const utf8 = bytes.toString("utf8");
592
+ if (!Buffer.from(utf8, "utf8").equals(bytes)) return null;
593
+ return path.join(cwd, utf8);
594
+ }
595
+
596
+ /** The submodule's checked-out HEAD, or null when there is no checkout to ask. */
597
+ function submoduleHead(cwd, latin1) {
598
+ const directory = submoduleDirectory(cwd, latin1);
599
+ if (directory === null) return null;
600
+ try {
601
+ const [head, toplevel] = execFileSync(
602
+ "git",
603
+ ["-C", directory, "rev-parse", "HEAD", "--show-toplevel"],
604
+ { stdio: ["ignore", "pipe", "pipe"], maxBuffer: MAX_SUBPROCESS_BUFFER },
605
+ )
606
+ .toString("utf8")
607
+ .trim()
608
+ .split("\n");
609
+ if (!/^[0-9a-f]{40,}$/.test(head ?? "")) return null;
610
+ // the submodule must be its own repository root; resolving to an
611
+ // enclosing one would report the superproject's HEAD as this pointer
612
+ if (path.resolve(toplevel ?? "") !== path.resolve(directory)) return null;
613
+ return head;
614
+ } catch {
615
+ return null;
616
+ }
617
+ }
618
+
619
+ /**
620
+ * The changed paths git reports as gitlinks, keyed byte-exactly. Only their
621
+ * identity is taken from here — the pointer itself is read from the submodule
622
+ * checkout, because the id in this record is the indexed one.
623
+ */
624
+ function gitlinkRecords(cwd, baseRef) {
625
+ const records = new Map();
626
+ let output;
627
+ try {
628
+ output = execFileSync(
629
+ "git",
630
+ [
631
+ "-C",
632
+ cwd,
633
+ "diff",
634
+ "--raw",
635
+ "-z",
636
+ "--no-renames", // same reason as the name-only pass: one path set
637
+ "--end-of-options",
638
+ baseRef,
639
+ "--",
640
+ ".",
641
+ ...reviewExemptPathspecs(cwd),
642
+ ],
643
+ { stdio: ["ignore", "pipe", "pipe"], maxBuffer: MAX_SUBPROCESS_BUFFER },
644
+ );
645
+ } catch {
646
+ return records;
647
+ }
648
+ const tokens = splitNul(output);
649
+ for (let i = 0; i < tokens.length; i++) {
650
+ const meta = tokens[i].toString("latin1");
651
+ if (!meta.startsWith(":")) continue;
652
+ // :<srcmode> <dstmode> <srcsha> <dstsha> <status>\0<path>[\0<dest>]
653
+ const fields = meta.slice(1).split(" ");
654
+ if (fields.length < 5) continue;
655
+ const [srcMode, dstMode, , dstSha, status] = fields;
656
+ let p = tokens[++i];
657
+ if (p === undefined) break;
658
+ // a rename or copy emits source then destination; the destination is the
659
+ // path that exists now, and it is the one the snapshot tracks
660
+ if (/^[RC]/.test(status)) {
661
+ const destination = tokens[++i];
662
+ if (destination === undefined) break;
663
+ p = destination;
664
+ }
665
+ if (srcMode !== "160000" && dstMode !== "160000") continue;
666
+ records.set(pathForMatch(p), /^0+$/.test(dstSha ?? "") ? null : dstSha);
667
+ }
668
+ return records;
669
+ }
670
+
671
+ function capturedChangedFiles(cwd, baseRef, strict) {
672
+ try {
673
+ return execFileSync(
674
+ "git",
675
+ [
676
+ "-C",
677
+ cwd,
678
+ "diff",
679
+ "--name-status",
680
+ "-z",
681
+ "--end-of-options",
682
+ baseRef,
683
+ "--",
684
+ ".",
685
+ ...reviewExemptPathspecs(cwd),
686
+ ],
687
+ { stdio: ["ignore", "pipe", "pipe"], maxBuffer: MAX_SUBPROCESS_BUFFER },
688
+ );
689
+ } catch (err) {
690
+ if (strict) {
691
+ fail(`cannot enumerate changed files — review aborted (git: ${subprocessError(err)})`);
692
+ }
693
+ return Buffer.from("(unresolvable changed-file manifest)");
694
+ }
695
+ }
696
+
697
+ function capturedPorcelain(cwd) {
698
+ try {
699
+ return execFileSync("git", ["-C", cwd, "status", "--porcelain"], {
700
+ encoding: "utf8",
701
+ stdio: ["ignore", "pipe", "pipe"],
702
+ });
703
+ } catch {
704
+ return "(unavailable)";
705
+ }
706
+ }
707
+
708
+ function capturedUntrackedFiles(cwd, strict) {
709
+ try {
710
+ return execFileSync("git", ["-C", cwd, "ls-files", "--others", "--exclude-standard", "-z"], {
711
+ stdio: ["ignore", "pipe", "pipe"],
712
+ maxBuffer: MAX_SUBPROCESS_BUFFER,
713
+ });
714
+ } catch (err) {
715
+ if (strict) {
716
+ fail(`cannot enumerate untracked files — review aborted (git: ${subprocessError(err)})`);
717
+ }
718
+ return Buffer.from("(unresolvable untracked-file manifest)");
719
+ }
720
+ }
721
+
722
+ export function captureReviewMaterial(cwd, baseRef, strict = false) {
723
+ const diffBytes = reviewDiff(cwd, baseRef, strict);
724
+ const diff = diffBytes.toString("utf8");
725
+ const dirty = dirtySnapshot(cwd);
726
+ const reviewDirty = dirtySnapshot(cwd, { boundedReview: true });
727
+ const changedContent = changedContentFingerprints(cwd, baseRef, strict);
728
+ if (strict) {
729
+ // A review over bytes that cannot be fingerprinted safely proves
730
+ // nothing. Soft callers retain the metadata sentinel for stale logic,
731
+ // but dispatch/grading rejects unreadable, raced, hard-linked, and
732
+ // non-regular paths — including a committed one that git status calls
733
+ // clean, since the snapshot now reads those too.
734
+ const maps = [dirty, reviewDirty, changedContent];
735
+ const unsafe = [...new Set(maps.flatMap((m) => Object.keys(m)))].filter((p) =>
736
+ maps.some((m) => m[p]?.startsWith?.("unreadable:") || m[p]?.startsWith?.("unsafe:")),
737
+ );
738
+ if (unsafe.length > 0) {
739
+ // the keys are latin1 byte-exact; render them through the one view
740
+ // seam so a non-UTF-8 or control-byte name reads right and cannot
741
+ // inject a line into the message
742
+ fail(
743
+ `file(s) cannot be fingerprinted safely — nothing to review there: ${unsafe
744
+ .map(viewPath)
745
+ .join(", ")}`,
746
+ );
747
+ }
748
+ }
749
+ const plan = currentTaskPlan(cwd);
750
+ const changedFiles = capturedChangedFiles(cwd, baseRef, strict);
751
+ const porcelain = capturedPorcelain(cwd);
752
+ const untrackedFiles = capturedUntrackedFiles(cwd, strict);
753
+ const snapshot = sha256(`${JSON.stringify(changedContent)}\n${normalizedPlanContent(plan)}`);
754
+ // The durable snapshot deliberately retains its long-standing exemptions
755
+ // (ledger/plan bookkeeping and trusted reset temps). The ephemeral
756
+ // material binding is stricter: every independent read consumed while
757
+ // composing the brief must agree before and after the build.
758
+ const materialBinding = sha256(
759
+ `${snapshot}\n${JSON.stringify(reviewDirty)}\n${sha256(changedFiles)}\n${porcelain}\n${sha256(
760
+ untrackedFiles,
761
+ )}`,
762
+ );
763
+ return {
764
+ snapshot,
765
+ materialBinding,
766
+ diffBytes,
767
+ diff,
768
+ dirty,
769
+ reviewDirty,
770
+ plan,
771
+ changedFiles,
772
+ porcelain,
773
+ untrackedFiles,
774
+ };
775
+ }
776
+
777
+ /**
778
+ * Hash of the work under review: the content of every path that differs from
779
+ * baseRef — tracked or untracked, committed or not — plus the plan's text,
780
+ * `.stdd/` excluded, because the ledger and the plan are working artifacts and
781
+ * recording the review itself must not invalidate it. Content, never git's
782
+ * bookkeeping: staging or committing the reviewed work moves no bytes on disk
783
+ * and therefore cannot stale a verdict about those bytes.
784
+ */
785
+ export function reviewSnapshot(cwd, baseRef, strict = false) {
786
+ return captureReviewMaterial(cwd, baseRef, strict).snapshot;
787
+ }
788
+
789
+ /**
790
+ * Inspect one review path through a descriptor. No bytes are returned until
791
+ * the opened inode and its parent have been proven to be stable, regular, and
792
+ * contained by the repository both before and after the bounded read.
793
+ */
794
+ export function inspectReviewPath(cwd, latin1, realRoot, readLimit = null) {
795
+ const absolute = absPathBuf(cwd, latin1);
796
+ const parent = parentPathBuf(absolute);
797
+ let before;
798
+ let parentBefore;
799
+ try {
800
+ before = fs.lstatSync(absolute, { bigint: true });
801
+ parentBefore = fs.lstatSync(parent, { bigint: true });
802
+ } catch (err) {
803
+ return {
804
+ kind: err.code === "ENOENT" ? "missing" : "unsafe",
805
+ reason: err.code === "ENOENT" ? "missing or deleted" : "metadata could not be read safely",
806
+ };
807
+ }
808
+ if (
809
+ before.isSymbolicLink() ||
810
+ !before.isFile() ||
811
+ before.nlink !== 1n ||
812
+ parentBefore.isSymbolicLink() ||
813
+ !parentBefore.isDirectory()
814
+ ) {
815
+ return {
816
+ kind: "unsafe",
817
+ reason: "symlink, hard-linked, or non-regular filesystem object",
818
+ };
819
+ }
820
+
821
+ let descriptor = null;
822
+ try {
823
+ const realParentBefore = realPathBuf(parent);
824
+ if (!bufferPathIsWithin(realRoot, realParentBefore)) {
825
+ return { kind: "unsafe", reason: "parent resolves outside the repository" };
826
+ }
827
+ descriptor = fs.openSync(
828
+ absolute,
829
+ fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK,
830
+ );
831
+ const opened = fs.fstatSync(descriptor, { bigint: true });
832
+ const pathAtOpen = fs.lstatSync(absolute, { bigint: true });
833
+ const parentAtOpen = fs.lstatSync(parent, { bigint: true });
834
+ const realParentAtOpen = realPathBuf(parent);
835
+ const realFileAtOpen = realPathBuf(absolute);
836
+ if (
837
+ !opened.isFile() ||
838
+ opened.nlink !== 1n ||
839
+ pathAtOpen.isSymbolicLink() ||
840
+ !pathAtOpen.isFile() ||
841
+ !sameFileObservation(before, opened) ||
842
+ !sameFileObservation(opened, pathAtOpen) ||
843
+ !sameFileObservation(parentBefore, parentAtOpen) ||
844
+ !bufferPathIsWithin(realRoot, realParentAtOpen) ||
845
+ !bufferPathIsWithin(realRoot, realFileAtOpen)
846
+ ) {
847
+ return { kind: "unsafe", reason: "changed or escaped during descriptor inspection" };
848
+ }
849
+
850
+ let bytes = null;
851
+ let truncated = false;
852
+ let contentHash = null;
853
+ if (readLimit !== null) {
854
+ const fingerprinted = fingerprintBoundedReviewDescriptor(descriptor, opened, true);
855
+ if (!fingerprinted.complete) {
856
+ return { kind: "unsafe", reason: "changed or ended during bounded descriptor read" };
857
+ }
858
+ bytes = fingerprinted.bytes;
859
+ truncated = fingerprinted.oversized;
860
+ contentHash = fingerprinted.fingerprint;
861
+ }
862
+
863
+ const after = fs.fstatSync(descriptor, { bigint: true });
864
+ const finalPath = fs.lstatSync(absolute, { bigint: true });
865
+ const finalParent = fs.lstatSync(parent, { bigint: true });
866
+ const realParentAfter = realPathBuf(parent);
867
+ const realFileAfter = realPathBuf(absolute);
868
+ if (
869
+ finalPath.isSymbolicLink() ||
870
+ !finalPath.isFile() ||
871
+ !sameFileObservation(opened, after) ||
872
+ !sameFileObservation(after, finalPath) ||
873
+ !sameFileObservation(parentBefore, finalParent) ||
874
+ !bufferPathIsWithin(realRoot, realParentAfter) ||
875
+ !bufferPathIsWithin(realRoot, realFileAfter)
876
+ ) {
877
+ return { kind: "unsafe", reason: "changed or escaped while being inspected" };
878
+ }
879
+ return { kind: "regular", bytes, truncated, contentHash };
880
+ } catch (err) {
881
+ return {
882
+ kind: "unsafe",
883
+ reason:
884
+ err.code === "ELOOP"
885
+ ? "became a symlink during inspection"
886
+ : "could not be inspected without following filesystem replacements",
887
+ };
888
+ } finally {
889
+ if (descriptor !== null) {
890
+ try {
891
+ fs.closeSync(descriptor);
892
+ } catch {
893
+ // The descriptor no longer participates in the brief once inspection returns.
894
+ }
895
+ }
896
+ }
897
+ }