@lannguyensi/harness 0.43.0 → 0.45.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 (237) hide show
  1. package/CHANGELOG.md +1248 -0
  2. package/dist/cli/adopt/derive.d.ts +16 -7
  3. package/dist/cli/adopt/derive.js +19 -2
  4. package/dist/cli/adopt/derive.js.map +1 -1
  5. package/dist/cli/adopt/index.js +18 -3
  6. package/dist/cli/adopt/index.js.map +1 -1
  7. package/dist/cli/apply/apply.d.ts +1 -0
  8. package/dist/cli/apply/apply.js +62 -6
  9. package/dist/cli/apply/apply.js.map +1 -1
  10. package/dist/cli/apply/generate-codex-config.js +5 -6
  11. package/dist/cli/apply/generate-codex-config.js.map +1 -1
  12. package/dist/cli/apply/generate-opencode-config.d.ts +58 -0
  13. package/dist/cli/apply/generate-opencode-config.js +210 -0
  14. package/dist/cli/apply/generate-opencode-config.js.map +1 -0
  15. package/dist/cli/apply/generate-settings.d.ts +90 -32
  16. package/dist/cli/apply/generate-settings.js +133 -10
  17. package/dist/cli/apply/generate-settings.js.map +1 -1
  18. package/dist/cli/apply/index.d.ts +2 -1
  19. package/dist/cli/apply/index.js +2 -1
  20. package/dist/cli/apply/index.js.map +1 -1
  21. package/dist/cli/approve/stdin-report.d.ts +15 -0
  22. package/dist/cli/approve/stdin-report.js +1 -1
  23. package/dist/cli/approve/stdin-report.js.map +1 -1
  24. package/dist/cli/approve/understanding.d.ts +75 -0
  25. package/dist/cli/approve/understanding.js +150 -2
  26. package/dist/cli/approve/understanding.js.map +1 -1
  27. package/dist/cli/audit.d.ts +1 -1
  28. package/dist/cli/audit.js +3 -2
  29. package/dist/cli/audit.js.map +1 -1
  30. package/dist/cli/doctor/claude-mcp.d.ts +19 -1
  31. package/dist/cli/doctor/claude-mcp.js +36 -10
  32. package/dist/cli/doctor/claude-mcp.js.map +1 -1
  33. package/dist/cli/doctor/codex.d.ts +3 -1
  34. package/dist/cli/doctor/codex.js +6 -9
  35. package/dist/cli/doctor/codex.js.map +1 -1
  36. package/dist/cli/doctor/format.js +94 -8
  37. package/dist/cli/doctor/format.js.map +1 -1
  38. package/dist/cli/doctor/index.d.ts +23 -0
  39. package/dist/cli/doctor/index.js +106 -2
  40. package/dist/cli/doctor/index.js.map +1 -1
  41. package/dist/cli/doctor/opencode.d.ts +24 -0
  42. package/dist/cli/doctor/opencode.js +107 -0
  43. package/dist/cli/doctor/opencode.js.map +1 -0
  44. package/dist/cli/doctor/target-checks.d.ts +11 -0
  45. package/dist/cli/doctor/target-checks.js +25 -0
  46. package/dist/cli/doctor/target-checks.js.map +1 -0
  47. package/dist/cli/doctor/toolchain-parity.d.ts +63 -0
  48. package/dist/cli/doctor/toolchain-parity.js +154 -0
  49. package/dist/cli/doctor/toolchain-parity.js.map +1 -0
  50. package/dist/cli/doctor/types.d.ts +85 -1
  51. package/dist/cli/doctor/types.js +1 -1
  52. package/dist/cli/doctor/types.js.map +1 -1
  53. package/dist/cli/doctor/understanding-mode-env.d.ts +39 -0
  54. package/dist/cli/doctor/understanding-mode-env.js +83 -0
  55. package/dist/cli/doctor/understanding-mode-env.js.map +1 -0
  56. package/dist/cli/dry-run.js +34 -13
  57. package/dist/cli/dry-run.js.map +1 -1
  58. package/dist/cli/explain.d.ts +1 -1
  59. package/dist/cli/explain.js +1 -1
  60. package/dist/cli/explain.js.map +1 -1
  61. package/dist/cli/index.js +62 -7
  62. package/dist/cli/index.js.map +1 -1
  63. package/dist/cli/init/composer.js +36 -13
  64. package/dist/cli/init/composer.js.map +1 -1
  65. package/dist/cli/init/interactive.d.ts +7 -4
  66. package/dist/cli/init/interactive.js +79 -27
  67. package/dist/cli/init/interactive.js.map +1 -1
  68. package/dist/cli/init/profiles.d.ts +2 -2
  69. package/dist/cli/init/profiles.js +32 -1
  70. package/dist/cli/init/profiles.js.map +1 -1
  71. package/dist/cli/init/templates.d.ts +2 -1
  72. package/dist/cli/init/templates.js +136 -24
  73. package/dist/cli/init/templates.js.map +1 -1
  74. package/dist/cli/pack/approve-escape.js +69 -8
  75. package/dist/cli/pack/approve-escape.js.map +1 -1
  76. package/dist/cli/pack/hook-branch-protection.js +1 -1
  77. package/dist/cli/pack/hook-branch-protection.js.map +1 -1
  78. package/dist/cli/pack/hook-codex-pre-tool-use.js +27 -1
  79. package/dist/cli/pack/hook-codex-pre-tool-use.js.map +1 -1
  80. package/dist/cli/pack/hook-post-merge-gate.js +44 -26
  81. package/dist/cli/pack/hook-post-merge-gate.js.map +1 -1
  82. package/dist/cli/pack/hook-pre-tool-use.js +91 -4
  83. package/dist/cli/pack/hook-pre-tool-use.js.map +1 -1
  84. package/dist/cli/pack/hook-solution-acceptance.js +19 -2
  85. package/dist/cli/pack/hook-solution-acceptance.js.map +1 -1
  86. package/dist/cli/pack/understanding-report-schema-hint.d.ts +13 -0
  87. package/dist/cli/pack/understanding-report-schema-hint.js +52 -3
  88. package/dist/cli/pack/understanding-report-schema-hint.js.map +1 -1
  89. package/dist/cli/pause/index.js +1 -1
  90. package/dist/cli/pause/index.js.map +1 -1
  91. package/dist/cli/policy/intercept.d.ts +148 -0
  92. package/dist/cli/policy/intercept.js +538 -79
  93. package/dist/cli/policy/intercept.js.map +1 -1
  94. package/dist/cli/session-start/index.d.ts +12 -3
  95. package/dist/cli/session-start/index.js +67 -11
  96. package/dist/cli/session-start/index.js.map +1 -1
  97. package/dist/cli/session-start/stale-base-check.d.ts +71 -0
  98. package/dist/cli/session-start/stale-base-check.js +379 -0
  99. package/dist/cli/session-start/stale-base-check.js.map +1 -0
  100. package/dist/cli/session-start/toolchain-parity.d.ts +142 -8
  101. package/dist/cli/session-start/toolchain-parity.js +244 -36
  102. package/dist/cli/session-start/toolchain-parity.js.map +1 -1
  103. package/dist/cli/smoke/assertions.d.ts +9 -2
  104. package/dist/cli/smoke/assertions.js +9 -2
  105. package/dist/cli/smoke/assertions.js.map +1 -1
  106. package/dist/cli/uninstall/index.js +18 -2
  107. package/dist/cli/uninstall/index.js.map +1 -1
  108. package/dist/cli/validate/checks.d.ts +3 -1
  109. package/dist/cli/validate/checks.js +298 -3
  110. package/dist/cli/validate/checks.js.map +1 -1
  111. package/dist/io/claude-mcp.d.ts +122 -1
  112. package/dist/io/claude-mcp.js +214 -12
  113. package/dist/io/claude-mcp.js.map +1 -1
  114. package/dist/io/duration.js.map +1 -0
  115. package/dist/io/expand-home.js.map +1 -0
  116. package/dist/io/extract.js.map +1 -0
  117. package/dist/io/harness-lock.js +1 -1
  118. package/dist/io/harness-lock.js.map +1 -1
  119. package/dist/{runtime → io}/ledger-record.d.ts +1 -1
  120. package/dist/io/ledger-record.js.map +1 -0
  121. package/dist/policies/index.d.ts +2 -2
  122. package/dist/policies/index.js +2 -2
  123. package/dist/policies/index.js.map +1 -1
  124. package/dist/policies/ledger-client.js +10 -6
  125. package/dist/policies/ledger-client.js.map +1 -1
  126. package/dist/policies/producers.js +1 -1
  127. package/dist/policies/producers.js.map +1 -1
  128. package/dist/policies/requires.js +2 -2
  129. package/dist/policies/requires.js.map +1 -1
  130. package/dist/policy-packs/builtin/branch-protection.js +28 -3
  131. package/dist/policy-packs/builtin/branch-protection.js.map +1 -1
  132. package/dist/policy-packs/builtin/post-merge-gate-runtime.d.ts +90 -10
  133. package/dist/policy-packs/builtin/post-merge-gate-runtime.js +112 -16
  134. package/dist/policy-packs/builtin/post-merge-gate-runtime.js.map +1 -1
  135. package/dist/policy-packs/builtin/post-merge-gate.js +84 -31
  136. package/dist/policy-packs/builtin/post-merge-gate.js.map +1 -1
  137. package/dist/policy-packs/builtin/solution-acceptance-runtime.d.ts +152 -8
  138. package/dist/policy-packs/builtin/solution-acceptance-runtime.js +260 -13
  139. package/dist/policy-packs/builtin/solution-acceptance-runtime.js.map +1 -1
  140. package/dist/policy-packs/builtin/solution-acceptance.js +17 -3
  141. package/dist/policy-packs/builtin/solution-acceptance.js.map +1 -1
  142. package/dist/policy-packs/builtin/understanding-before-execution/active-claim.d.ts +25 -0
  143. package/dist/policy-packs/builtin/understanding-before-execution/active-claim.js +89 -0
  144. package/dist/policy-packs/builtin/understanding-before-execution/active-claim.js.map +1 -0
  145. package/dist/policy-packs/builtin/understanding-before-execution/index.d.ts +13 -0
  146. package/dist/policy-packs/builtin/understanding-before-execution/index.js +39 -0
  147. package/dist/policy-packs/builtin/understanding-before-execution/index.js.map +1 -0
  148. package/dist/policy-packs/builtin/understanding-before-execution/ledger.d.ts +25 -0
  149. package/dist/policy-packs/builtin/understanding-before-execution/ledger.js +56 -0
  150. package/dist/policy-packs/builtin/understanding-before-execution/ledger.js.map +1 -0
  151. package/dist/policy-packs/builtin/understanding-before-execution/lifecycle.d.ts +20 -0
  152. package/dist/policy-packs/builtin/understanding-before-execution/lifecycle.js +79 -0
  153. package/dist/policy-packs/builtin/understanding-before-execution/lifecycle.js.map +1 -0
  154. package/dist/policy-packs/builtin/understanding-before-execution/markers.d.ts +103 -0
  155. package/dist/policy-packs/builtin/understanding-before-execution/markers.js +203 -0
  156. package/dist/policy-packs/builtin/understanding-before-execution/markers.js.map +1 -0
  157. package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.d.ts +177 -0
  158. package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.js +309 -0
  159. package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.js.map +1 -0
  160. package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.d.ts +106 -0
  161. package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.js +209 -0
  162. package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.js.map +1 -0
  163. package/dist/policy-packs/builtin/understanding-before-execution/task-markers.d.ts +78 -0
  164. package/dist/policy-packs/builtin/understanding-before-execution/task-markers.js +185 -0
  165. package/dist/policy-packs/builtin/understanding-before-execution/task-markers.js.map +1 -0
  166. package/dist/policy-packs/builtin/understanding-before-execution-runtime.d.ts +1 -536
  167. package/dist/policy-packs/builtin/understanding-before-execution-runtime.js +9 -1095
  168. package/dist/policy-packs/builtin/understanding-before-execution-runtime.js.map +1 -1
  169. package/dist/policy-packs/builtin/understanding-before-execution.d.ts +109 -1
  170. package/dist/policy-packs/builtin/understanding-before-execution.js +183 -15
  171. package/dist/policy-packs/builtin/understanding-before-execution.js.map +1 -1
  172. package/dist/policy-packs/registry.d.ts +11 -0
  173. package/dist/policy-packs/registry.js +1 -1
  174. package/dist/policy-packs/registry.js.map +1 -1
  175. package/dist/policy-packs/runtime.d.ts +1 -1
  176. package/dist/policy-packs/runtime.js +10 -1
  177. package/dist/policy-packs/runtime.js.map +1 -1
  178. package/dist/runtime/agent-facing.js +1 -1
  179. package/dist/runtime/agent-facing.js.map +1 -1
  180. package/dist/runtime/bash-match-facts.d.ts +9 -7
  181. package/dist/runtime/bash-match-facts.js +9 -7
  182. package/dist/runtime/bash-match-facts.js.map +1 -1
  183. package/dist/runtime/bash-match-registry.d.ts +75 -38
  184. package/dist/runtime/bash-match-registry.js +279 -232
  185. package/dist/runtime/bash-match-registry.js.map +1 -1
  186. package/dist/runtime/bash-prefix-parse.d.ts +7 -0
  187. package/dist/runtime/bash-prefix-parse.js +205 -17
  188. package/dist/runtime/bash-prefix-parse.js.map +1 -1
  189. package/dist/runtime/command-normalize.d.ts +429 -4
  190. package/dist/runtime/command-normalize.js +1146 -67
  191. package/dist/runtime/command-normalize.js.map +1 -1
  192. package/dist/runtime/hermetic-spawn-guard.js +6 -0
  193. package/dist/runtime/hermetic-spawn-guard.js.map +1 -1
  194. package/dist/runtime/index.d.ts +2 -2
  195. package/dist/runtime/index.js +2 -2
  196. package/dist/runtime/index.js.map +1 -1
  197. package/dist/runtime/intercept.d.ts +247 -4
  198. package/dist/runtime/intercept.js +553 -61
  199. package/dist/runtime/intercept.js.map +1 -1
  200. package/dist/runtime/ledger-add.js +1 -1
  201. package/dist/runtime/ledger-add.js.map +1 -1
  202. package/dist/runtime/read-only-bash.js +356 -26
  203. package/dist/runtime/read-only-bash.js.map +1 -1
  204. package/dist/runtime/shell-word.d.ts +21 -0
  205. package/dist/runtime/shell-word.js +242 -0
  206. package/dist/runtime/shell-word.js.map +1 -0
  207. package/dist/schema/doctor.d.ts +25 -0
  208. package/dist/schema/doctor.js +23 -0
  209. package/dist/schema/doctor.js.map +1 -0
  210. package/dist/schema/extract.js +1 -1
  211. package/dist/schema/extract.js.map +1 -1
  212. package/dist/schema/index.d.ts +76 -0
  213. package/dist/schema/index.js +9 -0
  214. package/dist/schema/index.js.map +1 -1
  215. package/dist/schema/requires.js +2 -2
  216. package/dist/schema/requires.js.map +1 -1
  217. package/dist/schema/risk.d.ts +7 -0
  218. package/dist/schema/risk.js +23 -0
  219. package/dist/schema/risk.js.map +1 -1
  220. package/dist/schema/stale-base-check.d.ts +18 -0
  221. package/dist/schema/stale-base-check.js +37 -0
  222. package/dist/schema/stale-base-check.js.map +1 -0
  223. package/dist/schema/toolchain-parity.d.ts +3 -0
  224. package/dist/schema/toolchain-parity.js +13 -0
  225. package/dist/schema/toolchain-parity.js.map +1 -1
  226. package/package.json +5 -2
  227. package/dist/policies/duration.js.map +0 -1
  228. package/dist/policies/extract.js.map +0 -1
  229. package/dist/runtime/expand-home.js.map +0 -1
  230. package/dist/runtime/ledger-record.js.map +0 -1
  231. /package/dist/{policies → io}/duration.d.ts +0 -0
  232. /package/dist/{policies → io}/duration.js +0 -0
  233. /package/dist/{runtime → io}/expand-home.d.ts +0 -0
  234. /package/dist/{runtime → io}/expand-home.js +0 -0
  235. /package/dist/{policies → io}/extract.d.ts +0 -0
  236. /package/dist/{policies → io}/extract.js +0 -0
  237. /package/dist/{runtime → io}/ledger-record.js +0 -0
@@ -8,7 +8,14 @@
8
8
  // `bash_match` regex against the raw command first and, only on a raw
9
9
  // miss, against this normalised form too (raw-OR-normalised, never raw-
10
10
  // replaced-by-normalised), closing a class of silent trigger bypasses
11
- // (see below). `targetDir` / `targetBase` — the effective target
11
+ // (see below) — and, only on a further miss of THIS form, against a
12
+ // SECOND, independent, ampersand-aware normalisation pass (`AMP_BOUNDARY_RE`
13
+ // / `normalizeCommandAmpAware` below, task aabbad63) that this header
14
+ // otherwise never mentions; see that function's own comment for its scope
15
+ // and why it carries no `targetDir`/`targetBase` of its own (fix round 1,
16
+ // finding F5 — added so a reader of this header, the module's index,
17
+ // learns a second pass exists at all instead of finding it only by
18
+ // reading past this point). `targetDir` / `targetBase` — the effective target
12
19
  // directory of the command's own git invocation(s), when one can be
13
20
  // named unambiguously — are NOT WIRED TO ANY GATE. An earlier version of
14
21
  // this run used them to resolve the `${REPO}`/`${BRANCH}` policy
@@ -27,6 +34,38 @@
27
34
  // `98ad072f`. Nothing below describes gate wiring; it describes what
28
35
  // THIS MODULE computes, live or not.
29
36
  //
37
+ // UPDATE (task `98ad072f`, run `2026-08-02-per-repo-gate-scoping-
38
+ // redesign`, T-002): the per-segment foundation named above now EXISTS as
39
+ // a real export — `segmentViewOf` / `CommandSegment`, defined below
40
+ // `normalizeCommand` — instead of being only a design intention. It
41
+ // exposes, per boundary-delimited segment, the canonicalised text, the
42
+ // segment's OWN explicit target, and an EFFECTIVE target composed with the
43
+ // last preceding `cd <path>` segment (see `CommandSegment`'s own doc
44
+ // comment for the composition rules and its own not-covered list, which
45
+ // is narrower in places than `targetDir`/`targetBase` above — e.g. no
46
+ // `VAR=value cd <path>` tolerance). `normalizeCommand`'s own `targetDir`/
47
+ // `targetBase` are UNCHANGED by this addition — byte-identical in every
48
+ // pre-existing test.
49
+ //
50
+ // UPDATE (task `98ad072f`, T-003, this run): `segmentViewOf` /
51
+ // `CommandSegment` — NOT `normalizeCommand`'s own `targetDir`/`targetBase`
52
+ // above, which stay exactly as unwired as before this slice — IS now
53
+ // wired to a gate: `src/runtime/intercept.ts`'s `attributeTriggerSegments`
54
+ // re-tests a matched policy's own `bash_match` regex against each
55
+ // segment's `text`, and, for a policy whose `requires:` uses
56
+ // `${REPO}`/`${BRANCH}`/`at_head`, resolves those builtins (and
57
+ // `currentHeadSha`) from the trigger-satisfying segment's
58
+ // `effectiveTarget` instead of the event's cwd — lazily, memoised per
59
+ // resolved path, never touching `resolverGit`/`riskContext`. Trust in
60
+ // `effectiveTarget` is UNIFORM — no distinction between a segment's own
61
+ // explicit target and one inherited from a preceding `cd` (orchestrator
62
+ // decision D-010: an initial revision distinguished the two and was
63
+ // rejected — bash itself draws no such distinction, a `cd <B> && <verb>`
64
+ // chain genuinely runs `<verb>` inside B). See that module's own comments
65
+ // (`attributeTriggerSegments`, `resolveAttributedContexts`) for the
66
+ // consumption rules; this module's OWN extraction and ambiguity rules
67
+ // below are unchanged by being consumed.
68
+ //
30
69
  // MEASUREMENT RULE (task 47297478): before measuring THIS module's
31
70
  // extraction across builds with an ad-hoc corpus, read the per-arm-gate
32
71
  // design in scripts/measure-bash-prefix-parse.mjs — a corpus arm whose
@@ -35,7 +74,65 @@
35
74
  // Note the tool itself measures ONLY bash-prefix-parse.ts's `cdTarget`
36
75
  // extraction; this module's `normalized`/`targetDir`/`targetBase`
37
76
  // outputs have no instrument yet, so cross-build claims about them need
38
- // their own per-arm-gated measurement.
77
+ // their own per-arm-gated measurement — that instrument now exists:
78
+ // scripts/measure-command-normalize.mjs (groundwork for task aabbad63, the
79
+ // BOUNDARY_RE bare-`&` fix below). It rebuilds a faithful superset of the
80
+ // reverted fix's 140-form quoted-value regression corpus and gates arm A
81
+ // on bash actually running the verb, per the same never-fold-into-a-zero
82
+ // discipline.
83
+ //
84
+ // UPDATE (task cf3dff51): closes the quoted-shell-boundary residual named
85
+ // in the "DELIBERATELY NOT SUPPORTED" list below (`VAR='a; b' git push` —
86
+ // task 13e55484's pinned gap). A HALT criterion, written before any
87
+ // implementation code, forced the closing mechanism into an ADDITIVE,
88
+ // fully independent third normalisation pass (`normalizeCommandQuoteAware`
89
+ // / `findNextBoundaryQuoteAware`, defined near `findNextBoundary` below)
90
+ // rather than an in-place edit of `BOUNDARY_RE` / `segmentAndCanonicalize`
91
+ // itself. First round of this task built and fully tested the new pass but
92
+ // left it UNWIRED (the wiring — a `policyMatchesEvent` fourth disjunct in
93
+ // `src/runtime/intercept.ts` — touched a file outside that round's
94
+ // assigned scope). The orchestrator EXTENDED the scope for a second round
95
+ // specifically to include that one wiring point (not a general re-open of
96
+ // scope), so `normalizeCommandQuoteAware` IS now `policyMatchesEvent`'s
97
+ // fourth arm, mirroring `normalizeCommandAmpAware`'s own wiring (task
98
+ // aabbad63) exactly — see that function's own doc comment and
99
+ // `src/runtime/intercept.ts`'s `policyMatchesEvent` for the wired shape.
100
+ // UPDATE (task f561e44c): `src/cli/dry-run.ts`'s own, independently-copied
101
+ // raw-OR-normalised-OR-amp-normalised matcher (the `harness dry-run` debug
102
+ // verb, which predicts `policyMatchesEvent`'s verdict without running it)
103
+ // was left out of the wiring above (named residual, not silently left
104
+ // implicit) and, as a result, under-predicted the quote-aware family. This
105
+ // follow-up task added the same fourth OR-arm to that matcher too, so
106
+ // `harness dry-run` and `policy intercept` now agree on this family — see
107
+ // that file's own comment for the fix and its parity-fixture test.
108
+ //
109
+ // THE HALT CRITERION (decided and written down before writing the first
110
+ // line of this pass's implementation, mirroring 13e55484's own "ONE-
111
+ // DIRECTIONAL GUARD" structure): quote-aware segmentation MUST be built as
112
+ // a NEW, independent function — never as an in-place edit of the EXISTING,
113
+ // already-wired primary pass (`BOUNDARY_RE` / `findNextBoundary` /
114
+ // `segmentAndCanonicalize`'s behaviour for `normalizeCommand`). If closing
115
+ // this residual is ever found to require editing that existing pass in
116
+ // place instead of adding a new one, HALT and fall back to documentation-
117
+ // only (pin the residual, as this header already does for its NOT-
118
+ // SUPPORTED siblings) rather than risk a two-directional regression. This
119
+ // is not a hypothetical: this task PROVED the trigger before writing any
120
+ // code, by reading `tests/runtime/command-normalize.test.ts`'s pinned
121
+ // "accepted over-block: a quoted assignment inside TEXT after a boundary
122
+ // char now matches" case — `echo "a; VAR='x y' git push"` — which matches
123
+ // TODAY only because `BOUNDARY_RE`'s quote-unawareness un-splits `VAR='x
124
+ // y' git push` into its own segment. An in-place quote-aware edit of the
125
+ // SAME pass would un-split it the other way (merge it back into the outer
126
+ // `echo "..."` segment, which is not itself a recognised invocation) and
127
+ // flip that already-accepted, already-WIRED pin from match to no-match —
128
+ // a genuine lost match under this module's own no-lost-matches discipline
129
+ // (see AMP_BOUNDARY_RE's own comment for why that discipline exists at
130
+ // all), not merely a lost false positive. Building a separate, additive
131
+ // pass instead means the existing primary pass — and every pin resting on
132
+ // it, including that exact one — stays byte-for-byte untouched; see
133
+ // `normalizeCommandQuoteAware`'s own comment for the differential
134
+ // measurement that confirms this by construction rather than by
135
+ // inspection alone.
39
136
  //
40
137
  // THE TRIGGER-MATCHING GAP THIS CLOSES: every `bash_match` policy trigger
41
138
  // (`src/cli/init/templates.ts`, `docs/examples/full-manifest.yaml`) is a
@@ -221,13 +318,33 @@
221
318
  // agent writes and then executes.
222
319
  // - `pushd`/`popd`, and a `cd` inside a nested subshell
223
320
  // (`(cd X && ...)`) — mirrors `bash-prefix-parse.ts`'s own scope.
224
- // - A shell-boundary character INSIDE a quoted assignment VALUE
225
- // (`VAR='a; b' git push` — task 13e55484's pinned residual):
226
- // `BOUNDARY_RE` splits segments BEFORE tokenisation and is quote-
227
- // unaware, so the quoted value is cut at the `;` and neither
228
- // resulting segment carries a recognisable invocation. Closing this
229
- // means a quote-aware segmenter, a different (and riskier) change
230
- // than the assignment-value continuation that task shipped.
321
+ // - CLOSED (was: a shell-boundary character INSIDE a quoted assignment
322
+ // VALUE — `VAR='a; b' git push`, task 13e55484's pinned residual, no
323
+ // longer belongs in a NOT-SUPPORTED list). Task cf3dff51: a quote-
324
+ // aware THIRD normalisation pass, `normalizeCommandQuoteAware`
325
+ // (defined near `findNextBoundary` below), built as a new, additive
326
+ // pass rather than an in-place edit of `BOUNDARY_RE` /
327
+ // `segmentAndCanonicalize` (see the module header's HALT CRITERION
328
+ // paragraph and that function's own comment for why), now wired as
329
+ // `policyMatchesEvent`'s FOURTH arm (`src/runtime/intercept.ts`,
330
+ // mirroring `normalizeCommandAmpAware`'s own wiring, task aabbad63).
331
+ // Closes `;`, `|`, `&&`, `(`, and a literal newline, each with an
332
+ // internal whitespace split inside the quoted value (the exact
333
+ // `VAR='a; b' git push` shape) — measured 12/12 at the real
334
+ // `runInterceptCli` hook entry point (isolated harness home,
335
+ // PATH-shim-verified bash execution, two gated verbs). Over-block
336
+ // surface: MEASURED, not assumed, against an adversarial probe —
337
+ // every ADDITIONAL match this pass adds beyond raw /
338
+ // `normalizeCommand` / `normalizeCommandAmpAware` is a TRUE positive
339
+ // (a real invocation bash would actually execute); no NEW over-block
340
+ // CLASS distinct from `consumeAssignment`'s own, already-reviewed
341
+ // precision limits (the "master false positive" class already pinned
342
+ // elsewhere in the test file) was found. See `normalizeCommandQuoteAware`'s
343
+ // own doc comment for the full mechanism and reasoning. CLOSED (task
344
+ // f561e44c): `src/cli/dry-run.ts`'s independently-copied matcher was
345
+ // initially not extended to try this fourth form — named residual — but
346
+ // that follow-up task added the same fourth OR-arm there too, see that
347
+ // file's own comment.
231
348
  // - A quoted assignment VALUE containing the literal word `git`/`gh`/
232
349
  // `npm`/`harness` as its own token (`VAR='a git b' git push`): the
233
350
  // continuation's one-directional guard abandons rather than risk
@@ -315,6 +432,28 @@
315
432
  // trips the gate, the same cost the dbc6d303 quoted/heredoc class
316
433
  // already carries.
317
434
  //
435
+ // SAME CLASS, third member (task aabbad63, this run's own amp-aware
436
+ // second pass): a WIDENING of the second member above, not a new class —
437
+ // `AMP_BOUNDARY_RE` is quote-unaware in exactly the same way `BOUNDARY_RE`
438
+ // is, so a bare `&` INSIDE a quoted string is now also a segment
439
+ // boundary, and a wrapper spelling sitting behind it can be peeled and
440
+ // canonicalised as though it were a real invocation. Measured on a
441
+ // 160-form quoted-text corpus arbitrated by real bash (PATH-shimmed
442
+ // verbs + a marker file the verb would touch if it actually ran): 144
443
+ // spellings of `<text> & <wrapper> <gated verb>` INSIDE a quoted string
444
+ // now match where the base (pre-aabbad63) build did not, and bash ran
445
+ // the gated verb in 0 of them — a false alarm on a command that only
446
+ // PRINTS the spelling, never a missed real gate. The wrapper-LESS
447
+ // variant of the same shape (`echo 'a & git push'`) already false-
448
+ // alarmed on the base build 16/16 via the RAW regex alone, because every
449
+ // shipped `bash_match` trigger already anchors on a bare `&` (task
450
+ // `d834a065`) — so this is a WIDENING of an already-accepted over-block
451
+ // class to the wrapped spellings of it, not a new false-positive class
452
+ // this task introduces. The opposite direction, measured on the general
453
+ // (non-adversarial) corpus rather than this quoted-text one: of 276
454
+ // matches this pass gains there, all 151 classifiable ones were TRUE
455
+ // positives, 0 false alarms.
456
+ //
318
457
  // `$(...)` command substitution is ACCIDENTALLY covered — not a
319
458
  // deliberate feature, and distinct from the backtick case above, which
320
459
  // is a genuine, deliberate gap. `BOUNDARY_RE` treats a bare `(` as a
@@ -407,26 +546,263 @@ export const MAX_NORMALIZE_LENGTH = 100_000;
407
546
  * starts with `&`), so there is no tie for the regex engine's leftmost-
408
547
  * match rule to break.
409
548
  *
410
- * DELIBERATELY MISSING: single `&`, even though bash starts a new
411
- * command after it and every shipped `bash_match` trigger now treats it
412
- * as a boundary (task `d834a065`). Adding it here was tried and
413
- * REVERTED after measurement: this regex is quote-unaware, `&` inside a
414
- * quoted assignment value is common, and the split lands mid-quote, so
415
- * the value continuation never engages and the wrapper is never peeled.
549
+ * DELIBERATELY MISSING, STILL: single `&`, even though bash starts a new
550
+ * command after it. Adding it HERE was tried and REVERTED after
551
+ * measurement: this regex is quote-unaware, `&` inside a quoted
552
+ * assignment value is common, and the split lands mid-quote, so the
553
+ * value continuation never engages and the wrapper is never peeled.
416
554
  * Measured against the pre-change build with a passing positive
417
555
  * control: 140 of 140 sampled `<wrapper> FOO='a&b' <gated verb>`
418
556
  * spellings LOST a gate they previously held, including the
419
- * `operator_only` kill-switch deny. The consequence of leaving it out
420
- * is that `A=x&env -C /tmp git status` stays ungated (the wrapper can
421
- * only be peeled after a recognised boundary); that gap is tracked
422
- * separately and needs a design that does not regress the quoted-value
423
- * case, not another character in this alternation.
557
+ * `operator_only` kill-switch deny. That evidence is why THIS alphabet
558
+ * stays byte-identical rather than growing a bare `&` alternative
559
+ * directly — see `scripts/measure-command-normalize.mjs`'s arm A for the
560
+ * regression corpus that keeps proving it stays byte-identical.
561
+ *
562
+ * TASK aabbad63 (this run): the bare-`&` family this alphabet cannot see
563
+ * (`A=x&env -C /tmp git status`, `echo hi & nice git status`) is now
564
+ * handled WITHOUT touching this regex at all — by a SECOND, independent
565
+ * boundary alphabet (`AMP_BOUNDARY_RE` below) driving a SECOND
566
+ * normalisation pass (`normalizeCommandAmpAware`) that
567
+ * `policyMatchesEvent` (`src/runtime/intercept.ts`) tries ONLY as a
568
+ * third, additional raw-OR-normalised-OR-amp-normalised disjunct — never
569
+ * a replacement for either of the first two arms. That construction is
570
+ * exactly what avoids re-triggering the 140/140 regression above: every
571
+ * command that matched via the raw test or via THIS (BOUNDARY_RE)
572
+ * normalised form keeps matching through those same two arms, byte-for-
573
+ * byte unchanged; the amp-aware arm can only ADD a match a spelling
574
+ * would otherwise have missed, so a quoted `FOO='a&b'` value that this
575
+ * alphabet already normalises correctly is never at risk of losing that
576
+ * gate to the new arm. See `AMP_BOUNDARY_RE`'s own comment for why it
577
+ * has to be a distinct regex object rather than a mutation of this one,
578
+ * and `normalizeCommandAmpAware`'s comment for why its return type
579
+ * carries no `targetDir`/`targetBase`.
424
580
  */
425
581
  const BOUNDARY_RE = /\n|&&|;|\||\(/g;
582
+ /**
583
+ * Second boundary alphabet (task aabbad63): identical to `BOUNDARY_RE`
584
+ * plus a bare `&` — bash's OWN "start a new command here" token, which
585
+ * every shipped `bash_match` trigger regex has treated as one of its own
586
+ * anchor characters since task `d834a065`, but which `BOUNDARY_RE` above
587
+ * has never recognised as a NORMALISATION-time boundary (see its comment
588
+ * for the reverted, measured-140/140-regression attempt to add it there
589
+ * directly).
590
+ *
591
+ * ALTERNATION ORDER IS LOAD-BEARING: `&&` MUST be listed to the LEFT of
592
+ * the bare `&` alternative. JS regex alternation is leftmost-FIRST, not
593
+ * leftmost-longest — with `&` tried before `&&`, every `&&` in a command
594
+ * would tokenise as TWO consecutive one-character `&` boundary MATCHES
595
+ * instead of a single two-character `&&` boundary match.
596
+ *
597
+ * MEASURED, NOT ASSUMED (do not "simplify" the pin below to an
598
+ * end-to-end `.normalized` string assertion): for THIS module's segment-
599
+ * and-rejoin architecture specifically, that reordering is, perhaps
600
+ * counter-intuitively, INVISIBLE in `normalizeCommandAmpAware`'s own
601
+ * output string. Two adjacent single-`&` matches sandwich a genuinely
602
+ * zero-length segment between them (the two `&` characters of a real
603
+ * `&&` are, by definition, adjacent), `canonicalizeSegment("")` is a
604
+ * verbatim no-op, and `parts.join("")` reassembles the exact same bytes
605
+ * either way — verified directly (not merely reasoned about) by
606
+ * temporarily swapping this alternation's order, rebuilding, and
607
+ * diffing `normalizeCommandAmpAware(...).normalized` across several
608
+ * `&&`-bearing commands (including this comment's own `git -C
609
+ * /tmp/repoB status && git -C /tmp/repoB log` example): byte-identical
610
+ * output both ways. So a test asserting on the OUTPUT STRING alone
611
+ * cannot distinguish the two orderings for this architecture — the pin
612
+ * in `tests/runtime/command-normalize.test.ts` therefore execs this
613
+ * regex object DIRECTLY against a `&&`-bearing string and asserts the
614
+ * match text is `"&&"`, not `"&"`, which DOES fail under a swap. The
615
+ * order is still specified exactly as documented above (leftmost-first
616
+ * is real JS semantics, and the internal `segmentAndCanonicalize`
617
+ * bookkeeping this alphabet ALSO drives — `hasAmbiguousNonGitSegment`,
618
+ * discarded by `normalizeCommandAmpAware` but not by a hypothetical
619
+ * future consumer of `segmentAndCanonicalize` under this alphabet —
620
+ * would still see a spurious extra "non-git segment" under the wrong
621
+ * order), it just cannot be pinned through this module's ONE exposed
622
+ * string alone.
623
+ *
624
+ * Used ONLY by `normalizeCommandAmpAware` below, which is consulted ONLY
625
+ * as `policyMatchesEvent`'s third, additional matching arm — see that
626
+ * function's own comment for why its result never carries a `targetDir`/
627
+ * `targetBase`, and `src/runtime/intercept.ts` for the memoised-thunk
628
+ * threading that keeps this off the common (raw-or-normalised-already-
629
+ * matched) path. Exported (mirrors `GIT_TOKEN_RE` / `NON_GIT_HEAD_TOKENS`
630
+ * / `MAX_NORMALIZE_LENGTH` above) solely so the ordering pin test can
631
+ * exec it directly instead of re-declaring an equivalent regex literal
632
+ * that would test JS semantics in general, not THIS source's actual
633
+ * alternation.
634
+ */
635
+ export const AMP_BOUNDARY_RE = /\n|&&|&|;|\||\(/g;
426
636
  /** A target value starting with `~` — not expanded, treated as unparseable (F5). */
427
637
  function isTildeTarget(dir) {
428
638
  return dir.startsWith("~");
429
639
  }
640
+ /**
641
+ * A target value `segmentViewOf`'s `CommandSegment.ownTarget` (task
642
+ * `98ad072f` groundwork) treats as unattributable even where the
643
+ * pre-existing per-segment machinery below (`canonicalizeSegment` /
644
+ * `peelGitGlobalOptions` / `peelEnv`) still captures SOME string for the
645
+ * OLD aggregate's `targetDir` — that extraction is UNCHANGED by this check
646
+ * (see the module header's STATUS/UPDATE note): the tokeniser is not
647
+ * quote-aware, so a quoted `-C` / `--work-tree` / `--git-dir` / `env -C`
648
+ * value is captured WITH its quote character(s) still attached
649
+ * (`git -C "/tmp/repoB" status` captures `"/tmp/repoB"`, quotes included —
650
+ * not a real path, and not `null` either under the pre-existing
651
+ * extraction) rather than being rejected outright. The same is true of a
652
+ * value containing a command-substitution marker (`$(` or a backtick) —
653
+ * this module never resolves substitutions, so a value carrying one names
654
+ * nothing this module can vouch for as a literal path. A
655
+ * whitespace-containing path needs no separate check here: the
656
+ * whitespace-splitting tokeniser never captures a value containing
657
+ * whitespace as ONE token in the first place — it becomes a different,
658
+ * unrecognised token sequence instead (see the module header's own
659
+ * "quoted directory arguments containing whitespace" note), which for
660
+ * `segmentViewOf` purposes simply fails to produce a recognised own target
661
+ * at all (see `parseCdSegmentTarget` below for the `cd` case).
662
+ *
663
+ * A value ENDING in a bare `$` is ALSO rejected, even though it does not
664
+ * literally contain the two-character `$(` marker: `BOUNDARY_RE` (this
665
+ * function is only ever reached through the primary, non-amp-aware pass —
666
+ * `segmentViewOf` never runs under `AMP_BOUNDARY_RE`) treats a bare `(` as
667
+ * a shell boundary UNCONDITIONALLY (needed for the leading-command case,
668
+ * module header), so `$(` can never survive as a substring WITHIN one
669
+ * token or segment in the first place — the `(` always splits it into its
670
+ * own boundary match first. A `cd` target ending in `$` is the tell-tale
671
+ * leftover of exactly that split (`cd $(pwd)/T && ...` tokenises this
672
+ * function's caller's OWN segment as literal `cd $`, a syntactically
673
+ * valid-LOOKING two-token `cd <path>` with `<path>` being the single
674
+ * character `$` — measured live, not hypothetical). Rejecting it here
675
+ * closes that gap without needing `parseCdSegmentTarget` (which has no
676
+ * visibility into what boundary token follows its own segment) to
677
+ * special-case it itself. The `-C`/`--work-tree`/`--git-dir`/`env -C` side
678
+ * of this module never has this specific gap: a value-capturing git
679
+ * global option additionally requires a SUBCOMMAND token after its value
680
+ * (`peelGitGlobalOptions`'s caller checks `gitOpts.idx >= tokens.length`),
681
+ * so `git -C $(pwd) status` already comes back `isGit: false` (no
682
+ * subcommand token survives the split) before this function is ever
683
+ * consulted for it — this extra check is `cd`-specific, `cd <path>` having
684
+ * no analogous "and then a subcommand" requirement.
685
+ */
686
+ function isUnattributableTargetValue(value) {
687
+ return (value.includes("'") ||
688
+ value.includes('"') ||
689
+ value.includes("$(") ||
690
+ value.includes("`") ||
691
+ value.endsWith("$"));
692
+ }
693
+ function classifyCdSegment(segmentText) {
694
+ const tokens = tokenizeWithOffsets(segmentText);
695
+ if (tokens.length === 0)
696
+ return { kind: "none" };
697
+ const head = tokens[0].text;
698
+ if (head === "pushd" || head === "popd")
699
+ return { kind: "reset" };
700
+ if (head !== "cd")
701
+ return { kind: "none" };
702
+ if (tokens.length === 1)
703
+ return { kind: "reset" }; // bare `cd`, no argument
704
+ if (tokens.length > 2)
705
+ return { kind: "reset" }; // flagged cd: `cd -P /tmp/x`
706
+ const arg = tokens[1].text;
707
+ if (arg.length === 0 || arg.startsWith("-"))
708
+ return { kind: "reset" }; // `cd -`, `cd -P` alone
709
+ return { kind: "cd", arg };
710
+ }
711
+ /**
712
+ * Compute one segment's `ownTarget` / `effectiveTarget` (task `98ad072f`
713
+ * groundwork, `segmentViewOf` / `CommandSegment`) and the cd-basis to
714
+ * carry into the NEXT segment, from: this segment's own canonicalisation
715
+ * result (already computed by the shared walk — `identityTargetDir` /
716
+ * `identityTargetBase` / `isGit`, unmodified here; NOT the OLD aggregate's
717
+ * `targetDir`/`targetBase`, which still includes `--work-tree` — D-017,
718
+ * fix round 2), whether THIS segment is itself a recognised `cd <path>`
719
+ * segment, and the cd-basis carried in from whatever preceded it.
720
+ * Implements `CommandSegment.effectiveTarget`'s doc comment verbatim; see
721
+ * that comment for the consumer-facing statement of these rules.
722
+ */
723
+ function computeSegmentTarget(segmentText, canon, incomingCdBasis) {
724
+ // Same-invocation `env -C`/`--chdir` base + a RELATIVE own git
725
+ // REPO-RELOCATING target (`-C`/`--git-dir` — orchestrator follow-up
726
+ // after the initial T-002 round, still task `98ad072f` groundwork):
727
+ // `canon.identityTargetBase` is non-null EXACTLY in this mixed case —
728
+ // see `canonicalizeSegment`'s own `identityTargetBase` computation,
729
+ // reused here unmodified, never when there is no wrapping `env -C` on
730
+ // the SAME invocation. `env -C /tmp/base git -C sub status` must NOT
731
+ // hand a future consumer `ownTarget`/`effectiveTarget: "sub"` (raw,
732
+ // relative): a naive consumer resolving a relative value against ITS
733
+ // OWN cwd (this module's documented fallback for the "no known basis"
734
+ // branch below) would land on a CONFIDENTLY WRONG repository whenever
735
+ // the caller's cwd differs from `/tmp/base` — precisely the "relative
736
+ // and `~` target dirs resolve to a confidently wrong repo" class named
737
+ // in the 07-27 review. Composing correctly to `/tmp/base/sub` would ALSO
738
+ // be correct but is deliberately NOT this module's job (real
739
+ // filesystem-shaped path joining — the same reason the cd-basis
740
+ // composition rule below refuses to compose `cd T && git -C sub` into
741
+ // `T/sub`); noted here as a possible LATER precision gain, not built in
742
+ // this slice. A bare git invocation with an `env -C` base and no
743
+ // relative own REPO-RELOCATING target (`env -C /abs git status`) is
744
+ // UNAFFECTED — the ordinary `canon.identityTargetDir` extraction below
745
+ // already folds that in (`gitOpts.relocateTargetDir ?? envTargetDir`),
746
+ // and `canon.identityTargetBase` stays `null` for that shape (no
747
+ // relative own repo-relocating target to be relative TO). A `--work-
748
+ // tree`-only invocation (no `-C`/`--git-dir` on the same call) is NEVER
749
+ // "mixed" here even when wrapped by `env -C`: `relocateTargetDir` is
750
+ // `null` for it, so `identityTargetDir` falls through to `envTargetDir`
751
+ // (or stays `null`) and `identityTargetBase` stays `null` — D-017, this
752
+ // is the fix. A `cd`-shaped segment never reaches this branch: it never
753
+ // matches `GIT_TOKEN_RE`, so `canon.isGit` is always `false` for one.
754
+ if (canon.isGit && canon.identityTargetBase !== null) {
755
+ return { ownTarget: null, effectiveTarget: null, outgoingCdBasis: incomingCdBasis };
756
+ }
757
+ const cdClass = classifyCdSegment(segmentText);
758
+ // D-014 (fix round, run 2026-08-02-per-repo-gate-scoping-redesign): a
759
+ // segment that GENUINELY changes directory to somewhere this module
760
+ // cannot name (bare `cd`, `cd -`, a flagged `cd`, `pushd`, `popd`)
761
+ // RESETS the running basis to `null` for every later segment, rather
762
+ // than the pre-fix behaviour of leaving it unchanged (silently
763
+ // INHERITING whatever preceded it, as though the shell had not moved at
764
+ // all). This segment names nothing itself either. A PRECISION fix, not
765
+ // the security fix: `src/runtime/intercept.ts`'s D-011 additive
766
+ // attribution rule means an inherited target always also demands the
767
+ // cwd context, so an over-cautious reset here can only make a LATER
768
+ // segment fall back to cwd-only evaluation — identical to the shipped
769
+ // baseline for an unattributable form (D-003), never a fail-open.
770
+ if (cdClass.kind === "reset") {
771
+ return { ownTarget: null, effectiveTarget: null, outgoingCdBasis: null };
772
+ }
773
+ const cdArg = cdClass.kind === "cd" ? cdClass.arg : null;
774
+ const rawOwn = cdArg !== null ? cdArg : canon.isGit ? canon.identityTargetDir : null;
775
+ const ownTarget = rawOwn !== null && !isTildeTarget(rawOwn) && !isUnattributableTargetValue(rawOwn)
776
+ ? rawOwn
777
+ : null;
778
+ let effectiveTarget;
779
+ if (ownTarget === null) {
780
+ // D-014: a recognised `cd <path>` segment whose OWN value came out
781
+ // unattributable (tilde/quoted/substitution) RESETS rather than
782
+ // inherits — the shell genuinely changed directory to somewhere this
783
+ // module cannot name, so silently keeping an earlier basis in place
784
+ // would be a confidently-wrong carry-forward, exactly the class this
785
+ // whole module exists to avoid. A segment that is NOT cd-shaped at
786
+ // all (`cdArg === null`, `cdClass.kind === "none"`) still inherits —
787
+ // this module has no evidence the shell moved, so it does not guess.
788
+ effectiveTarget = cdArg !== null ? null : incomingCdBasis;
789
+ }
790
+ else if (path.isAbsolute(ownTarget)) {
791
+ effectiveTarget = ownTarget;
792
+ }
793
+ else {
794
+ effectiveTarget = incomingCdBasis === null ? ownTarget : null;
795
+ }
796
+ // Only a recognised `cd <path>` segment updates the outgoing basis
797
+ // (whether or not its OWN value came out attributable — see
798
+ // `CommandSegment.effectiveTarget`'s doc comment for why an
799
+ // unattributable `cd` must still RESET the basis to `null`, not leave a
800
+ // stale earlier one in place). Every other segment — gated or not —
801
+ // never itself changes the shell's directory, so the incoming basis
802
+ // passes through unchanged.
803
+ const outgoingCdBasis = cdArg !== null ? effectiveTarget : incomingCdBasis;
804
+ return { ownTarget, effectiveTarget, outgoingCdBasis };
805
+ }
430
806
  /**
431
807
  * Given a Bash command string, return its normalised form and the target
432
808
  * directory (and base, if relative) of its git invocation. Never throws:
@@ -454,7 +830,269 @@ export function normalizeCommand(command) {
454
830
  return { normalized: command, targetDir: null, targetBase: null, truncated: false };
455
831
  }
456
832
  }
833
+ /**
834
+ * Given a Bash command string, return its per-segment view (task
835
+ * `98ad072f` groundwork, T-002) — see `CommandSegment`'s own doc comment
836
+ * for the field-level composition rules. `null` when the input exceeded
837
+ * `MAX_NORMALIZE_LENGTH` (mirrors `NormalizedCommand.truncated`, but as
838
+ * the ABSENCE of a view rather than a boolean flag alongside one, since
839
+ * there is no meaningful per-segment view to return for an input whose
840
+ * normalisation itself was skipped). `[]` for an empty or non-string
841
+ * command, and as the never-throws fallback (mirrors `normalizeCommand`'s
842
+ * own defensive backstop — see its doc comment above).
843
+ *
844
+ * THIS function's OWN call into `segmentAndCanonicalize` (`collectSegments:
845
+ * true`) is one walk, O(length) — the "no additional pass" claim is scoped
846
+ * to that ONE call, not to a caller's total work. CORRECTED (D-015, fix
847
+ * round, run 2026-08-02-per-repo-gate-scoping-redesign) — the prior wording
848
+ * here read "no additional pass over the command string" without that
849
+ * scoping, which a reader could take as "calling this costs nothing beyond
850
+ * `normalizeCommand`'s own walk." Measured false: `normalizeCommand(cmd)`
851
+ * and `segmentViewOf(cmd)` are two SEPARATE top-level calls, each running
852
+ * its own full `segmentAndCanonicalize` pass over the SAME string — a
853
+ * caller needing both (as `src/cli/policy/intercept.ts` does, for
854
+ * `normalized`/`truncated` and the segment view respectively) pays TWO
855
+ * walks, not one; measured +206% at the `MAX_NORMALIZE_LENGTH` bound
856
+ * (2.73 → 8.36 ms), harmless against the 1000 ms hook budget but not free.
857
+ * As of the same fix round, the one production caller only pays this
858
+ * second walk lazily, when some matching policy actually needs it (see
859
+ * `InterceptOptions.commandSegmentsThunk` in `src/runtime/intercept.ts`) —
860
+ * but that laziness lives in the CALLER, not in this function, which still
861
+ * does exactly one walk every time it runs. The ampersand-aware second pass
862
+ * (`normalizeCommandAmpAware`) NEVER collects a segment view (always calls
863
+ * `segmentAndCanonicalize` with `collectSegments: false`) — see that
864
+ * function's own comment for why a directory extracted under the
865
+ * ampersand-aware alphabet would be unreliable in the first place; the
866
+ * same reasoning applies here, so this module offers no amp-aware variant
867
+ * of this function at all.
868
+ */
869
+ export function segmentViewOf(command) {
870
+ if (typeof command !== "string" || command.length === 0)
871
+ return [];
872
+ if (command.length > MAX_NORMALIZE_LENGTH)
873
+ return null;
874
+ try {
875
+ return segmentAndCanonicalize(command, BOUNDARY_RE, true).segments ?? [];
876
+ }
877
+ catch {
878
+ return [];
879
+ }
880
+ }
881
+ /**
882
+ * Ampersand-aware SECOND normalisation pass (task aabbad63). Segments the
883
+ * command under `AMP_BOUNDARY_RE` (`BOUNDARY_RE` plus a bare `&`) instead
884
+ * of `BOUNDARY_RE`, then canonicalises exactly like the primary pass
885
+ * (same `canonicalizeSegment`, same wrapper-peeling rules, same
886
+ * `MAX_NORMALIZE_LENGTH` bound). Closes the gap `BOUNDARY_RE`'s own
887
+ * comment names: `A=x&env -C /tmp git status` and `echo hi & nice git
888
+ * status` were ungated because a wrapper can only be peeled after a
889
+ * recognised boundary, and a bare `&` was not one.
890
+ *
891
+ * Return type deliberately has NO `targetDir`/`targetBase` — not merely
892
+ * "always null under this alphabet", genuinely ABSENT from the type, so
893
+ * no consumer can read one by mistake. Reason: a bare `&` is ALSO how
894
+ * bash spells a background job (`git -C /x push &`) and how a redirect
895
+ * merges streams (`git -C /x log 2>&1`, `... &> out`). Under THIS
896
+ * alphabet each of those splits into a spurious extra "segment", which
897
+ * `segmentAndCanonicalize`'s own non-git-segment ambiguity rule (a
898
+ * segment that starts a new command and is not itself a git invocation
899
+ * forces `targetDir` to `null` — see its G1 comment) would turn into a
900
+ * wrong-shaped loss for a case the PRIMARY pass still resolves correctly
901
+ * today. Pinned in `tests/runtime/command-normalize.test.ts`'s "aabbad63
902
+ * groundwork" describe block, which exercises ONLY `normalizeCommand`
903
+ * (the primary pass), never this function — `targetDir`/`targetBase`
904
+ * stay the primary pass's property alone.
905
+ *
906
+ * Only consulted by `policyMatchesEvent`'s third arm
907
+ * (`src/runtime/intercept.ts`), and only when a policy's `bash_match`
908
+ * regex has already missed BOTH the raw command and the primary
909
+ * normalised form — see that module and `src/cli/policy/intercept.ts`
910
+ * for the memoised-thunk threading that keeps this computation off the
911
+ * common (already-matched) path.
912
+ */
913
+ export function normalizeCommandAmpAware(command) {
914
+ return normalizeWithGuards(command, (c) => segmentAndCanonicalize(c, AMP_BOUNDARY_RE, false).normalized);
915
+ }
916
+ /**
917
+ * Shared empty/length/throw guard clauses for a normalisation pass whose
918
+ * public return shape is exactly `{ normalized, truncated }` — no
919
+ * `targetDir`/`targetBase` (task cf3dff51 extraction: before this,
920
+ * `normalizeCommandAmpAware` and `normalizeCommandQuoteAware` carried
921
+ * byte-identical copies of this wrapper, which `npm run check:duplication`
922
+ * — a jscpd clone-count fitness function, see that script's own header
923
+ * comment for the precedent this follows — flagged as new duplication
924
+ * against the pinned baseline; extracted rather than raising the pin,
925
+ * per that script's own stated preference). `normalizeCommand` itself is
926
+ * NOT this shape (its return type also carries `targetDir`/`targetBase`)
927
+ * and keeps its own, separate guard clauses — deliberately not folded in
928
+ * here, since unifying THREE different return shapes behind one generic
929
+ * helper would obscure more than the ~10 duplicated lines it would save.
930
+ * `compute` is called only once `command` has passed both guards, so it
931
+ * never needs to re-check emptiness or the length bound itself.
932
+ */
933
+ function normalizeWithGuards(command, compute) {
934
+ if (typeof command !== "string" || command.length === 0) {
935
+ return { normalized: typeof command === "string" ? command : "", truncated: false };
936
+ }
937
+ if (command.length > MAX_NORMALIZE_LENGTH) {
938
+ return { normalized: command, truncated: true };
939
+ }
940
+ try {
941
+ return { normalized: compute(command), truncated: false };
942
+ }
943
+ catch {
944
+ return { normalized: command, truncated: false };
945
+ }
946
+ }
457
947
  function normalizeCommandInner(command) {
948
+ const { normalized, targetDir, targetBase } = segmentAndCanonicalize(command, BOUNDARY_RE, false);
949
+ return { normalized, targetDir, targetBase, truncated: false };
950
+ }
951
+ /**
952
+ * Quote-aware THIRD normalisation pass (task cf3dff51). Segments the
953
+ * command under `BOUNDARY_RE`'s own alphabet — same characters as the
954
+ * primary pass, no widening — but tracks single/double-quote state while
955
+ * scanning (`findNextBoundaryQuoteAware` below) so a boundary character
956
+ * sitting INSIDE a quoted assignment value is no longer treated as a real
957
+ * segment split. Closes the gap the module header's "DELIBERATELY NOT
958
+ * SUPPORTED" list and `BOUNDARY_RE`'s own comment both name: `VAR='a; b'
959
+ * git push` (task 13e55484's pinned residual) — `BOUNDARY_RE` used to cut
960
+ * the segment at the `;`, landing squarely between the two halves of one
961
+ * quoted value, so neither resulting piece carried a recognisable
962
+ * invocation and `consumeAssignment`'s own multi-token quote continuation
963
+ * (built for exactly this shape) never got the chance to run: it operates
964
+ * AFTER segmentation, on tokens WITHIN one segment, and the quoted value
965
+ * was never in one segment to begin with. Once quote-aware segmentation
966
+ * keeps the value whole, `consumeAssignment` needs no changes at all — it
967
+ * already reconstructs a multi-token quoted value correctly; this pass
968
+ * only fixes what gets handed to it as ONE segment in the first place.
969
+ *
970
+ * BUILT AS A NEW, INDEPENDENT PASS RATHER THAN AN IN-PLACE EDIT OF
971
+ * `BOUNDARY_RE` / `segmentAndCanonicalize` — this is the module header's
972
+ * HALT CRITERION, restated here at the point it governs: quote-aware
973
+ * segmentation can MERGE a segment the primary pass currently splits, and
974
+ * that merge cuts in BOTH directions — it can turn a previously-missed
975
+ * bypass into a match (the fix), but it can just as easily turn a
976
+ * previously-accepted, ALREADY-WIRED over-block into a miss (a
977
+ * regression). Measured, not hypothetical: `tests/runtime/command-
978
+ * normalize.test.ts`'s "accepted over-block: a quoted assignment inside
979
+ * TEXT after a boundary char now matches" pin — `echo "a; VAR='x y' git
980
+ * push"` — matches via `normalizeCommand` TODAY only because `BOUNDARY_RE`
981
+ * splits it at the `;`, landing `VAR='x y' git push` at a segment start
982
+ * where the assignment continuation turns it into a match. An in-place
983
+ * quote-aware edit of THAT pass would un-split it back into the outer
984
+ * `echo "..."` segment — not itself a recognised invocation — and flip
985
+ * that pin to a lost match, in violation of the raw-OR-normalised-OR-amp-
986
+ * aware-OR-(this) never-lose-a-match discipline every other pass in this
987
+ * module already honours. Building this as a SEPARATE pass instead means
988
+ * `normalizeCommand`'s own segmentation, and every test resting on it —
989
+ * including that exact one — is untouched by this task; see this file's
990
+ * own test suite for the differential proof (every case the first three
991
+ * arms already matched still matches, by construction, because none of
992
+ * those three arms' code changed at all).
993
+ *
994
+ * WIRED to `policyMatchesEvent` (`src/runtime/intercept.ts`) as its
995
+ * FOURTH arm — same shape as `normalizeCommandAmpAware`'s own third-arm
996
+ * wiring (task aabbad63): consulted only after the raw command,
997
+ * `normalizedCommand`, and the amp-aware pass have all missed, via a
998
+ * memoised thunk (`InterceptOptions.quoteNormalizedCommandThunk`,
999
+ * `src/cli/policy/intercept.ts`'s `quoteNormalizedCommandThunk`) so the
1000
+ * segmentation cost is paid at most once per event, on the common
1001
+ * (already-matched) path it costs nothing at all. Wiring landed in a
1002
+ * SECOND round of this task, after the orchestrator extended the first
1003
+ * round's file scope specifically for this one wiring point — the first
1004
+ * round shipped this function fully built and tested but genuinely
1005
+ * unwired (an inert security fix, called out explicitly as the wrong
1006
+ * shipped state in that round's own report) — `src/cli/dry-run.ts`'s own,
1007
+ * independently-copied matcher was deliberately NOT extended in the same
1008
+ * round (out of the extended scope too) and stayed a named residual until
1009
+ * task f561e44c closed it by adding the same fourth OR-arm there too — see
1010
+ * that file's own comment.
1011
+ *
1012
+ * THIS PASS'S OWN OVER-BLOCK SURFACE is measured and named in the test
1013
+ * file rather than assumed — and the measurement CORRECTS an earlier,
1014
+ * pre-measurement guess that used to sit in this comment: it does NOT
1015
+ * reproduce the `echo "a; VAR='x y' git push"` accepted over-block named
1016
+ * above. Quote-aware merging keeps that whole `echo "..."` segment
1017
+ * together, starting with the unrecognised head token `echo`, so this
1018
+ * function's output for it is byte-identical to the raw input
1019
+ * (`normalizeCommand`'s OWN, separate, unchanged-by-this-task match for
1020
+ * that same string is unaffected either way). Measured against a broader
1021
+ * adversarial probe, both at this function's own level and again at
1022
+ * `policyMatchesEvent`'s wired level (a bash-shim-verified corpus of
1023
+ * chained/prefixed/subshell-wrapped genuine invocations plus candidate
1024
+ * over-block shapes — entirely quoted `echo`/`printf` arguments that only
1025
+ * PRINT the spelling): every additional match this pass adds beyond raw /
1026
+ * `normalizeCommand` / `normalizeCommandAmpAware` is a TRUE positive (a
1027
+ * real invocation bash would actually execute); no NEW over-block class
1028
+ * distinct from `consumeAssignment`'s own, already-reviewed precision
1029
+ * limits was found. This function shares `canonicalizeSegment` /
1030
+ * `consumeAssignment` completely unchanged with the other passes — only
1031
+ * the segmentation boundary decision differs — which is also the
1032
+ * structural reason a longer (merged) segment can only REVEAL a match
1033
+ * those functions would already accept from a shorter view, never
1034
+ * fabricate a new kind of one; see the module header's cf3dff51 UPDATE
1035
+ * paragraph for the fuller statement of this reasoning and its
1036
+ * "not a formal proof" caveat.
1037
+ */
1038
+ export function normalizeCommandQuoteAware(command) {
1039
+ return normalizeWithGuards(command, (c) => segmentAndCanonicalize(c, BOUNDARY_RE, false, true).normalized);
1040
+ }
1041
+ /**
1042
+ * The shared segmentation + canonicalisation engine behind BOTH
1043
+ * normalisation passes (task aabbad63). Parameterised on the boundary
1044
+ * alphabet (`boundaryRe`) instead of closing over a single module-level
1045
+ * regex, so the primary pass (`BOUNDARY_RE`) and the ampersand-aware
1046
+ * second pass (`AMP_BOUNDARY_RE`) never share regex scan state: each
1047
+ * call is handed its OWN regex object, and `findNextBoundary` resets
1048
+ * that object's own `lastIndex` before every use, so the two passes
1049
+ * (even if a future change nested one inside the other, which nothing
1050
+ * here does today) can never observe or clobber each other's position.
1051
+ * `targetDir`/`targetBase` ARE still computed here regardless of which
1052
+ * alphabet drove the segmentation — `normalizeCommandAmpAware` below
1053
+ * simply never returns them to its own caller; see that function's
1054
+ * comment for why exposing them would be wrong under the amp alphabet
1055
+ * specifically. Fix round 1, finding F10: under the amp alphabet
1056
+ * specifically, ALL of this function's target bookkeeping
1057
+ * (`explicitTargets`, `explicitTargetBase`, `bareGitSegmentCount`,
1058
+ * `leadingCd`'s full `parseBashPrefix` pass, and the whole G1 ambiguity
1059
+ * resolution below) is knowingly computed and then discarded by
1060
+ * `normalizeCommandAmpAware` ITSELF, which returns only
1061
+ * `{ normalized, truncated }` — its own caller never sees these fields at
1062
+ * all. This is the containment design's cost, not an oversight, so a
1063
+ * future reader should not "fix" it by exposing these fields to a
1064
+ * hypothetical amp-alphabet consumer.
1065
+ *
1066
+ * `collectSegments` (task `98ad072f` groundwork, T-002): when `true`, ALSO
1067
+ * builds the per-segment view `segmentViewOf` returns (`CommandSegment[]`)
1068
+ * inside this SAME walk — no SECOND walk INSIDE this one function call,
1069
+ * just a bounded amount of extra work per segment already being visited
1070
+ * (see `computeSegmentTarget`). Scoped claim only (D-015 correction, fix
1071
+ * round, run 2026-08-02-per-repo-gate-scoping-redesign): this says nothing
1072
+ * about a CALLER that invokes this function twice with different
1073
+ * `collectSegments` values on the same string — see `segmentViewOf`'s own
1074
+ * doc comment, corrected in the same fix round, for the measured cost of
1075
+ * exactly that. `normalizeCommandInner` and
1076
+ * `normalizeCommandAmpAware` both pass `false`: the OLD aggregate never
1077
+ * needed this, and the amp-aware pass deliberately never exposes a segment
1078
+ * view at all (see that function's own comment). `segments` is `null` on
1079
+ * the returned object when `collectSegments` is `false`, an array
1080
+ * (possibly empty) otherwise.
1081
+ *
1082
+ * `quoteAware` (task cf3dff51, default `false` — every PRE-EXISTING call
1083
+ * site is unchanged, positionally omitting this parameter): selects
1084
+ * `findNextBoundaryQuoteAware` instead of `findNextBoundary` as the one
1085
+ * thing this walk asks to find the next segment split. Everything else in
1086
+ * this function — `canonicalizeSegment`, the target-directory bookkeeping,
1087
+ * the `collectSegments` per-segment view — is IDENTICAL either way; only
1088
+ * WHERE a segment ends changes. Only `normalizeCommandQuoteAware` passes
1089
+ * `true`; see that function's own comment for why this had to be a new
1090
+ * parameter on the SHARED walk rather than a wholly separate function
1091
+ * (reuse, same reasoning `AMP_BOUNDARY_RE`'s parameterisation on
1092
+ * `boundaryRe` already established) and for the HALT CRITERION that ruled
1093
+ * out editing `findNextBoundary` itself in place instead.
1094
+ */
1095
+ function segmentAndCanonicalize(command, boundaryRe, collectSegments, quoteAware = false) {
458
1096
  const parts = [];
459
1097
  const explicitTargets = new Set();
460
1098
  let explicitTargetBase = null;
@@ -462,6 +1100,14 @@ function normalizeCommandInner(command) {
462
1100
  let bareGitSegmentCount = 0;
463
1101
  let i = 0;
464
1102
  const n = command.length;
1103
+ // task `98ad072f` groundwork (T-002): the per-segment view's own,
1104
+ // independent bookkeeping — a running cd-basis (see
1105
+ // `computeSegmentTarget`) and the accumulated segments themselves.
1106
+ // Entirely separate from `explicitTargets` / `bareGitSegmentCount` /
1107
+ // `hasAmbiguousNonGitSegment` above, which stay exactly as they were:
1108
+ // this never reads or writes them, and they never read or write this.
1109
+ const segments = collectSegments ? [] : null;
1110
+ let cdBasis = null;
465
1111
  // A leading `cd <dir> &&|;` prefix, parsed ONCE up front (G1 fix,
466
1112
  // review round 2, 2026-07-27) instead of the two separate calls the
467
1113
  // size===1 and size===0 branches below each used to make on their own.
@@ -486,11 +1132,54 @@ function normalizeCommandInner(command) {
486
1132
  // least one boundary token's length each iteration a boundary is
487
1133
  // found, and the loop stops once none remain.
488
1134
  for (;;) {
489
- const boundary = findNextBoundary(command, i);
1135
+ const boundary = quoteAware
1136
+ ? findNextBoundaryQuoteAware(command, i, boundaryRe)
1137
+ : findNextBoundary(command, i, boundaryRe);
490
1138
  const segEnd = boundary ? boundary.start : n;
491
1139
  const segmentText = command.slice(i, segEnd);
492
1140
  const result = canonicalizeSegment(segmentText);
493
1141
  parts.push(result.text);
1142
+ if (segments !== null) {
1143
+ // task `98ad072f` groundwork (T-002): O(1) extra work per segment
1144
+ // already being visited — see `segmentAndCanonicalize`'s own doc
1145
+ // comment for why this is not a second pass over `command`.
1146
+ //
1147
+ // D-014 (fix round, run 2026-08-02-per-repo-gate-scoping-redesign),
1148
+ // two bash-truthfulness corrections applied around the call, not
1149
+ // inside `computeSegmentTarget` itself (both are about WHICH basis
1150
+ // crosses a BOUNDARY, not about one segment's own composition):
1151
+ //
1152
+ // - a running cd-basis never crosses a bare `|` — each side of a
1153
+ // pipeline is bash's own subshell, so a `cd` on one side cannot
1154
+ // affect the other (measured bypass: `cd <forged> | git push`
1155
+ // really runs `git push` at the real cwd, never `<forged>`).
1156
+ // The segment immediately after a `|` is handed `null` as its
1157
+ // incoming basis, as though nothing preceded it.
1158
+ // - the OUTGOING basis resets to `null` whenever THIS segment's
1159
+ // own text contains a `)` (a subshell close riding along after
1160
+ // `BOUNDARY_RE`'s bare `(` boundary — this alphabet has no `)`
1161
+ // boundary of its own, see its header comment — so a subshell's
1162
+ // `cd` genuinely stops applying once the subshell exits: `(cd
1163
+ // <forged> ; echo hi) && git push` must not let `<forged>` reach
1164
+ // `git push`). THIS segment's own `effectiveTarget` is computed
1165
+ // from the UNCHANGED incoming basis first — a segment ending in
1166
+ // `)` (e.g. the `git status)` tail of `(cd A && git status)`)
1167
+ // still correctly attributes to A for itself, exactly as it
1168
+ // should while still inside the subshell; only what carries
1169
+ // FORWARD to the next segment is affected. A simple substring
1170
+ // check, not paren-depth tracking: conservative, precision-only
1171
+ // under D-011 (see `computeSegmentTarget`'s own D-014 note) — an
1172
+ // over-eager reset here can only make a later segment fall back
1173
+ // to cwd-only, never a fail-open.
1174
+ const incomingBasis = precedingBoundaryToken === "|" ? null : cdBasis;
1175
+ const seg = computeSegmentTarget(segmentText, result, incomingBasis);
1176
+ segments.push({
1177
+ text: result.text,
1178
+ ownTarget: seg.ownTarget,
1179
+ effectiveTarget: seg.effectiveTarget,
1180
+ });
1181
+ cdBasis = segmentText.includes(")") ? null : seg.outgoingCdBasis;
1182
+ }
494
1183
  if (result.isGit) {
495
1184
  if (result.targetDir === null) {
496
1185
  bareGitSegmentCount += 1;
@@ -585,14 +1274,251 @@ function normalizeCommandInner(command) {
585
1274
  // push-gate fail-open this closes).
586
1275
  targetDir = null;
587
1276
  }
588
- return { normalized: parts.join(""), targetDir, targetBase, truncated: false };
1277
+ return { normalized: parts.join(""), targetDir, targetBase, segments };
589
1278
  }
590
- /** Find the earliest shell boundary token at or after `from`. Returns null when none remain. */
591
- function findNextBoundary(s, from) {
592
- BOUNDARY_RE.lastIndex = from;
593
- const m = BOUNDARY_RE.exec(s);
1279
+ /**
1280
+ * Find the earliest shell boundary token at or after `from`, under the
1281
+ * CALLER-SUPPLIED boundary alphabet. Takes `boundaryRe` as an explicit
1282
+ * parameter rather than closing over a single module-level regex (task
1283
+ * aabbad63) so the primary pass (`BOUNDARY_RE`) and the ampersand-aware
1284
+ * second pass (`AMP_BOUNDARY_RE`) each carry their own `lastIndex` state.
1285
+ *
1286
+ * Fix round 1, finding F8 (corrects the prior wording here): resetting
1287
+ * `lastIndex = from` on every call is NOT what stops the two passes from
1288
+ * clobbering each other's scan position — they are two DISTINCT regex
1289
+ * objects, so there is nothing shared for either to clobber, and
1290
+ * `segmentAndCanonicalize`'s own loop always calls this with `from`
1291
+ * already equal to where the previous match's `lastIndex` would have
1292
+ * landed anyway. That is why removing this reset measured 0 output
1293
+ * differences across the corpus and left the suite green (see
1294
+ * `tests/runtime/command-normalize.test.ts`'s F8 note — the mutation was
1295
+ * applied and reverted to confirm this, not merely reasoned about). The
1296
+ * line is still a legitimate guard, just for a DIFFERENT reason:
1297
+ * `BOUNDARY_RE` and `AMP_BOUNDARY_RE` are module-level `/g` objects
1298
+ * shared by every call in the process, so their `lastIndex` outlives any
1299
+ * single scan. If `canonicalizeSegment` THROWS partway through a scan,
1300
+ * the top-level `catch` in `normalizeCommand` /
1301
+ * `normalizeCommandAmpAware` returns a fallback but leaves that shared
1302
+ * object's `lastIndex` wherever the last successful match landed — and
1303
+ * the NEXT call, on a completely different command, would then start
1304
+ * scanning from that stale offset. This reset re-anchors it.
1305
+ *
1306
+ * DO NOT DELETE THIS LINE ON THE STRENGTH OF ITS MUTATION-INERTNESS.
1307
+ * Measured (fix round 2): with the reset removed and a throw forced into
1308
+ * `canonicalizeSegment`, the FOLLOWING call to
1309
+ * `normalizeCommandAmpAware("a | env git push origin master")` returned
1310
+ * the un-normalised string, and `preflight-before-push` went from MATCH
1311
+ * to NO-MATCH. That is a gate-losing fail-open, not a stylistic nit.
1312
+ * The honest caveat, stated rather than implied: no throw is KNOWN to be
1313
+ * reachable from real input — the leak above was demonstrated with a
1314
+ * forced throw, which is why the line measures inert. The module's own
1315
+ * top-level `catch` exists because a throw is treated as possible, and
1316
+ * this reset is the other half of that same posture.
1317
+ */
1318
+ function findNextBoundary(s, from, boundaryRe) {
1319
+ boundaryRe.lastIndex = from;
1320
+ const m = boundaryRe.exec(s);
594
1321
  return m === null ? null : { start: m.index, token: m[0] };
595
1322
  }
1323
+ /**
1324
+ * Quote-aware sibling of `findNextBoundary` (task cf3dff51): the earliest
1325
+ * boundary token at or after `from` that sits OUTSIDE any open single- or
1326
+ * double-quoted run, under the SAME `boundaryRe` alphabet the caller
1327
+ * already scans with — no new boundary characters, only quote-tracking
1328
+ * around the existing ones. `null` when no such boundary exists (either
1329
+ * none remain at all, or every remaining candidate sits inside quoting
1330
+ * that never closes before the string ends — the same conservative "no
1331
+ * further split" fallback `consumeAssignment`'s own unterminated-quote
1332
+ * case already uses, restated here for whole-string segmentation instead
1333
+ * of one token).
1334
+ *
1335
+ * TWO-PHASE, NOT a per-character regex probe (task cf3dff51, latency fix
1336
+ * round — this replaces this function's first shipped implementation,
1337
+ * which called a sticky-regex `.exec()` at EVERY character position;
1338
+ * measured ~2x `normalizeCommand`'s own cost on a large, quote-free
1339
+ * command, because a JS-to-native regex-engine round trip at every single
1340
+ * character is far more expensive than the SAME regex engine's own
1341
+ * internal forward scan across many characters in one call — exactly what
1342
+ * `findNextBoundary` already does for the primary, non-quote-aware pass).
1343
+ * This version instead COMPOSES `findNextBoundary` (unchanged, still the
1344
+ * quote-UNAWARE fast forward scanner) with `QuoteScanner` (below): repeat
1345
+ * — find the next quote-unaware CANDIDATE boundary via `findNextBoundary`
1346
+ * (one native regex scan, however far away the candidate is), then walk
1347
+ * ONLY the span between the previous position and that candidate,
1348
+ * character-by-character, through `QuoteScanner` (below) to learn whether
1349
+ * a quote is open at the candidate's position; if not, the candidate IS
1350
+ * the real boundary — return it; if so, the candidate is inside a quote
1351
+ * (exactly the `VAR='a; b' git push` shape this pass exists to see
1352
+ * through), step through the candidate token's own characters too (never
1353
+ * quote-relevant — `BOUNDARY_RE`'s alphabet contains no `'`/`"`/`\` — so
1354
+ * this is a correctness no-op, only here so the running quote/escape
1355
+ * state stays byte-for-byte identical to a full character-by-character
1356
+ * walk) and continue the search from just past it. For a typical command
1357
+ * (few boundary characters, most of them outside any quote) this pays
1358
+ * for ONE `findNextBoundary` native scan per REAL segment plus one plain
1359
+ * (non-regex) character walk of the same total length `findNextBoundary`
1360
+ * would have scanned internally anyway — no longer one regex call per
1361
+ * character. `null` when no such boundary exists (either none remain at
1362
+ * all — `findNextBoundary` itself returns `null` — or every remaining
1363
+ * candidate sits inside quoting that never closes before the string ends,
1364
+ * the same conservative "no further split" fallback `consumeAssignment`'s
1365
+ * own unterminated-quote case already uses, restated here for whole-
1366
+ * string segmentation instead of one token).
1367
+ *
1368
+ * `QuoteScanner` (below) is the SAME quote/escape state machine
1369
+ * `consumeAssignment`'s own per-token loop uses (three states — `""`,
1370
+ * `'`, `"` — same POSIX approximation: outside quotes `\` escapes the
1371
+ * next char, `'...'` closes only at an UNESCAPED-by-construction `'`
1372
+ * (single quotes never honour backslash escapes at all), `"..."` closes
1373
+ * at an unescaped `"`). Extracted into a shared class (task cf3dff51 fix
1374
+ * round: `npm run check:duplication`'s jscpd scan flagged the two
1375
+ * independently-written copies of this loop as new duplication against
1376
+ * the pinned baseline; see that script's own header comment for why
1377
+ * extraction, not raising the pin, is the stated preference) rather than
1378
+ * duplicated a second time. Generalised from per-token to whole-string
1379
+ * scope by THIS function, not by the scanner itself: `consumeAssignment`
1380
+ * only ever sees one already-whitespace-tokenised segment at a time; this
1381
+ * function walks the RAW command text directly, whitespace included, so
1382
+ * it can tell whether a `;`/`|`/`&&`/`(`/`\n` character sits inside a
1383
+ * quote that a later whitespace-delimited token will re-enter (exactly
1384
+ * the shape `VAR='a; b' git push` needs: the space after `;` is itself
1385
+ * INSIDE the quoted run, not a token boundary this function cares about
1386
+ * at all — only the eventual UNQUOTED position matters).
1387
+ *
1388
+ * Reuses the MODULE-LEVEL `BOUNDARY_QUOTE_SCANNER` instance (`.reset()`
1389
+ * at the top of each call — see that constant's and `reset()`'s own
1390
+ * comments) instead of allocating a fresh `QuoteScanner` per call, and
1391
+ * calls `findNextBoundary` with the CALLER-SUPPLIED `boundaryRe` object
1392
+ * directly rather than a sticky clone — `findNextBoundary` already resets
1393
+ * `lastIndex` itself on every call (its own comment documents exactly
1394
+ * this discipline for the primary pass sharing the SAME `BOUNDARY_RE`
1395
+ * object), so composing it here needs no extra care. Called once per
1396
+ * segment (not once per character) by `segmentAndCanonicalize`'s loop;
1397
+ * the character walk itself still visits each character in the
1398
+ * `[from, end-of-match-or-string)` span exactly once IN TOTAL across all
1399
+ * calls for one command (split across the phase-1 "verify" loop and the
1400
+ * phase-2 "step through the false candidate" loop, never both for the
1401
+ * same character), keeping the whole walk O(length), same complexity
1402
+ * class `findNextBoundary`'s own comment establishes for the primary
1403
+ * pass — this rewrite changes the CONSTANT factor, not the asymptotic
1404
+ * class.
1405
+ */
1406
+ function findNextBoundaryQuoteAware(s, from, boundaryRe) {
1407
+ BOUNDARY_QUOTE_SCANNER.reset();
1408
+ let pos = from;
1409
+ const n = s.length;
1410
+ for (;;) {
1411
+ const candidate = findNextBoundary(s, pos, boundaryRe);
1412
+ if (candidate === null)
1413
+ return null;
1414
+ // Phase 1: verify quote-safety by walking ONLY the span strictly
1415
+ // BEFORE the candidate — mirrors the original per-character scan,
1416
+ // which never steps through the boundary token's own characters
1417
+ // before deciding whether to return it.
1418
+ for (let i = pos; i < candidate.start; i++) {
1419
+ BOUNDARY_QUOTE_SCANNER.step(s[i]);
1420
+ }
1421
+ if (BOUNDARY_QUOTE_SCANNER.state === "") {
1422
+ return candidate;
1423
+ }
1424
+ // Phase 2: the candidate sits inside an open quote — not a real
1425
+ // boundary. Step through ITS OWN characters too (a correctness
1426
+ // no-op, see this function's own comment) so the running state stays
1427
+ // exactly what a full character-by-character walk would have
1428
+ // produced, then resume the search just past it.
1429
+ const tokenEnd = candidate.start + candidate.token.length;
1430
+ for (let i = candidate.start; i < tokenEnd; i++) {
1431
+ BOUNDARY_QUOTE_SCANNER.step(s[i]);
1432
+ }
1433
+ if (tokenEnd >= n)
1434
+ return null;
1435
+ pos = tokenEnd;
1436
+ }
1437
+ }
1438
+ /**
1439
+ * Mutable single-/double-quote + backslash-escape state machine (task
1440
+ * cf3dff51 extraction — see `findNextBoundaryQuoteAware`'s own comment for
1441
+ * why this was pulled out of two independently-written copies rather than
1442
+ * left duplicated). POSIX-approximated semantics, shared verbatim by both
1443
+ * consumers: outside quotes `\` escapes the next character; `'...'`
1444
+ * closes only at a literal `'` (single quotes never honour backslash
1445
+ * escapes — the `escaped` branch below is unreachable whenever
1446
+ * `state === "'"`, by construction: `step` checks `this.escaped` BEFORE
1447
+ * checking `this.state`, so an escape flagged one character earlier is
1448
+ * always consumed first, but nothing inside the `state === "'"` branch
1449
+ * itself ever SETS `escaped`, so it can never observe a `\` as anything
1450
+ * other than a literal character); `"..."` closes at an unescaped `"`. A
1451
+ * mutable class rather than a pure step function returning a new state
1452
+ * object: this module runs on every Bash/Edit/Write tool call (module
1453
+ * header) and both consumers step through EVERY character of a segment or
1454
+ * token, so one allocation per scan (not one per character) matters for
1455
+ * the same "allocation-light" reason the module header states elsewhere.
1456
+ */
1457
+ class QuoteScanner {
1458
+ state = "";
1459
+ escaped = false;
1460
+ /** Advance the state machine by exactly one character. */
1461
+ step(c) {
1462
+ if (this.escaped) {
1463
+ this.escaped = false;
1464
+ return;
1465
+ }
1466
+ if (this.state === "'") {
1467
+ if (c === "'")
1468
+ this.state = "";
1469
+ return;
1470
+ }
1471
+ if (this.state === '"') {
1472
+ if (c === "\\")
1473
+ this.escaped = true;
1474
+ else if (c === '"')
1475
+ this.state = "";
1476
+ return;
1477
+ }
1478
+ // state === ""
1479
+ if (c === "\\")
1480
+ this.escaped = true;
1481
+ else if (c === "'" || c === '"')
1482
+ this.state = c;
1483
+ }
1484
+ /**
1485
+ * Reset to the initial `""` / not-escaped state — behaviourally
1486
+ * IDENTICAL to discarding this instance and constructing a fresh one
1487
+ * (task cf3dff51, latency fix round: lets `findNextBoundaryQuoteAware`
1488
+ * reuse ONE module-level instance across every segment of a command
1489
+ * instead of allocating a new one per segment — see that function's own
1490
+ * comment for the measured latency this closes).
1491
+ */
1492
+ reset() {
1493
+ this.state = "";
1494
+ this.escaped = false;
1495
+ }
1496
+ /**
1497
+ * Clear a pending escape without consuming a character. Used only by
1498
+ * `consumeAssignment`, at a whitespace-tokenised TOKEN boundary: a
1499
+ * backslash pending at a token's end would otherwise escape the
1500
+ * separating whitespace itself (task b093911d's class, deliberately
1501
+ * NOT continued — see that function's own comment). `state` is never
1502
+ * touched by this — an open quote genuinely stays open across
1503
+ * whitespace, only a dangling escape does not.
1504
+ */
1505
+ resetEscape() {
1506
+ this.escaped = false;
1507
+ }
1508
+ }
1509
+ /**
1510
+ * Module-level `QuoteScanner` instance reused by `findNextBoundaryQuoteAware`
1511
+ * across every segment of every command (task cf3dff51, latency fix
1512
+ * round) — `.reset()` at the top of each call makes reusing this single
1513
+ * instance behaviourally IDENTICAL to `new QuoteScanner()` per call (see
1514
+ * `reset()`'s own doc comment), just without the allocation. Placed here,
1515
+ * textually AFTER `class QuoteScanner`, because a top-level `const`
1516
+ * initialiser referencing a class runs in real module-evaluation order —
1517
+ * `findNextBoundaryQuoteAware` itself sits ABOVE this point in the file
1518
+ * but is a hoisted function declaration whose BODY only runs when called,
1519
+ * by which time this line has already executed.
1520
+ */
1521
+ const BOUNDARY_QUOTE_SCANNER = new QuoteScanner();
596
1522
  /**
597
1523
  * Consume one leading `VAR=value` assignment starting at `idx`, returning
598
1524
  * the index of the first token AFTER it. Task 13e55484: the VALUE may be
@@ -653,40 +1579,19 @@ function findNextBoundary(s, from) {
653
1579
  * pinned.
654
1580
  */
655
1581
  function consumeAssignment(tokens, idx) {
656
- let state = "";
657
- let escaped = false;
1582
+ const quote = new QuoteScanner();
658
1583
  for (let i = idx; i < tokens.length; i++) {
659
1584
  const text = tokens[i].text;
660
1585
  if (i > idx && (GIT_TOKEN_RE.test(text) || NON_GIT_HEAD_TOKENS.has(text))) {
661
1586
  return idx + 1;
662
1587
  }
663
1588
  for (let k = 0; k < text.length; k++) {
664
- const c = text[k];
665
- if (escaped) {
666
- escaped = false;
667
- continue;
668
- }
669
- if (state === "'") {
670
- if (c === "'")
671
- state = "";
672
- continue;
673
- }
674
- if (state === '"') {
675
- if (c === "\\")
676
- escaped = true;
677
- else if (c === '"')
678
- state = "";
679
- continue;
680
- }
681
- if (c === "\\")
682
- escaped = true;
683
- else if (c === "'" || c === '"')
684
- state = c;
1589
+ quote.step(text[k]);
685
1590
  }
686
1591
  // A backslash pending at a token's end would escape the separating
687
1592
  // whitespace itself — b093911d's class, not continued here.
688
- escaped = false;
689
- if (state === "")
1593
+ quote.resetEscape();
1594
+ if (quote.state === "")
690
1595
  return i + 1;
691
1596
  }
692
1597
  // Ran out of tokens with a quote still open: unterminated. Preserve the
@@ -723,7 +1628,14 @@ function tokenizeWithOffsets(s) {
723
1628
  function canonicalizeSegment(segmentText) {
724
1629
  const tokens = tokenizeWithOffsets(segmentText);
725
1630
  if (tokens.length === 0) {
726
- return { text: segmentText, targetDir: null, targetBase: null, isGit: false };
1631
+ return {
1632
+ text: segmentText,
1633
+ targetDir: null,
1634
+ targetBase: null,
1635
+ identityTargetDir: null,
1636
+ identityTargetBase: null,
1637
+ isGit: false,
1638
+ };
727
1639
  }
728
1640
  let idx = 0;
729
1641
  let envTargetDir = null;
@@ -783,7 +1695,14 @@ function canonicalizeSegment(segmentText) {
783
1695
  }
784
1696
  const headTok = tokens[idx]?.text;
785
1697
  if (headTok === undefined) {
786
- return { text: segmentText, targetDir: null, targetBase: null, isGit: false };
1698
+ return {
1699
+ text: segmentText,
1700
+ targetDir: null,
1701
+ targetBase: null,
1702
+ identityTargetDir: null,
1703
+ identityTargetBase: null,
1704
+ isGit: false,
1705
+ };
787
1706
  }
788
1707
  if (GIT_TOKEN_RE.test(headTok)) {
789
1708
  idx += 1; // consume the git token (whatever spelling matched GIT_TOKEN_RE)
@@ -791,7 +1710,14 @@ function canonicalizeSegment(segmentText) {
791
1710
  if (gitOpts.malformed || gitOpts.idx >= tokens.length) {
792
1711
  // Malformed global option (missing required value) or no subcommand
793
1712
  // token left: nothing safe to canonicalise. Leave untouched.
794
- return { text: segmentText, targetDir: null, targetBase: null, isGit: false };
1713
+ return {
1714
+ text: segmentText,
1715
+ targetDir: null,
1716
+ targetBase: null,
1717
+ identityTargetDir: null,
1718
+ identityTargetBase: null,
1719
+ isGit: false,
1720
+ };
795
1721
  }
796
1722
  const subcommandTok = tokens[gitOpts.idx];
797
1723
  const rewritten = `git ${subcommandTok.text}${segmentText.slice(subcommandTok.end)}`;
@@ -805,7 +1731,50 @@ function canonicalizeSegment(segmentText) {
805
1731
  !path.isAbsolute(gitOpts.targetDir)
806
1732
  ? envTargetDir
807
1733
  : null;
808
- return { text: rewritten, targetDir, targetBase, isGit: true };
1734
+ // D-017: the identity pair mirrors targetDir/targetBase's composition
1735
+ // exactly, but is fed by `relocateTargetDir` (repo-relocating flags
1736
+ // only — `-C`/`--git-dir`, never `--work-tree`) instead of
1737
+ // `gitOpts.targetDir` (which still includes `--work-tree`).
1738
+ //
1739
+ // D-018 (fix round 3): a same-invocation wrapping `env -C`/`--chdir`
1740
+ // PLUS the git invocation's OWN absolute repo-relocating flag are two
1741
+ // repo-relocating options on one invocation, the same "more than one"
1742
+ // class `noteRelocatingOption` (in `peelGitGlobalOptions`) closes for
1743
+ // git's own flags — extended here across the `env -C` wrap. When they
1744
+ // diverge (different resolved directories), git's own flag DOES
1745
+ // deterministically win in reality, but this module deliberately does
1746
+ // not encode that composition rule (`computeSegmentTarget`'s own
1747
+ // comment on why path composition is out of scope) — `null` (cwd
1748
+ // fallback, D-003) rather than keep preferring git's own flag as
1749
+ // before this fix. An identical pair (`env -C X git -C X ...`) is the
1750
+ // same idempotent-repeat allowance as `noteRelocatingOption`'s single-
1751
+ // function case, so it stays `X`, not `null`. The pre-existing
1752
+ // RELATIVE-own-target + env-base mix is untouched by this check —
1753
+ // `identityTargetBase` below already resolves that class fully to
1754
+ // `null` via `computeSegmentTarget`'s early return; this new check
1755
+ // only has any effect when `gitOpts.relocateTargetDir` is ABSOLUTE
1756
+ // (`path.isAbsolute` guards it out for the relative case, the same
1757
+ // condition `identityTargetBase` below already uses).
1758
+ const relocateEnvDiverges = gitOpts.relocateTargetDir !== null &&
1759
+ envTargetDir !== null &&
1760
+ path.isAbsolute(gitOpts.relocateTargetDir) &&
1761
+ gitOpts.relocateTargetDir !== envTargetDir;
1762
+ const identityTargetDir = relocateEnvDiverges
1763
+ ? null
1764
+ : (gitOpts.relocateTargetDir ?? envTargetDir);
1765
+ const identityTargetBase = gitOpts.relocateTargetDir !== null &&
1766
+ envTargetDir !== null &&
1767
+ !path.isAbsolute(gitOpts.relocateTargetDir)
1768
+ ? envTargetDir
1769
+ : null;
1770
+ return {
1771
+ text: rewritten,
1772
+ targetDir,
1773
+ targetBase,
1774
+ identityTargetDir,
1775
+ identityTargetBase,
1776
+ isGit: true,
1777
+ };
809
1778
  }
810
1779
  if (NON_GIT_HEAD_TOKENS.has(headTok)) {
811
1780
  // D-001 + fix round 2 (finding F2): `gh` / `npm` / `harness` get
@@ -836,15 +1805,36 @@ function canonicalizeSegment(segmentText) {
836
1805
  // just the head would silently drop whatever wrapper preceded it for
837
1806
  // no benefit. Leave untouched, same fail-safe shape as the git
838
1807
  // malformed/no-subcommand case above.
839
- return { text: segmentText, targetDir: null, targetBase: null, isGit: false };
1808
+ return {
1809
+ text: segmentText,
1810
+ targetDir: null,
1811
+ targetBase: null,
1812
+ identityTargetDir: null,
1813
+ identityTargetBase: null,
1814
+ isGit: false,
1815
+ };
840
1816
  }
841
1817
  const rewritten = tokens
842
1818
  .slice(idx)
843
1819
  .map((t) => t.text)
844
1820
  .join(" ");
845
- return { text: rewritten, targetDir: null, targetBase: null, isGit: false };
1821
+ return {
1822
+ text: rewritten,
1823
+ targetDir: null,
1824
+ targetBase: null,
1825
+ identityTargetDir: null,
1826
+ identityTargetBase: null,
1827
+ isGit: false,
1828
+ };
846
1829
  }
847
- return { text: segmentText, targetDir: null, targetBase: null, isGit: false };
1830
+ return {
1831
+ text: segmentText,
1832
+ targetDir: null,
1833
+ targetBase: null,
1834
+ identityTargetDir: null,
1835
+ identityTargetBase: null,
1836
+ isGit: false,
1837
+ };
848
1838
  }
849
1839
  /**
850
1840
  * Peel `env`'s own leading flags and `VAR=value` assignments. The flag-
@@ -1097,36 +2087,121 @@ function parentIfDotGit(dir) {
1097
2087
  * adjacent to `git`), tracking the first `-C` / `--work-tree` /
1098
2088
  * `--git-dir` target directory encountered (excluding a `~`-prefixed
1099
2089
  * value — F5, treated as if absent, but still consumed as an option so
1100
- * parsing continues past it). `malformed: true` means a value-requiring
1101
- * option was missing its value — the caller bails without rewriting
1102
- * rather than guess.
2090
+ * parsing continues past it) as `targetDir` — the OLD, UNCONSUMED
2091
+ * aggregate's own extraction (module header STATUS note), UNCHANGED by
2092
+ * the fix below. `malformed: true` means a value-requiring option was
2093
+ * missing its value — the caller bails without rewriting rather than
2094
+ * guess.
2095
+ *
2096
+ * `relocateTargetDir` (D-017, fix round 2, run `2026-08-02-per-repo-
2097
+ * gate-scoping-redesign`): a SEPARATE tracker, fed ONLY by the
2098
+ * repo-relocating flags `-C` and `--git-dir` — NEVER `--work-tree`.
2099
+ * `--work-tree` sets the working tree but does NOT relocate git's
2100
+ * `--git-dir` search; `git push`/`log`/`status` under `--work-tree=<B>`
2101
+ * alone still operate on whatever repo the CURRENT directory (or an
2102
+ * actual `-C`/`--git-dir`) names, not `<B>`. Pass-2 review measured this
2103
+ * as a live cross-repo fail-open once `--work-tree` was folded into the
2104
+ * same `targetDir` bucket as `-C`/`--git-dir` and that bucket fed
2105
+ * `CommandSegment.ownTarget`'s REPLACE attribution (`computeSegmentTarget`
2106
+ * below, `src/runtime/intercept.ts`'s `resolveAttributedContexts`,
2107
+ * D-011): `git --work-tree=<B> push`, cwd = A, was attributed to B alone,
2108
+ * dropping the cwd demand a real `git push` there still has to satisfy.
2109
+ * `relocateTargetDir` is what `computeSegmentTarget` now consults for a
2110
+ * git segment's `ownTarget` — see that function and `canonicalizeSegment`
2111
+ * below. Kept as a distinct field (a whitelist of repo-relocating flags),
2112
+ * not a `--work-tree` special-case, so a future path-valued-but-repo-
2113
+ * neutral git global option cannot silently reopen this class by being
2114
+ * folded into `targetDir` the way `--work-tree` originally was.
2115
+ *
2116
+ * D-018 (fix round 3, pass-3 CRITICAL): `relocateTargetDir` is set ONLY
2117
+ * when the invocation carries EXACTLY ONE recognised, fully-resolved
2118
+ * repo-relocating option (a single `-C` or a single `--git-dir`/
2119
+ * `--git-dir=`). Pass-3 review measured (against real git 2.34.1) that
2120
+ * `git -C <forge> -C <realA> push` attributes to the FIRST `-C` here
2121
+ * while git composes ALL of them cumulatively and actually operates in
2122
+ * `<realA>` — the same first-token-wins gap spans `-C` combined with
2123
+ * `--git-dir`. `noteRelocatingOption` below counts every RESOLVED
2124
+ * (non-`~`) occurrence of either flag across the whole invocation; the
2125
+ * second (or later) occurrence forces `relocateTargetDir` to `null` and
2126
+ * LOCKS it there for the rest of this call — UNLESS its value is an
2127
+ * exact repeat of the one already recorded (idempotent, e.g. `-C X -C
2128
+ * X`), which is not new information and does not trigger the lock. This
2129
+ * is a deliberate under-approximation of git's real `-C`-chains-relative-
2130
+ * to-the-previous-`-C` composition rule (the module has never encoded
2131
+ * that, by design — see `computeSegmentTarget`'s own comment on why
2132
+ * composing paths is not this module's job): ANY invocation this module
2133
+ * cannot resolve to a single, unambiguous flag falls back to `null` here,
2134
+ * which downstream (`computeSegmentTarget`) means "no own target" and the
2135
+ * cwd context is demanded instead — exactly the shipped, pre-this-run
2136
+ * semantics, never a forged first-token guess.
2137
+ *
2138
+ * D-021 (UNIVERSAL-ADDITIVE, operator decision, run 2026-08-02-per-repo-
2139
+ * gate-scoping-redesign): both the D-017 `--work-tree` exclusion and the
2140
+ * D-018 multi-option lock above were originally the SAFETY mechanism
2141
+ * (the thing standing between an attacker-composed invocation and a
2142
+ * forged REPLACE attribution); under D-021, `src/runtime/intercept.ts`'s
2143
+ * `resolveAttributedContexts` never replaces the cwd demand at all, so
2144
+ * neither exclusion can be bypassed into a weaker-than-shipped gate any
2145
+ * more — the cwd demand is never dropped regardless of what
2146
+ * `relocateTargetDir` resolves to. Both stay in place as PRECISION
2147
+ * (avoiding a spurious extra demand for an ambiguous or non-relocating
2148
+ * target), not as the security boundary; see `resolveAttributedContexts`
2149
+ * itself for where that boundary now lives.
1103
2150
  */
1104
2151
  function peelGitGlobalOptions(tokens, startIdx) {
1105
2152
  let idx = startIdx;
1106
2153
  let targetDir = null;
2154
+ let relocateTargetDir = null;
2155
+ let relocateResolvedCount = 0;
2156
+ let relocateAmbiguous = false;
2157
+ // D-018: record one repo-relocating flag's resolved value (`null` for a
2158
+ // `~`-prefixed value, which — same as the pre-fix code — never sets or
2159
+ // counts against `relocateTargetDir` at all, D-003's "treated as if
2160
+ // absent"). The SECOND (or later) resolved occurrence nulls and LOCKS
2161
+ // `relocateTargetDir`, unless it is an exact repeat of the value
2162
+ // already recorded (idempotent, e.g. `-C X -C X` or `-C X --git-dir
2163
+ // X/.git`), which stays a single value rather than tripping the lock —
2164
+ // "im Zweifel null" still applies to every OTHER two-or-more case.
2165
+ function noteRelocatingOption(resolved) {
2166
+ if (resolved === null)
2167
+ return;
2168
+ relocateResolvedCount += 1;
2169
+ if (relocateAmbiguous)
2170
+ return;
2171
+ if (relocateResolvedCount === 1) {
2172
+ relocateTargetDir = resolved;
2173
+ return;
2174
+ }
2175
+ if (resolved === relocateTargetDir)
2176
+ return; // idempotent repeat
2177
+ relocateTargetDir = null;
2178
+ relocateAmbiguous = true;
2179
+ }
1107
2180
  while (idx < tokens.length) {
1108
2181
  const t = tokens[idx].text;
1109
2182
  if (t === "-C") {
1110
2183
  const dir = tokens[idx + 1]?.text;
1111
2184
  if (dir === undefined)
1112
- return { idx, targetDir, malformed: true };
2185
+ return { idx, targetDir, relocateTargetDir, malformed: true };
1113
2186
  if (targetDir === null && !isTildeTarget(dir))
1114
2187
  targetDir = dir;
2188
+ noteRelocatingOption(isTildeTarget(dir) ? null : dir);
1115
2189
  idx += 2;
1116
2190
  continue;
1117
2191
  }
1118
2192
  if (t === "-c") {
1119
2193
  if (tokens[idx + 1] === undefined)
1120
- return { idx, targetDir, malformed: true };
2194
+ return { idx, targetDir, relocateTargetDir, malformed: true };
1121
2195
  idx += 2;
1122
2196
  continue;
1123
2197
  }
1124
2198
  if (t === "--git-dir") {
1125
2199
  const dir = tokens[idx + 1]?.text;
1126
2200
  if (dir === undefined)
1127
- return { idx, targetDir, malformed: true };
2201
+ return { idx, targetDir, relocateTargetDir, malformed: true };
1128
2202
  if (targetDir === null && !isTildeTarget(dir))
1129
2203
  targetDir = parentIfDotGit(dir);
2204
+ noteRelocatingOption(isTildeTarget(dir) ? null : parentIfDotGit(dir));
1130
2205
  idx += 2;
1131
2206
  continue;
1132
2207
  }
@@ -1134,13 +2209,17 @@ function peelGitGlobalOptions(tokens, startIdx) {
1134
2209
  const dir = t.slice("--git-dir=".length);
1135
2210
  if (targetDir === null && !isTildeTarget(dir))
1136
2211
  targetDir = parentIfDotGit(dir);
2212
+ noteRelocatingOption(isTildeTarget(dir) ? null : parentIfDotGit(dir));
1137
2213
  idx += 1;
1138
2214
  continue;
1139
2215
  }
1140
2216
  if (t === "--work-tree") {
2217
+ // D-017: `--work-tree` still feeds the OLD `targetDir` aggregate
2218
+ // (unchanged, unconsumed by any gate) but deliberately NEVER
2219
+ // `relocateTargetDir` — see this function's own doc comment.
1141
2220
  const dir = tokens[idx + 1]?.text;
1142
2221
  if (dir === undefined)
1143
- return { idx, targetDir, malformed: true };
2222
+ return { idx, targetDir, relocateTargetDir, malformed: true };
1144
2223
  if (targetDir === null && !isTildeTarget(dir))
1145
2224
  targetDir = dir;
1146
2225
  idx += 2;
@@ -1163,7 +2242,7 @@ function peelGitGlobalOptions(tokens, startIdx) {
1163
2242
  }
1164
2243
  if (t === "--namespace") {
1165
2244
  if (tokens[idx + 1] === undefined)
1166
- return { idx, targetDir, malformed: true };
2245
+ return { idx, targetDir, relocateTargetDir, malformed: true };
1167
2246
  idx += 2;
1168
2247
  continue;
1169
2248
  }
@@ -1177,6 +2256,6 @@ function peelGitGlobalOptions(tokens, startIdx) {
1177
2256
  }
1178
2257
  break;
1179
2258
  }
1180
- return { idx, targetDir, malformed: false };
2259
+ return { idx, targetDir, relocateTargetDir, malformed: false };
1181
2260
  }
1182
2261
  //# sourceMappingURL=command-normalize.js.map