@sabaiway/agent-workflow-kit 10.6.0 → 11.0.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 (50) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/SKILL.md +9 -1
  3. package/bridges/antigravity-cli-bridge/SKILL.md +1 -1
  4. package/bridges/antigravity-cli-bridge/bin/agy-review.sh +6 -6
  5. package/bridges/antigravity-cli-bridge/capability.json +1 -1
  6. package/bridges/codex-cli-bridge/SKILL.md +1 -1
  7. package/bridges/codex-cli-bridge/bin/codex-exec.sh +1 -1
  8. package/bridges/codex-cli-bridge/bin/codex-review.sh +6 -6
  9. package/bridges/codex-cli-bridge/capability.json +1 -1
  10. package/capability.json +1 -1
  11. package/package.json +1 -1
  12. package/references/modes/commit-guard.md +2 -2
  13. package/references/modes/control-bytes.md +91 -0
  14. package/references/modes/flow-writer.md +16 -4
  15. package/references/modes/procedures.md +2 -0
  16. package/references/modes/review-state.md +1 -1
  17. package/references/modes/robustness-brief.md +61 -0
  18. package/references/modes/velocity.md +2 -0
  19. package/references/robustness-literals.json +107 -0
  20. package/tools/changed-surface.mjs +1 -1
  21. package/tools/commands.mjs +14 -0
  22. package/tools/commit-guard.mjs +20 -9
  23. package/tools/control-bytes.mjs +264 -0
  24. package/tools/core-evidence.mjs +33 -20
  25. package/tools/coverage-check.mjs +1 -1
  26. package/tools/dispatch-store-read.mjs +2 -2
  27. package/tools/flow-check-git-lane.mjs +2 -1
  28. package/tools/flow-delta-proof.mjs +4 -4
  29. package/tools/flow-record-shape.mjs +44 -2
  30. package/tools/flow-round-gates.mjs +151 -0
  31. package/tools/flow-store-read.mjs +1 -1
  32. package/tools/flow-writer.mjs +235 -82
  33. package/tools/fold-scope.mjs +7 -3
  34. package/tools/fs-read-nofollow.mjs +56 -4
  35. package/tools/git-env.mjs +120 -0
  36. package/tools/grounding.mjs +4 -3
  37. package/tools/plan-shape-facts.mjs +23 -10
  38. package/tools/plan-shape.mjs +8 -0
  39. package/tools/procedures.mjs +18 -4
  40. package/tools/repo-lex.mjs +29 -0
  41. package/tools/review-state.mjs +43 -34
  42. package/tools/robustness-brief.mjs +209 -0
  43. package/tools/robustness-literals.mjs +101 -0
  44. package/tools/sandbox-masks.mjs +2 -1
  45. package/tools/set-flow.mjs +2 -1
  46. package/tools/source-size-scope.mjs +19 -25
  47. package/tools/tracked-tree-census.mjs +3 -3
  48. package/tools/velocity-profile.mjs +5 -2
  49. package/tools/worktree-handoff-return.mjs +2 -2
  50. package/tools/worktrees.mjs +2 -2
@@ -0,0 +1,107 @@
1
+ {
2
+ "schema": 1,
3
+ "version": 1,
4
+ "classes": [
5
+ {
6
+ "id": "git-location",
7
+ "prove": "Prove that every git query reaches the intended repository: every GIT_-named variable is stripped before cwd discovery, the git-dir, common-dir and work-tree identities agree between the ambient and the stripped discovery, and the hermetic environment is the fixtures' own, never a consumer's.",
8
+ "members": [
9
+ { "literal": "GIT_", "kind": "env", "note": "Strip every ASCII-case-insensitive git location variable before cwd discovery.", "source": "agent-workflow-kit/tools/git-env.mjs" },
10
+ { "literal": "GIT_DIR", "kind": "env", "note": "An ambient git directory can redirect discovery to another repository.", "source": "agent-workflow-kit/tools/git-env.mjs" },
11
+ { "literal": "GIT_WORK_TREE", "kind": "env", "note": "An ambient work-tree override can pair one git directory with another tree.", "source": "agent-workflow-kit/tools/git-env.mjs" },
12
+ { "literal": "GIT_COMMON_DIR", "kind": "env", "note": "An ambient common directory can redirect shared repository state.", "source": "agent-workflow-kit/tools/git-env.mjs" },
13
+ { "literal": "GIT_INDEX_FILE", "kind": "env", "note": "A pre-commit hook can name a temporary or copied index.", "source": "agent-workflow-kit/tools/hostile-git-harness.test.mjs" },
14
+ { "literal": "GIT_PREFIX", "kind": "env", "note": "A hook inherits the prefix git measured for its invocation.", "source": "agent-workflow-kit/tools/hostile-git-harness.test.mjs" },
15
+ { "literal": "GIT_CONFIG_PARAMETERS", "kind": "env", "note": "Command-line git configuration reaches hooks through this variable.", "source": "agent-workflow-kit/tools/hostile-git-harness.test.mjs" },
16
+ { "literal": "GIT_EXEC_PATH", "kind": "env", "note": "A hook-shaped environment carries git's executable path.", "source": "agent-workflow-kit/tools/hostile-git-harness.test.mjs" },
17
+ { "literal": "HOME", "kind": "env", "note": "Pin the home directory for hermetic git configuration.", "source": "agent-workflow-kit/tools/git-env.mjs" },
18
+ { "literal": "XDG_CONFIG_HOME", "kind": "env", "note": "Pin the XDG configuration home beside HOME.", "source": "agent-workflow-kit/tools/git-env.mjs" },
19
+ { "literal": "GIT_CONFIG_GLOBAL", "kind": "env", "note": "Disable the host's global git configuration file.", "source": "agent-workflow-kit/tools/git-env.mjs" },
20
+ { "literal": "GIT_CONFIG_SYSTEM", "kind": "env", "note": "Disable the host's system git configuration file.", "source": "agent-workflow-kit/tools/git-env.mjs" },
21
+ { "literal": "GIT_CONFIG_NOSYSTEM", "kind": "env", "note": "Tell git not to consult system configuration.", "source": "agent-workflow-kit/tools/git-env.mjs" },
22
+ { "literal": "GIT_ATTR_NOSYSTEM", "kind": "env", "note": "Tell git not to consult system attributes.", "source": "agent-workflow-kit/tools/git-env.mjs" },
23
+ { "literal": "LC_ALL", "kind": "env", "note": "Pin git's diagnostic language before matching its answers.", "source": "agent-workflow-kit/tools/git-env.mjs" },
24
+ { "literal": "PATH", "kind": "env", "note": "A supplied PATH is honored and an omitted PATH borrows the process value.", "source": "agent-workflow-kit/tools/git-env.mjs" },
25
+ { "literal": "--absolute-git-dir", "kind": "argv", "note": "Compare the ambient and stripped git-directory identities.", "source": "agent-workflow-kit/tools/git-env.mjs" },
26
+ { "literal": "--git-common-dir", "kind": "argv", "note": "Compare the ambient and stripped common-directory identities.", "source": "agent-workflow-kit/tools/git-env.mjs" },
27
+ { "literal": "--show-toplevel", "kind": "argv", "note": "Compare the ambient and stripped work-tree identities.", "source": "agent-workflow-kit/tools/git-env.mjs" },
28
+ { "literal": "work-tree", "kind": "state", "note": "All three ambient and stripped identities agree.", "source": "agent-workflow-kit/tools/git-env.mjs" },
29
+ { "literal": "not-a-repository", "kind": "state", "note": "Both discoveries report that cwd reaches no repository.", "source": "agent-workflow-kit/tools/git-env.mjs" },
30
+ { "literal": "error", "kind": "state", "note": "A probe, process result, path identity or answer failed closed.", "source": "agent-workflow-kit/tools/git-env.mjs" },
31
+ { "literal": "redirected", "kind": "state", "note": "Ambient and stripped discovery reach different repository identities.", "source": "agent-workflow-kit/tools/git-env.mjs" },
32
+ { "literal": "no-work-tree", "kind": "state", "note": "The git directory agrees but names no work tree.", "source": "agent-workflow-kit/tools/git-env.mjs" },
33
+ { "literal": "env-only", "kind": "state", "note": "Only the ambient git environment reaches a repository.", "source": "agent-workflow-kit/tools/git-env.mjs" },
34
+ { "literal": "GIT_MAX_BUFFER", "kind": "state", "note": "Every declared non-test git spawn buffer under agent-workflow-kit/tools/ uses the 256 MiB GIT_MAX_BUFFER; git-env.mjs declares it and every other scanned use imports it.", "source": "agent-workflow-kit/tools/git-env.mjs" }
35
+ ]
36
+ },
37
+ {
38
+ "id": "git-operation-state",
39
+ "prove": "Prove that an interrupted or conflicted git operation cannot be mistaken for an ordinary clean work tree.",
40
+ "members": [
41
+ { "literal": "rebase-apply", "kind": "state", "note": "A stopped git am leaves the apply-backend directory even when ordinary diffs are clean.", "source": "agent-workflow-kit/tools/hostile-git-harness.test.mjs" },
42
+ { "literal": "rebase-merge", "kind": "state", "note": "A stopped rebase on the merge backend leaves this directory and REBASE_HEAD while status names the conflicted path.", "source": "agent-workflow-kit/tools/hostile-git-harness.test.mjs" },
43
+ { "literal": "MERGE_HEAD", "kind": "ref", "note": "A merge in progress leaves this sequencer reference.", "source": "scripts/release/release-run.mjs" },
44
+ { "literal": "CHERRY_PICK_HEAD", "kind": "ref", "note": "A cherry-pick in progress leaves this sequencer reference.", "source": "scripts/release/release-run.mjs" },
45
+ { "literal": "REVERT_HEAD", "kind": "ref", "note": "A revert in progress leaves this sequencer reference.", "source": "scripts/release/release-run.mjs" },
46
+ { "literal": "REBASE_HEAD", "kind": "ref", "note": "A rebase in progress leaves this sequencer reference.", "source": "scripts/release/release-run.mjs" },
47
+ { "literal": "index.lock", "kind": "state", "note": "A held index lock blocks writes while read-only index queries still answer.", "source": "agent-workflow-kit/tools/hostile-git-harness.test.mjs" },
48
+ { "literal": "unmerged stage 1", "kind": "state", "note": "The conflicted path carries the common-ancestor index stage.", "source": "agent-workflow-kit/tools/hostile-git-harness.test.mjs" },
49
+ { "literal": "unmerged stage 2", "kind": "state", "note": "The conflicted path carries the current-side index stage.", "source": "agent-workflow-kit/tools/hostile-git-harness.test.mjs" },
50
+ { "literal": "unmerged stage 3", "kind": "state", "note": "The conflicted path carries the other-side index stage.", "source": "agent-workflow-kit/tools/hostile-git-harness.test.mjs" },
51
+ { "literal": "unmerged path twice", "kind": "state", "note": "Git 2.43 lists one conflicted path once per unmerged side; git am and bisect leave none of the four sequencer refs above.", "source": "agent-workflow-kit/tools/hostile-git-harness.test.mjs" }
52
+ ]
53
+ },
54
+ {
55
+ "id": "git-read-flags",
56
+ "prove": "Prove that host git configuration, attributes and output framing cannot hide bytes or paths from a read.",
57
+ "members": [
58
+ { "literal": "status.showUntrackedFiles", "kind": "flag", "note": "Porcelain status can hide untracked files when this config is no.", "source": "agent-workflow-kit/tools/hostile-git-harness.test.mjs" },
59
+ { "literal": "core.autocrlf", "kind": "flag", "note": "Work-tree CRLF and blob LF can compare clean under this config.", "source": "agent-workflow-kit/tools/hostile-git-harness.test.mjs" },
60
+ { "literal": "core.symlinks", "kind": "flag", "note": "A host can materialize a tracked symlink as a placeholder regular file.", "source": "agent-workflow-kit/tools/control-bytes.mjs" },
61
+ { "literal": "--no-textconv", "kind": "flag", "note": "Disable textconv so configured filters cannot erase staged content from a diff.", "source": "scripts/release/release-run.mjs" },
62
+ { "literal": "--ignore-submodules=none", "kind": "flag", "note": "Override a host config that hides every submodule change.", "source": "scripts/release/release-run.mjs" },
63
+ { "literal": "-z", "kind": "flag", "note": "Use NUL framing for path-bearing git output.", "source": "agent-workflow-kit/tools/control-bytes.mjs" },
64
+ { "literal": "--exclude-standard", "kind": "flag", "note": "Apply repository ignore rules only where the query explicitly intends them.", "source": "agent-workflow-kit/tools/control-bytes.mjs" },
65
+ { "literal": "%B", "kind": "flag", "note": "Raw subject and body output carries its terminating newline and needs explicit framing.", "source": "scripts/check-ascii-letters.mjs" },
66
+ { "literal": "LC_ALL=C", "kind": "env", "note": "Pin git's own diagnostic wording before matching it.", "source": "agent-workflow-kit/tools/git-env.mjs" }
67
+ ]
68
+ },
69
+ {
70
+ "id": "spawn-outcome",
71
+ "prove": "Prove that every process-launch and process-result state is handled explicitly and never collapses into an empty success.",
72
+ "members": [
73
+ { "literal": "ENOENT", "kind": "errno", "note": "The executable may be absent from PATH before a child starts.", "source": "agent-workflow-kit/tools/hostile-git-harness.test.mjs" },
74
+ { "literal": "EACCES", "kind": "errno", "note": "The executable or a required path may be present but inaccessible.", "source": "agent-workflow-kit/tools/fs-safe.mjs" },
75
+ { "literal": "EPERM", "kind": "errno", "note": "A sandbox or host policy may refuse the attempted process or filesystem operation.", "source": "agent-workflow-kit/tools/fs-safe.mjs" },
76
+ { "literal": "signal", "kind": "state", "note": "A child killed by a signal has no ordinary exit status.", "source": "agent-workflow-kit/tools/hostile-git-harness.test.mjs" },
77
+ { "literal": "synchronous throw", "kind": "state", "note": "An injected or native runner can throw before returning a process result.", "source": "agent-workflow-kit/tools/control-bytes.mjs" },
78
+ { "literal": "exit 128", "kind": "state", "note": "Git's own not-a-repository or no-work-tree wording classifies only the matching exit 128 answer.", "source": "agent-workflow-kit/tools/git-env.mjs" },
79
+ { "literal": "empty answer", "kind": "state", "note": "A zero exit with no identity text is an error, never a repository path.", "source": "agent-workflow-kit/tools/git-env.mjs" },
80
+ { "literal": "maxBuffer", "kind": "state", "note": "A declared output ceiling is shared rather than silently truncated or copied.", "source": "agent-workflow-kit/tools/git-env.mjs" }
81
+ ]
82
+ },
83
+ {
84
+ "id": "durable-write",
85
+ "prove": "Prove that a durable write binds the intended regular file, publishes atomically and refuses an existing exclusive target.",
86
+ "members": [
87
+ { "literal": "O_NOFOLLOW", "kind": "syscall", "note": "Refuse a symlink leaf at descriptor open.", "source": "agent-workflow-kit/tools/fs-read-nofollow.mjs" },
88
+ { "literal": "O_NONBLOCK", "kind": "syscall", "note": "Prevent a hostile FIFO from blocking a descriptor open forever.", "source": "agent-workflow-kit/tools/fs-read-nofollow.mjs" },
89
+ { "literal": "fsync", "kind": "syscall", "note": "Sync the temporary file before rename and the directory after publication where supported.", "source": "scripts/release/release-run.mjs" },
90
+ { "literal": "rename", "kind": "syscall", "note": "Publish the fully written temporary file atomically after fsync.", "source": "scripts/release/release-run.mjs" },
91
+ { "literal": "wx", "kind": "flag", "note": "Open a temporary file or lock with exclusive creation.", "source": "scripts/release/release-run.mjs" },
92
+ { "literal": "EEXIST", "kind": "errno", "note": "An existing exclusive lock is a named busy state, never permission to clobber.", "source": "scripts/release/release-run.mjs" }
93
+ ]
94
+ },
95
+ {
96
+ "id": "bytes-not-strings",
97
+ "prove": "Prove that byte-domain identities remain bytes until a deliberate lossless decoding boundary.",
98
+ "members": [
99
+ { "literal": "undecoded", "kind": "state", "note": "Path records remain Buffer slices through NUL framing.", "source": "agent-workflow-kit/tools/git-env.mjs" },
100
+ { "literal": "NUL", "kind": "state", "note": "Git path records are split on the byte delimiter rather than whitespace or lines.", "source": "agent-workflow-kit/tools/git-env.mjs" },
101
+ { "literal": "partial UTF-8 sequence", "kind": "state", "note": "A file can be observed while its last multibyte sequence is incomplete.", "source": "agent-workflow-kit/tools/hostile-git-harness.test.mjs" },
102
+ { "literal": "raw control byte", "kind": "state", "note": "A report line must never carry a raw line-breaking control character.", "source": "agent-workflow-kit/tools/repo-lex.mjs" },
103
+ { "literal": "U+FFFD", "kind": "state", "note": "Lossy decoding can fold distinct invalid byte sequences to the replacement character.", "source": "agent-workflow-kit/tools/repo-lex.mjs" }
104
+ ]
105
+ }
106
+ ]
107
+ }
@@ -14,7 +14,7 @@ import { readFileSync, lstatSync } from 'node:fs';
14
14
  import { join } from 'node:path';
15
15
  import { spawnSync } from 'node:child_process';
16
16
 
17
- const GIT_MAX_BUFFER = 256 * 1024 * 1024; // a full-tree diff can be large; never truncate
17
+ import { GIT_MAX_BUFFER } from './git-env.mjs';
18
18
 
19
19
  // ── the CLOSED changed-path classification rule (AD-046 Decision 5; no heuristics) ───────────────
20
20
 
@@ -224,6 +224,20 @@ const CATALOG = [
224
224
  kind: READ_ONLY,
225
225
  oneLine: 'Check that every configured review backend has receipted the current uncommitted tree with a fresh grounded review; --check turns it into a gate exit code.',
226
226
  },
227
+ {
228
+ key: 'control-bytes',
229
+ invocation: invocationOf('control-bytes'),
230
+ group: 'Orchestrate',
231
+ kind: READ_ONLY,
232
+ oneLine: 'Refuse any raw control byte (C0 or DEL; TAB, LF, CR admitted) in the work tree — a path name, a regular file\'s content unless a git attribute says binary, a symlink target; --check is the gate spelling of the same report and exit code.',
233
+ },
234
+ {
235
+ key: 'robustness-brief',
236
+ invocation: invocationOf('robustness-brief'),
237
+ group: 'Orchestrate',
238
+ kind: READ_ONLY,
239
+ oneLine: 'Generate the concrete robustness literals for every tagged row in a plan, or for one selected row.',
240
+ },
227
241
  {
228
242
  key: 'receipt-deadline',
229
243
  invocation: invocationOf('receipt-deadline'),
@@ -54,11 +54,12 @@ import {
54
54
  import { resolveLcovPath } from './coverage-check.mjs';
55
55
  import { GATES_REL, loadDeclaration } from './run-gates.mjs';
56
56
  import { computeFlowDecision } from './flow-check.mjs';
57
+ import { GIT_MAX_BUFFER, resolveGitLocation } from './git-env.mjs';
58
+ import { escapeForDisplay } from './repo-lex.mjs';
57
59
 
58
60
  const usageFail = (message) => Object.assign(new Error(`[agent-workflow-kit] ${message}`), { exitCode: 2 });
59
61
  const sha = (text) => createHash('sha256').update(text).digest('hex');
60
62
 
61
- const GIT_MAX_BUFFER = 256 * 1024 * 1024;
62
63
  const gitLine = (args, cwd) => {
63
64
  const r = spawnSync('git', args, { cwd, maxBuffer: GIT_MAX_BUFFER, windowsHide: true });
64
65
  if (r.error || r.status !== 0) return null;
@@ -201,28 +202,38 @@ const flowAdvisoryLines = (flow) => (flow.present && flow.armed
201
202
  : []);
202
203
 
203
204
  export const runGuard = ({ cwd = process.cwd(), env = process.env } = {}) => {
204
- const rootTop = gitLine(['rev-parse', '--show-toplevel'], cwd);
205
- if (rootTop == null) return { code: 1, lines: ['commit-guard: not a git work tree — nothing to guard'] };
206
- // FIRST: a pure tree property needing no store read. Its recovery re-stages the tree and re-mints
205
+ // FIRST of all: WHICH repository. The location is a closed six-state table judged by realpath
206
+ // agreement (the git-env leaf); every state but work-tree refuses by its own name — a redirecting
207
+ // GIT_DIR, a killed or absent git, a bare repository — instead of guarding another tree or
208
+ // passing a tree git could not describe. A pre-commit hook's own env agrees and passes.
209
+ const location = resolveGitLocation(cwd, { env });
210
+ if (location.state !== 'work-tree') {
211
+ // The cause can carry a repository PATH (a newline-bearing one included) — escaped, ONE line.
212
+ return { code: 1, lines: [`commit-guard: REFUSED — the git location is ${location.state} (${escapeForDisplay(location.cause)}) — nothing is guarded until git names one work tree from ${escapeForDisplay(cwd)}`] };
213
+ }
214
+ const rootTop = location.top;
215
+ // NEXT: a pure tree property needing no store read. Its recovery re-stages the tree and re-mints
207
216
  // the receipt, so every arm below is re-decided anyway — naming a stale fingerprint ahead of it
208
217
  // would send the operator down a recovery they must redo.
209
- const working = computeWorkingState(cwd);
218
+ const working = computeWorkingState(cwd, { env });
219
+ if (working === null) return { code: 1, lines: ['commit-guard: REFUSED — the working state is undecidable (a git query failed or was killed) — nothing is guarded'] };
210
220
  const indexLag = decideIndexLag(working);
211
221
  if (indexLag !== null) return indexLag;
212
- const fingerprint = computeTreeFingerprint(cwd);
222
+ const fingerprint = computeTreeFingerprint(cwd, { env });
223
+ if (fingerprint === null) return { code: 1, lines: ['commit-guard: REFUSED — the tree fingerprint is undecidable (a git query failed or was killed) — nothing is guarded'] };
213
224
  // The CONTENT-FREE lanes — the second pure tree property, decided here for the same reason the
214
225
  // index lag is: no store read can answer it. A payload with no bytes states nothing about what
215
226
  // this commit will carry, and its fingerprint is the ONE value every clean moment of every
216
227
  // repository shares, so a receipt found at it was minted by some other moment, possibly at
217
228
  // another base. Such evidence must therefore decide NOTHING here — neither refuse nor attest
218
229
  // (the same fact flow-check-rungs.mjs applies to a red final). The index tells the two lanes
219
- // apart, and `computeWorkingState` probes it with --ignore-submodules=none precisely so a
220
- // config-hidden gitlink cannot pass for a clean one.
230
+ // apart. The payload runs --no-textconv and --ignore-submodules=none, so a config-hidden gitlink
231
+ // rides it; the arm stays because a content-free payload over a dirty index is undecidable by any receipt.
221
232
  const contentFree = fingerprint === CONTENT_FREE_FINGERPRINT;
222
233
  if (contentFree && working.stagedDirty) {
223
234
  return {
224
235
  code: 1,
225
- lines: [`commit-guard: REFUSED — the index carries staged content the fingerprint domain cannot see (a submodule gitlink hidden from \`git diff\` by \`submodule.<name>.ignore\` or \`diff.ignoreSubmodules\`), so no final receipt can describe what this commit will carry. Recovery: clear that ignore setting (or set it to \`none\`) until \`git diff --cached --no-ext-diff\` shows the change, then re-run node ${FINAL_RUN_TOOL} --final`],
236
+ lines: [`commit-guard: REFUSED — the index carries staged content the payload cannot see, so no final receipt can describe what this commit will carry. Recovery: make the staged change visible to \`git diff --cached\`, then re-run node ${FINAL_RUN_TOOL} --final`],
226
237
  };
227
238
  }
228
239
  // The guard's OWN reads resolve FIXED git-dir paths — a stray AW_CORE_EVIDENCE / AW_LCOV_FILE
@@ -0,0 +1,264 @@
1
+ #!/usr/bin/env node
2
+ // control-bytes.mjs — the read-only control-byte gate over the WORK TREE (the contract:
3
+ // docs/ai/specs/kit/control-bytes.md; the mode: references/modes/control-bytes.md). Measured
4
+ // 2026-08-30: an editor wrote a raw NUL into a test source, git classed the file as binary, the
5
+ // letter-level checker skipped the blob by kind, and the byte reached a reviewer one round later.
6
+ // This tool makes that byte a mechanical refusal: it judges BYTES, never letters, over tracked plus
7
+ // untracked-not-ignored paths — the name always, a regular file's content unless a git attribute
8
+ // says binary, a symlink's target string without following it — and refuses, never skips, on
9
+ // anything it cannot judge. main(argv, deps) returns { code, lines } and never exits; every spawn,
10
+ // fs and env read is injectable; the CLI half prints and sets the exit code. Read-only: it writes
11
+ // nothing and spawns only the git argv declared below (plus the location leaf's rev-parse probes).
12
+
13
+ import { closeSync, constants as fsConstants, fstatSync, lstatSync, openSync, readSync, readlinkSync, realpathSync, statSync } from 'node:fs';
14
+ import { spawnSync } from 'node:child_process';
15
+ import { isDirectRun } from './direct-run.mjs';
16
+ import { probeRegularFileNoFollow, readFileBytesNoFollowCapped } from './fs-read-nofollow.mjs';
17
+ import { GIT_MAX_BUFFER, parseIndexEntries, resolveGitLocation, withGitPath } from './git-env.mjs';
18
+ import { isRefusedControlByte, renderPathForDisplay } from './repo-lex.mjs';
19
+
20
+ export const READ_CAP = 16 * 1024 * 1024;
21
+ // The exact read-only git argv this tool spawns — pinned by the kit read-only tier registry.
22
+ export const CONTROL_BYTES_GIT_ARGV = Object.freeze([
23
+ Object.freeze(['ls-files', '-s', '-z']),
24
+ Object.freeze(['ls-files', '--others', '--exclude-standard', '-z']),
25
+ Object.freeze(['check-attr', '--stdin', '-z', 'binary', 'diff']),
26
+ Object.freeze(['config', '--type=bool', '--get', 'core.symlinks']),
27
+ Object.freeze(['ls-files', '-v', '-z']),
28
+ ]);
29
+ const [TRACKED_ARGV, UNTRACKED_ARGV, ATTRIBUTES_ARGV, SYMLINKS_ARGV, SPARSE_ARGV] = CONTROL_BYTES_GIT_ARGV;
30
+ export const defaultIo = Object.freeze({ open: openSync, fstat: fstatSync, read: readSync, close: closeSync, constants: fsConstants, lstat: lstatSync, readlink: readlinkSync, realpath: realpathSync, stat: statSync });
31
+
32
+ const USAGE = `usage: node control-bytes.mjs [--check] [--cwd <dir>]
33
+ Refuses any raw C0 or DEL byte (TAB, LF, CR admitted) in the work tree: a path's name, a regular
34
+ file's content (unless its git attribute says binary or -diff) or a symlink's target string.
35
+ --cwd <dir> anchor at that directory's work-tree root (default: the process cwd)
36
+ --check the gate spelling; the plain run prints the same report and shares the exit table
37
+ exit 0 clean · 1 a finding or a refusal · 2 usage`;
38
+
39
+ const NUL = Buffer.from([0]);
40
+ const render = renderPathForDisplay;
41
+ // Every DYNAMIC diagnostic (a location cause, git stderr, an fs error code, a user argument) rides
42
+ // the same renderer as paths before it reaches a line — SAFE (never a raw byte or U+FFFD), while
43
+ // injectivity is promised for NAMES only: a string-originated diagnostic folds invalid bytes first.
44
+ const safe = (text) => render(Buffer.isBuffer(text) ? text : Buffer.from(String(text), 'utf8'));
45
+ const refusalLine = (message) => `control-bytes: REFUSED — ${message}`;
46
+ const absent = (path, tracked) => `${render(path)}: absent (${tracked ? 'tracked but missing from the work tree — stage the deletion with git rm or git add -A, or it' : 'git named it, it'} vanished before the read)`;
47
+ const unreadable = (path, why) => `${render(path)}: unreadable (${safe(why)})`;
48
+ const hexByte = (byte) => `0x${byte.toString(16).padStart(2, '0')}`;
49
+ const errorCode = (err) => err?.code ?? err?.message ?? String(err);
50
+
51
+ const parseArgs = (argv, io) => {
52
+ const opts = { check: false, cwd: null };
53
+ for (let i = 0; i < argv.length; i += 1) {
54
+ const arg = argv[i];
55
+ if (arg === '--check') {
56
+ opts.check = true;
57
+ continue;
58
+ }
59
+ if (arg === '--cwd') {
60
+ const value = argv[i + 1];
61
+ if (value === undefined || value.startsWith('--')) return { error: '--cwd needs a directory' };
62
+ try {
63
+ if (!io.stat(value).isDirectory()) return { error: `--cwd ${safe(value)} is not a directory` };
64
+ } catch (err) {
65
+ return { error: `--cwd ${safe(value)}: ${safe(errorCode(err))}` };
66
+ }
67
+ opts.cwd = value;
68
+ i += 1;
69
+ continue;
70
+ }
71
+ return { error: `unknown argument: ${safe(arg)}` };
72
+ }
73
+ return opts;
74
+ };
75
+
76
+ // One git query → { stdout } or { failure } — a synchronous throw, ENOENT, a signal and an exit
77
+ // outside `ok` are four named failures, never a silent empty answer.
78
+ const gitQuery = (run, args, { cwd, env, input }, ok = [0]) => {
79
+ let r;
80
+ try {
81
+ r = run('git', args, { cwd, env: withGitPath(env), input, maxBuffer: GIT_MAX_BUFFER, windowsHide: true });
82
+ } catch (err) {
83
+ return { failure: `git ${args.join(' ')} threw synchronously (${safe(err?.message ?? err)})` };
84
+ }
85
+ if (r.error) return { failure: `git ${args.join(' ')} could not run (${safe(errorCode(r.error))})${r.error.code === 'ENOENT' ? ' — git is not on PATH' : ''}` };
86
+ if (r.signal) return { failure: `git ${args.join(' ')} was killed by ${safe(r.signal)}` };
87
+ const stderr = String(r.stderr ?? '').trim();
88
+ if (!ok.includes(r.status)) return { failure: `git ${args.join(' ')} exited ${r.status}${stderr ? ` (${safe(stderr)})` : ''}` };
89
+ return { stdout: Buffer.isBuffer(r.stdout) ? r.stdout : Buffer.from(r.stdout ?? '') };
90
+ };
91
+
92
+ const splitZ = (buf) => {
93
+ const out = [];
94
+ for (let start = 0; start < buf.length;) {
95
+ let end = buf.indexOf(0, start);
96
+ if (end === -1) end = buf.length;
97
+ if (end > start) out.push(buf.subarray(start, end));
98
+ start = end + 1;
99
+ }
100
+ return out;
101
+ };
102
+
103
+ // `check-attr --stdin -z` answers path NUL attribute NUL value NUL; `binary` set or `diff` unset
104
+ // is git's own "binary" — the content skip, keyed by the path's exact bytes.
105
+ const attributeSkips = (stdout) => {
106
+ const skips = new Set();
107
+ const fields = splitZ(stdout);
108
+ for (let i = 0; i + 2 < fields.length; i += 3) {
109
+ const [path, attribute, value] = [fields[i], fields[i + 1].toString('utf8'), fields[i + 2].toString('utf8')];
110
+ if ((attribute === 'binary' && value === 'set') || (attribute === 'diff' && value === 'unset')) skips.add(path.toString('hex'));
111
+ }
112
+ return skips;
113
+ };
114
+
115
+ const kindOfMode = (mode) => (mode === '120000' ? 'symlink' : mode === '160000' ? 'gitlink' : mode === '100644' || mode === '100755' ? 'file' : null);
116
+ // An UNTRACKED entry git names with a trailing slash is its nested-repository rule (the `nested/`
117
+ // line): name-only, like a gitlink, and counted as a skip by kind beside the never-committable stat
118
+ // classes. A directory at a slash-less entry is a leaf that changed kind — a refusal.
119
+ const kindOfStat = (st, nested) => (st.isSymbolicLink() ? 'symlink' : st.isFile() ? 'file' : st.isDirectory() ? (nested ? 'nested-repository' : 'directory') : 'never-committable');
120
+
121
+ export const main = (argv, deps = {}) => {
122
+ const io = { ...defaultIo, ...(deps.io ?? {}) };
123
+ const run = deps.spawn ?? spawnSync;
124
+ const env = deps.env ?? process.env;
125
+ const opts = parseArgs(argv, io);
126
+ if (opts.error) return { code: 2, lines: [`control-bytes: usage — ${opts.error}`, USAGE] };
127
+ const cwd = opts.cwd ?? deps.cwd ?? process.cwd();
128
+ const location = resolveGitLocation(cwd, { spawn: run, env, realpath: io.realpath });
129
+ if (location.state !== 'work-tree') return { code: 1, lines: [refusalLine(`the git location is ${location.state} — ${safe(location.cause)}`)] };
130
+ const top = location.top;
131
+ const git = (args, input, ok) => gitQuery(run, args, { cwd: top, env, input }, ok);
132
+ const tracked = git(TRACKED_ARGV);
133
+ if (tracked.failure) return { code: 1, lines: [refusalLine(tracked.failure)] };
134
+ const entries = parseIndexEntries(tracked.stdout);
135
+ const unmerged = entries.filter((e) => e.stage !== 0).length;
136
+ if (unmerged > 0) return { code: 1, lines: [refusalLine(`the git index is UNMERGED (${unmerged} conflict-stage entr(ies)) — resolve the conflict, then re-run`)] };
137
+ const others = git(UNTRACKED_ARGV);
138
+ if (others.failure) return { code: 1, lines: [refusalLine(others.failure)] };
139
+ const domain = [...entries.map((e) => ({ path: e.path, mode: e.mode })), ...splitZ(others.stdout).map((path) => ({ path, mode: null }))]
140
+ .sort((a, b) => Buffer.compare(a.path, b.path));
141
+ if (domain.length === 0) return { code: 1, lines: [refusalLine(`the domain is EMPTY — no tracked and no untracked-not-ignored path under ${render(Buffer.from(top))}`)] };
142
+ const attributes = git(ATTRIBUTES_ARGV, Buffer.concat(domain.flatMap((d) => [d.path, NUL])));
143
+ if (attributes.failure) return { code: 1, lines: [refusalLine(attributes.failure)] };
144
+ const skipByAttribute = attributeSkips(attributes.stdout);
145
+ // core.symlinks: exit 1 = unset = git's default (true); `false` = the host materialises a tracked
146
+ // symlink as a regular file holding the target bytes — refusing it there would refuse forever.
147
+ const symlinks = git(SYMLINKS_ARGV, undefined, [0, 1]);
148
+ if (symlinks.failure) return { code: 1, lines: [refusalLine(symlinks.failure)] };
149
+ const symlinksMaterialised = symlinks.stdout.toString('utf8').trim() === 'false';
150
+ // Sparse checkouts: a tracked path whose skip-worktree bit is set (`ls-files -v`, a tag that
151
+ // uppercases to S — core-evidence's rule) may be ABSENT by design: a counted skip, never a refusal.
152
+ const sparse = git(SPARSE_ARGV);
153
+ if (sparse.failure) return { code: 1, lines: [refusalLine(sparse.failure)] };
154
+ const sparseSkip = new Set();
155
+ for (const record of splitZ(sparse.stdout)) {
156
+ if (record.length > 2 && String.fromCharCode(record[0]).toUpperCase() === 'S') sparseSkip.add(record.subarray(2).toString('hex'));
157
+ }
158
+
159
+ const findings = [];
160
+ const refusals = [];
161
+ const skippedByAttribute = [];
162
+ let skippedByKind = 0;
163
+ const judge = (bytes, surface, path) => {
164
+ let first = -1;
165
+ let count = 0;
166
+ for (let i = 0; i < bytes.length; i += 1) {
167
+ if (!isRefusedControlByte(bytes[i])) continue;
168
+ if (first === -1) first = i;
169
+ count += 1;
170
+ }
171
+ if (first !== -1) findings.push(`${render(path)} ${surface} offset ${first}: ${hexByte(bytes[first])}${count > 1 ? ` (+${count - 1} more)` : ''}`);
172
+ };
173
+ // An ABSENT path: a sparse (skip-worktree) tracked one is a counted skip; every other refuses.
174
+ const settleAbsent = (path, tracked) => {
175
+ if (tracked && sparseSkip.has(path.toString('hex'))) skippedByKind += 1;
176
+ else refusals.push(absent(path, tracked));
177
+ };
178
+ // A descriptor read's outcome, settled: ok judges the bytes on `surface`; anything else refuses.
179
+ const settle = (read, path, tracked, surface, named) => {
180
+ if (read.outcome === 'ok') {
181
+ if (read.bytes) judge(read.bytes, surface, path);
182
+ return true;
183
+ }
184
+ if (read.outcome === 'over-cap') refusals.push(`${render(path)}: over the read cap (${READ_CAP} bytes) — the gate read cap+1 bytes on the descriptor and stopped; ${surface === 'content' ? 'declare the path binary in .gitattributes or shrink it' : 'restore the link or shorten its target'}`);
185
+ else if (read.outcome === 'absent') settleAbsent(path, tracked);
186
+ else if (read.outcome === 'foreign') refusals.push(unreadable(path, `a ${read.className} where git named a ${named}`));
187
+ else refusals.push(unreadable(path, read.code));
188
+ return false;
189
+ };
190
+ for (const { path, mode } of domain) {
191
+ judge(path, 'name', path);
192
+ // Filesystem calls drop git's nested-repository trailing slash: lstat on a slash-terminated
193
+ // name FOLLOWS a symlink standing there (POSIX), which would skip a swapped link unjudged.
194
+ const fsPath = path[path.length - 1] === 0x2f ? path.subarray(0, path.length - 1) : path;
195
+ const full = Buffer.concat([Buffer.from(`${top}/`), fsPath]);
196
+ const tracked = mode !== null;
197
+ let kind = null;
198
+ if (tracked) {
199
+ kind = kindOfMode(mode);
200
+ if (kind === null) {
201
+ refusals.push(unreadable(path, `index mode ${mode} names no readable kind`));
202
+ continue;
203
+ }
204
+ } else {
205
+ try {
206
+ kind = kindOfStat(io.lstat(full), path[path.length - 1] === 0x2f);
207
+ } catch (err) {
208
+ if (err?.code === 'ENOENT') settleAbsent(path, tracked);
209
+ else refusals.push(unreadable(path, errorCode(err)));
210
+ continue;
211
+ }
212
+ }
213
+ if (kind === 'gitlink') continue;
214
+ if (kind === 'directory') {
215
+ refusals.push(unreadable(path, 'a directory where git named a file'));
216
+ continue;
217
+ }
218
+ if (kind === 'never-committable' || kind === 'nested-repository') {
219
+ skippedByKind += 1;
220
+ continue;
221
+ }
222
+ if (kind === 'symlink') {
223
+ let leafIsFile = false;
224
+ if (symlinksMaterialised) {
225
+ try {
226
+ leafIsFile = io.lstat(full).isFile();
227
+ } catch (err) {
228
+ if (err?.code === 'ENOENT') settleAbsent(path, tracked);
229
+ else refusals.push(unreadable(path, errorCode(err)));
230
+ continue;
231
+ }
232
+ }
233
+ if (leafIsFile) {
234
+ settle(readFileBytesNoFollowCapped(full, READ_CAP, io), path, tracked, 'symlink-target', 'symlink');
235
+ continue;
236
+ }
237
+ try {
238
+ judge(io.readlink(full, { encoding: 'buffer' }), 'symlink-target', path);
239
+ } catch (err) {
240
+ if (err?.code === 'ENOENT') settleAbsent(path, tracked);
241
+ else refusals.push(unreadable(path, err?.code === 'EINVAL' ? 'not a symlink where git named one' : errorCode(err)));
242
+ }
243
+ continue;
244
+ }
245
+ // An attribute skip covers the CONTENT only: the leaf still owes the descriptor's kind check.
246
+ const skipped = skipByAttribute.has(path.toString('hex'));
247
+ const read = skipped ? probeRegularFileNoFollow(full, io) : readFileBytesNoFollowCapped(full, READ_CAP, io);
248
+ if (settle(read, path, tracked, 'content', 'file') && skipped) skippedByAttribute.push(path);
249
+ }
250
+ const lines = [
251
+ `control-bytes: work tree ${render(Buffer.from(top))}`,
252
+ `paths judged: ${domain.length} · skipped by attribute: ${skippedByAttribute.length} · skipped by kind: ${skippedByKind} · findings: ${findings.length}`,
253
+ ...skippedByAttribute.map((path) => `skipped by attribute: ${render(path)}`),
254
+ ...findings,
255
+ ...refusals.map(refusalLine),
256
+ ];
257
+ return { code: findings.length > 0 || refusals.length > 0 ? 1 : 0, lines };
258
+ };
259
+
260
+ if (isDirectRun(import.meta.url)) {
261
+ const result = main(process.argv.slice(2));
262
+ (result.code === 2 ? process.stderr : process.stdout).write(`${result.lines.join('\n')}\n`);
263
+ process.exitCode = result.code;
264
+ }
@@ -24,7 +24,7 @@
24
24
  // re-derived): safe repo-relative path resolution, no-follow real-path containment, shell-free
25
25
  // argv, per-run timeout, N/N reruns, the quarantine lane (reruns 3, timeout 120s).
26
26
  // Import posture: this module is the DAG BOTTOM — it imports only node built-ins + atomic-write +
27
- // changed-surface + the pure leaves (repo-lex, fs-read-nofollow, coverage-state), and OWNS the
27
+ // changed-surface + the pure leaves (repo-lex, fs-read-nofollow, coverage-state, git-env), and OWNS the
28
28
  // canonical review-domain primitives (tree fingerprint, receipt read
29
29
  // path, attesting predicate, verdict vocabulary, testId format, base resolution). review-state
30
30
  // RE-EXPORTS its historical public API from here, so its consumers (and the bash-twin parity
@@ -43,6 +43,7 @@ import { parsePositiveIntKnob, probeVerdict } from './changed-surface.mjs';
43
43
  import { isDirectRun } from './direct-run.mjs';
44
44
  import { readRegularFileNoFollow } from './fs-read-nofollow.mjs';
45
45
  import { lexicalRepoRelative } from './repo-lex.mjs';
46
+ import { GIT_MAX_BUFFER, resolveGitLocation, withGitPath } from './git-env.mjs';
46
47
  // The coverage vocabulary leaf: run-gates RECORDS the token this validator checks, and run-gates
47
48
  // imports THIS module (the sole-writer boundary), so their shared home sits below both.
48
49
  import { COVERAGE, FINAL_COVERAGE_STATES } from './coverage-state.mjs';
@@ -52,7 +53,6 @@ const stop = (message) => Object.assign(new Error(`[agent-workflow-kit] ${messag
52
53
  const usageFail = (message) => Object.assign(new Error(`[agent-workflow-kit] ${message}`), { exitCode: 2 });
53
54
 
54
55
  const isoNow = () => new Date().toISOString();
55
- const GIT_MAX_BUFFER = 256 * 1024 * 1024; // a full-tree diff / TAP stream can be large; never truncate
56
56
 
57
57
  const gitRaw = (args, cwd) => spawnSync('git', args, { cwd, maxBuffer: GIT_MAX_BUFFER, windowsHide: true });
58
58
 
@@ -114,15 +114,32 @@ export const isNeverCommittableStat = (stat) =>
114
114
  stat != null &&
115
115
  (stat.isCharacterDevice() || stat.isBlockDevice() || stat.isFIFO() || stat.isSocket());
116
116
 
117
+ // The git runner seam the tree computations share — (args, dir, input, env) — and its guard: a
118
+ // runner that THROWS answers as a failed spawn (measured: a raw stack out of a fingerprint).
119
+ const defaultRunGit = (args, dir, input, env) => spawnSync('git', args, { cwd: dir, input, env: withGitPath(env ?? process.env), maxBuffer: GIT_MAX_BUFFER, windowsHide: true });
120
+ const guardedRunner = (runGit, env) => (args, dir, input, override) => {
121
+ try { return runGit(args, dir, input, override ?? env); } catch (err) { return { error: err, status: null, signal: null, stdout: Buffer.alloc(0), stderr: '' }; }
122
+ };
123
+ const stdoutOf = (r) => (r.error || r.status !== 0 ? null : r.stdout);
124
+ // Only a work-tree location is computed over (git-env's table under the runner's own env) — a redirecting GIT_DIR never hashes another repository in silence.
125
+ const locateWorkTree = (cwd, run, env) => {
126
+ const location = resolveGitLocation(cwd, { spawn: (cmd, args, opts) => run(args, opts.cwd, undefined, opts.env), env });
127
+ return location.state === 'work-tree' ? location.top : null;
128
+ };
129
+
117
130
  // The canonical payload bytes: staged diff + unstaged diff + the untracked-not-ignored section —
118
- // byte-identical to the wrappers' emit_fingerprint_payload, emitted from the work-tree ROOT. Null
119
- // outside a git work tree. The lstat is injectable ONLY for the never-committable filter tests.
120
- export const computeFingerprintPayload = (cwd, { lstat = lstatSync } = {}) => {
121
- const top = gitLine(['rev-parse', '--show-toplevel'], cwd);
131
+ // byte-identical to the wrappers' emit_fingerprint_payload, emitted from the work-tree ROOT. Null on
132
+ // every location but a work tree. lstat is injectable ONLY for the never-committable filter tests.
133
+ export const FINGERPRINT_CACHED_DIFF_ARGV = Object.freeze(['diff', '--cached', '--no-ext-diff', '--no-textconv', '--ignore-submodules=none']);
134
+ export const FINGERPRINT_UNSTAGED_DIFF_ARGV = Object.freeze(['diff', '--no-ext-diff', '--no-textconv', '--ignore-submodules=none']);
135
+ export const computeFingerprintPayload = (cwd, { lstat = lstatSync, runGit = null, env = process.env } = {}) => {
136
+ const run = guardedRunner(runGit ?? defaultRunGit, env);
137
+ const top = locateWorkTree(cwd, run, env);
122
138
  if (top == null) return null;
123
- const staged = gitBuf(['diff', '--cached', '--no-ext-diff'], top);
124
- const unstaged = gitBuf(['diff', '--no-ext-diff'], top);
125
- const untrackedZ = gitBuf(['ls-files', '--others', '--exclude-standard', '-z'], top);
139
+ const buf = (args) => stdoutOf(run(args, top));
140
+ const staged = buf(FINGERPRINT_CACHED_DIFF_ARGV);
141
+ const unstaged = buf(FINGERPRINT_UNSTAGED_DIFF_ARGV);
142
+ const untrackedZ = buf(['ls-files', '--others', '--exclude-standard', '-z']);
126
143
  if (staged == null || unstaged == null || untrackedZ == null) return null;
127
144
  const chunks = [staged, unstaged];
128
145
  for (const rel of untrackedZ.toString('utf8').split('\0').filter(Boolean)) {
@@ -348,21 +365,17 @@ const flaggedIndexLag = (top, runGit, lstat, readlink) => {
348
365
  return { paths: lagging, submodules: laggingSubmodules, flagged: laggingFlags };
349
366
  };
350
367
 
351
- export const computeWorkingState = (cwd, { lstat = lstatSync, readlink = readlinkSync, runGit = null } = {}) => {
352
- const run = runGit ?? ((args, dir, input, env) => spawnSync('git', args, { cwd: dir, input, env: env ?? process.env, maxBuffer: GIT_MAX_BUFFER, windowsHide: true }));
353
- const topRun = run(['rev-parse', '--show-toplevel'], cwd);
354
- if (topRun.error || topRun.status !== 0) return null;
355
- const top = topRun.stdout.toString('utf8').replace(/\r?\n$/, '');
368
+ export const computeWorkingState = (cwd, { lstat = lstatSync, readlink = readlinkSync, runGit = null, env = process.env } = {}) => {
369
+ const run = guardedRunner(runGit ?? defaultRunGit, env);
370
+ const top = locateWorkTree(cwd, run, env);
371
+ if (top == null) return null;
356
372
  // `--ignore-submodules=none` on the staged probe too: a config-hidden STAGED gitlink would
357
373
  // otherwise make stagedDirty false, and isTreeClean would call such a tree clean.
358
374
  const staged = run(['diff', '--cached', '--quiet', '--ignore-submodules=none'], top);
359
375
  // ONLY 0 or 1 is a usable answer: a signal-killed probe reports status null, which the old
360
376
  // `> 1` guard let through as "nothing staged" — a fail-OPEN the whole arm cannot afford.
361
377
  if (staged.error || (staged.status !== 0 && staged.status !== 1)) return null;
362
- const buf = (args) => {
363
- const r = run(args, top);
364
- return r.error || r.status !== 0 ? null : r.stdout;
365
- };
378
+ const buf = (args) => stdoutOf(run(args, top));
366
379
  // The FULL probe forces submodules back in: diff.ignoreSubmodules / submodule.<n>.ignore would
367
380
  // otherwise erase a dirty submodule from the comparison entirely.
368
381
  const changedZ = buf(['diff', '--name-only', '-z', '--ignore-submodules=none']);
@@ -370,7 +383,7 @@ export const computeWorkingState = (cwd, { lstat = lstatSync, readlink = readlin
370
383
  const untrackedZ = buf(['ls-files', '--others', '--exclude-standard', '-z']);
371
384
  if (changedZ == null || plainZ == null || untrackedZ == null) return null;
372
385
  const split = (b) => b.toString('utf8').split('\0').filter(Boolean);
373
- const changed = split(changedZ);
386
+ const changed = [...new Set(split(changedZ))]; // an unmerged path is listed once per conflict side
374
387
  const plain = new Set(split(plainZ));
375
388
  const flagged = flaggedIndexLag(top, run, lstat, readlink);
376
389
  if (flagged == null) return null;
@@ -950,6 +963,7 @@ export const runRedProof = ({ cwd = process.cwd(), env = process.env, testId } =
950
963
  // captured BEFORE the runs and re-checked after them; any drift refuses below.
951
964
  const base = resolveBase(cwd);
952
965
  const fingerprint = computeTreeFingerprint(cwd);
966
+ if (fingerprint == null) throw stop('cannot compute the tree fingerprint — the git location is not a work tree; nothing was observed, no runs were spent');
953
967
  const { entry, file, resolveReason } = probeBound({ testId, rootTop, env, reruns, timeoutS });
954
968
  const verdict = probeVerdict(entry);
955
969
  const counts = `${entry.greens} green / ${entry.reds} red / ${entry.timeouts} timed out / ${entry.runs - entry.greens - entry.reds - entry.timeouts} unresolved of ${entry.runs} run(s)`;
@@ -974,7 +988,6 @@ export const runRedProof = ({ cwd = process.cwd(), env = process.env, testId } =
974
988
  `${entry.timeouts > 0 ? 'raise the timeout or make the test faster' : 'replace the flaky test'}, then re-observe. Nothing was recorded.`,
975
989
  );
976
990
  }
977
- if (fingerprint == null) throw stop('cannot compute the tree fingerprint — not a git work tree');
978
991
  // Post-run drift recheck: fingerprint, base, and the test-file bytes must all equal the
979
992
  // pre-run capture — a mutating test / parallel edit / commit would otherwise mint a record
980
993
  // whose fields attest different trees.
@@ -24,6 +24,7 @@ import { lstatSync, readFileSync, realpathSync } from 'node:fs';
24
24
  import { createHash } from 'node:crypto';
25
25
  import { join } from 'node:path';
26
26
  import { spawnSync } from 'node:child_process';
27
+ import { GIT_MAX_BUFFER } from './git-env.mjs';
27
28
  import { computeChangedSurface } from './changed-surface.mjs';
28
29
  import { isDirectRun } from './direct-run.mjs';
29
30
  import { lcovCoveredMap, uncoveredChangedFromLcov } from './lcov.mjs';
@@ -47,7 +48,6 @@ const usageFail = (message) => Object.assign(new Error(`[agent-workflow-kit] ${m
47
48
 
48
49
  export const LCOV_BASENAME = 'agent-workflow-lcov.info';
49
50
 
50
- const GIT_MAX_BUFFER = 256 * 1024 * 1024;
51
51
  const gitLine = (args, cwd) => {
52
52
  const r = spawnSync('git', args, { cwd, maxBuffer: GIT_MAX_BUFFER, windowsHide: true });
53
53
  if (r.error || r.status !== 0) return null;