@sabaiway/agent-workflow-kit 10.5.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 (53) hide show
  1. package/CHANGELOG.md +139 -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/core-evidence.md +1 -1
  15. package/references/modes/flow-writer.md +16 -4
  16. package/references/modes/procedures.md +5 -1
  17. package/references/modes/review-state.md +3 -3
  18. package/references/modes/robustness-brief.md +61 -0
  19. package/references/modes/velocity.md +2 -0
  20. package/references/robustness-literals.json +107 -0
  21. package/tools/changed-surface.mjs +1 -1
  22. package/tools/commands.mjs +14 -0
  23. package/tools/commit-guard.mjs +24 -12
  24. package/tools/control-bytes.mjs +264 -0
  25. package/tools/core-evidence.mjs +33 -20
  26. package/tools/coverage-check.mjs +1 -1
  27. package/tools/dispatch-store-read.mjs +269 -0
  28. package/tools/dispatch-store.mjs +21 -357
  29. package/tools/flow-check-git-lane.mjs +2 -1
  30. package/tools/flow-delta-proof.mjs +4 -4
  31. package/tools/flow-record-shape.mjs +44 -2
  32. package/tools/flow-round-gates.mjs +151 -0
  33. package/tools/flow-store-read.mjs +1 -1
  34. package/tools/flow-writer.mjs +235 -82
  35. package/tools/fold-scope.mjs +7 -3
  36. package/tools/fs-read-nofollow.mjs +56 -4
  37. package/tools/git-env.mjs +120 -0
  38. package/tools/grounding.mjs +4 -3
  39. package/tools/held-session.mjs +165 -0
  40. package/tools/plan-shape-facts.mjs +23 -10
  41. package/tools/plan-shape.mjs +8 -0
  42. package/tools/procedures.mjs +47 -4
  43. package/tools/repo-lex.mjs +29 -0
  44. package/tools/review-state.mjs +127 -39
  45. package/tools/robustness-brief.mjs +209 -0
  46. package/tools/robustness-literals.mjs +101 -0
  47. package/tools/sandbox-masks.mjs +2 -1
  48. package/tools/set-flow.mjs +2 -1
  49. package/tools/source-size-scope.mjs +19 -25
  50. package/tools/tracked-tree-census.mjs +3 -3
  51. package/tools/velocity-profile.mjs +5 -2
  52. package/tools/worktree-handoff-return.mjs +2 -2
  53. package/tools/worktrees.mjs +2 -2
@@ -0,0 +1,61 @@
1
+ ### Mode: robustness-brief
2
+
3
+ <!-- opt-in-capability: none — a read-only generator over a plan file and the kit's own list -->
4
+
5
+ Read-only **executor-brief generator** — it turns a ledger row's `robust:<class>[,<class>]` tag into
6
+ the block of concrete robustness literals the executor must prove BEFORE the first line of code.
7
+ Measured 2026-08-30: a diff correct to its contract took 13 review rounds, each finding one more
8
+ standard invariant (a git location variable, a sequencer ref, `%B`'s newline, `textconv`, fsync, a
9
+ no-process spawn error) that nobody had written into the brief. The list is data
10
+ (`${CLAUDE_SKILL_DIR}/references/robustness-literals.json`, versioned, language-neutral); this mode
11
+ renders it, so a retro extends the list instead of a reviewer re-finding a member.
12
+
13
+ **What it reads.** The plan file named by `--plan`, opened as a regular file no-follow through the
14
+ kit's capped descriptor read (1 MiB — a plan is capped at 100 lines) with its realpath required
15
+ under the realpath of the cwd (the project root the advisor renders the command from; no git is
16
+ asked): an absolute path outside, a `..` traversal out of it, a symlink leading out, a non-regular file and
17
+ an over-cap plan each refuse **exit 2** by name — the generator PRINTS row text, so unlike
18
+ plan-shape-cli's unbounded no-follow read it is bounded. The plan is judged on every rule of plan-shape's structural half (headings, section caps,
19
+ row grammar, ids, bytes, lexical containment, sweep form, duplicate paths, the total line, the
20
+ acceptance and governing-spec presence rules) over EMPTY facts (`{}` — never undefined); the
21
+ facts-bound half — realpath containment and sweep expansion — stays `plan-shape --check`'s. A plan
22
+ that fails the structural half is refused, never half-rendered. And the kit's own list through its
23
+ reader leaf (a list that fails the closed schema is refused by name; the tag grammar is that leaf's
24
+ `parseRobustTag`, ONE home). Nothing else: no git, no project config, no network.
25
+
26
+ Run `node ${CLAUDE_SKILL_DIR}/tools/robustness-brief.mjs --plan <plan> [--row <id>] [--coverage]`:
27
+
28
+ 1. Plain run → the Markdown block, ready to paste into the dispatch brief: a header naming the list
29
+ version and the plan path; then, for every tagged row, its id and path, and per tagged class the
30
+ class's `prove` sentence followed by its members as `literal — note (source)`. Every
31
+ plan-derived path — the header's and each row's — is rendered through the kit's string display
32
+ escape (`escapeForDisplay`), so no output line carries a raw control byte, U+2028 or U+2029; a
33
+ U+FFFD passes as the printable replacement character (stated, not escaped). The brief is built
34
+ whole before a byte is printed — never a partial brief. **Exit 0.** A plan with no `robust:`
35
+ token prints ONE stated line and still exits 0 — an untagged ledger is a fact about the plan, not
36
+ a failure of the tool. A row whose tag the grammar refuses (`no-class`, `empty-class`,
37
+ `duplicate-class`, `invalid-class`, `multiple-tags`) or names an unknown class id is **exit 1**
38
+ naming the row — never a zero-tags success.
39
+ 2. **`--row <id>`** → the block for that one row (the slice brief of a subagent or a delegated
40
+ dispatch). **Exit 1** when the row is absent from the ledger or carries no tag, so a brief can
41
+ never be assembled from a row that names nothing to prove.
42
+ 3. **Exit 1** also when the plan fails the structural half (the refusal lines are printed first);
43
+ **exit 2** usage — every operand is validated at parse: an unknown argument, an uncontained or
44
+ over-cap `--plan` (above), a malformed-UTF-8 plan (`malformed-utf8` — a genuine U+FFFD passes)
45
+ and a list the reader refuses by name (the tool's own installation, the plan-shape-facts precedent) are usage.
46
+ 4. Where it belongs (procedures.md `plan-execution` step 2): the dispatch brief — delegated or
47
+ subagent — carries the generated block for every tagged row; the procedures advisor renders the
48
+ populated command in the plan-execution render. Tag a row by writing `robust:<class>` in its
49
+ responsibility sentence; `plan-shape --check` refuses a class id the list does not carry
50
+ (`robust-class`) and leaves an untagged row unjudged.
51
+ 5. **`--coverage`** judges every valid `create`/`modify` row except `.md`/`.txt` documents and the shipped list itself. A class is PRESENT when the row file's bytes carry a bounded member token of kind env/flag/ref/syscall/errno/argv; `state` is never searched, `uncovered = present − tagged`, delete and absent rows are listed, and sweep rows union every path expanded by `plan-shape-facts`' one glob compiler. Reads use the same 1 MiB capped no-follow primitive. The table exits `0` when covered, `1` with each `row:class:literal` when uncovered, and `2` on a named row-read refusal. Bootstrap: for rows up to the row that lands this mode, apply the rule by hand; thereafter run coverage before every dispatch and before `flow-writer adoption`.
52
+
53
+ **Honest bounds (stated, accepted):** the tag is the author's claim — the tool cannot know that an
54
+ untagged row touches git, the reviewer still can; the list names classes and carries only MEASURED
55
+ members, so a fact of someone else's runtime that nobody has measured yet is not in it until a retro
56
+ adds it with its `source`; `source` is provenance of the measurement, not a path an installed kit
57
+ opens; a class with only `state` members is blind and can never be PRESENT. The block informs the
58
+ brief and never judges a diff — the hostile harness and the diff review stay the last line.
59
+
60
+ **Invariants:** read-only · never writes · never commits · spawns nothing · the plain run reads the plan file and the kit list; `--coverage` additionally reads every judged row file through the capped no-follow read and the repository's regular-file listing for sweep expansion — nothing else, still no git, no spawn and no write · exit 0 printed / 1 refused / 2 usage ·
61
+ `main(argv, deps)` returns, never exits.
@@ -36,6 +36,8 @@ Run `node ${CLAUDE_SKILL_DIR}/tools/velocity-profile.mjs [--dry-run | --apply] [
36
36
  - `node ${CLAUDE_SKILL_DIR}/tools/repo-search.mjs --pattern <literal> --path <p>` (wildcard — the LITERAL search lane; for a pattern carrying a shell-significant byte use `--pattern-file <p>` instead, and for a TARGET PATH carrying one use `--paths-file <p>` — either way the bytes never enter the command string. one of the TWO tier tools that carry hook residual coverage — see the honesty note below)
37
37
  - `node ${CLAUDE_SKILL_DIR}/tools/path-inventory.mjs --path <p>` (wildcard — the read-only inventory lane: exists / type / size / `wc -l` lines / one-level listing / small-file contents for N paths in ONE call, so small path questions never need a composed shell. `--paths-file <p>` is its out-of-band target lane; hook residual coverage as with repo-search)
38
38
  - `node ${CLAUDE_SKILL_DIR}/tools/review-rounds-cli.mjs --artifact <plan>` (wildcard — the read-only round table over the review receipts, the verdict half of the per-round emission the `procedures` advisor names every round; the path argument is resolved and compared, never opened as a pattern, so it rides the settings-level posture like the rest)
39
+ - `node ${CLAUDE_SKILL_DIR}/tools/control-bytes.mjs --check` (wildcard — the read-only control-byte gate over the work tree: read-only git queries and descriptor-bound reads, no write API; the agent's own hand run before the declared gate runs it)
40
+ - `node ${CLAUDE_SKILL_DIR}/tools/robustness-brief.mjs --plan <plan>` (wildcard — the read-only executor-brief generator over a plan and the kit's shipped literal list; no write API and no process spawn)
39
41
  - `node ${CLAUDE_SKILL_DIR}/tools/run-gates.mjs --cwd ${PROJECT_ROOT}` — **EXACT byte-string only**, and honestly **project-exec, not read-only**: it runs YOUR declared `docs/ai/gates.json` commands — the same trust boundary the opt-in hook grants byte-exact per-cmd. A wildcard would be BROADER than that boundary (`--cwd <dir>` executes another project's declared gates), so the bare cwd-defaulting form, any other `--cwd`, `--only`, and **`--final`** forms all still prompt (`--final` WRITES the final-run receipt into the core-evidence store — a recording run is never auto-approved).
40
42
  - `node ${CLAUDE_SKILL_DIR}/tools/source-size-check.mjs --check` — **EXACT byte-string only**, and only this READ-ONLY mode: the tool's `--write-baseline` and `--adopt` modes WRITE (the recorded ratchet, and a gate declaration) and are deliberately uncovered — they still prompt, always. Every other form, `--cwd` included, keeps prompting. **What it covers, precisely:** the agent's own direct invocation. It is **not** the byte-string a DECLARED gate carries — the fill emits that path double-quoted (a kit path with a space must survive), and a seedable allow rule may carry no quotes at all, so the two spellings cannot be one string; a declared gate is auto-approved by the opt-in hook instead, byte-exact against `docs/ai/gates.json`.
41
43
  - Writer previews, **exact arg-free dry-run byte-strings only** (the SEEDED tier byte-string is the arg-free preview of each): `node ${CLAUDE_SKILL_DIR}/tools/velocity-profile.mjs` · `node ${CLAUDE_SKILL_DIR}/tools/cheap-agents.mjs` · `node ${CLAUDE_SKILL_DIR}/tools/gate-hook.mjs` — every `--apply`/`--write`/`--yes` still prompts, always. (`gate-hook` also has a **`--read-lane`** flagged preview — the opt-in read-only compound lane, `${CLAUDE_SKILL_DIR}/references/modes/hook.md`; that flagged form is NOT the seeded arg-free byte-string, so it may **prompt once** — it IS a consent flow, stated, no silent cap.)
@@ -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'),
@@ -34,8 +34,8 @@
34
34
  // reason verbatim — a foreign worktree's chain stays advisory; the armed state extends the
35
35
  // PASS line;
36
36
  // 4. re-computes the review-state decision (the ship-receipt arm) — a missing/vetoed ship
37
- // receipt refuses. The env it hands over is SANITIZED (receipts/evidence/flow-store producer
38
- // seams stripped) so a poisoned override can neither redirect nor mask any store this guard
37
+ // receipt refuses. The env it hands over is SANITIZED; four producer seams are stripped:
38
+ // receipts/evidence/flow-store/delegation-store. A poisoned override can neither redirect nor mask any store this guard
39
39
  // reads.
40
40
  // `git commit --no-verify` stays the stated residual (a self-discipline mechanism, not a security
41
41
  // boundary). Read-only; dependency-free; Node >= 22. No side effects on import.
@@ -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
@@ -323,6 +334,7 @@ export const runGuard = ({ cwd = process.cwd(), env = process.env } = {}) => {
323
334
  delete reviewEnv.AW_REVIEW_RECEIPTS;
324
335
  delete reviewEnv.AW_CORE_EVIDENCE;
325
336
  delete reviewEnv.AW_FLOW_STORE;
337
+ delete reviewEnv.AW_DELEGATION_STORE;
326
338
  const review = decideCheck(buildState({ cwd, env: reviewEnv }));
327
339
  if (review.code !== 0) {
328
340
  return { code: 1, lines: [`commit-guard: REFUSED — the review obligations are not satisfied: ${review.reason}`] };
@@ -354,7 +366,7 @@ the LATEST completed run-gates --final receipt — refusing on { no receipt for
354
366
  latest attempt · before≠after · declaration content drift · evidence-hash drift · lcov drift ·
355
367
  a flow-store refusal (a PRESENT store's open own chain / base motion / coverage — verbatim; no
356
368
  store file = byte-exact pre-flow behavior) · unsatisfied review obligations (the review-state
357
- decision, over a sanitized env — receipts/evidence/flow-store seams stripped) }. Wire it into
369
+ decision, over a sanitized env — receipts/evidence/flow-store/delegation-store seams stripped) }. Wire it into
358
370
  pre-commit; \`git commit --no-verify\` stays the stated residual (self-discipline, not a security
359
371
  boundary).
360
372
 
@@ -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
+ }