session-orchestrator 3.17.0 → 3.20.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 (221) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/rules/030-wave-execution.mdc +17 -1
  5. package/CHANGELOG.md +185 -412
  6. package/README.md +12 -9
  7. package/SECURITY.md +190 -27
  8. package/agents/AGENTS.md +20 -3
  9. package/agents/code-implementer.md +6 -6
  10. package/agents/db-specialist.md +1 -1
  11. package/agents/qa-strategist.md +31 -6
  12. package/agents/schemas/qa-strategist.schema.json +27 -0
  13. package/agents/schemas/test-writer.schema.json +60 -2
  14. package/agents/security-reviewer.md +1 -1
  15. package/agents/session-reviewer.md +1 -1
  16. package/agents/test-writer.md +29 -10
  17. package/agents/ui-developer.md +1 -1
  18. package/commands/contract-version-bump.md +28 -0
  19. package/commands/portfolio.md +1 -1
  20. package/commands/session.md +6 -2
  21. package/docs/USER-GUIDE.md +9 -4
  22. package/docs/ci-setup.md +121 -7
  23. package/docs/codex-setup.md +1 -1
  24. package/docs/components.md +6 -6
  25. package/docs/cursor-setup.md +22 -9
  26. package/docs/events-schema.md +5 -1
  27. package/docs/instruction-delivery.md +794 -0
  28. package/docs/rule-authoring.md +58 -9
  29. package/docs/session-config-reference.md +245 -50
  30. package/docs/session-config-template.md +39 -26
  31. package/hooks/_lib/guard-source-loader.mjs +680 -0
  32. package/hooks/_lib/lock-bootstrap.mjs +21 -0
  33. package/hooks/_lib/vcs-create-matcher.mjs +119 -0
  34. package/hooks/config-protection.mjs +0 -0
  35. package/hooks/enforce-commands.mjs +226 -19
  36. package/hooks/enforce-scope.mjs +133 -9
  37. package/hooks/hooks-codex.json +1 -1
  38. package/hooks/hooks-cursor.json +11 -2
  39. package/hooks/hooks-pi.json +10 -0
  40. package/hooks/hooks.json +21 -1
  41. package/hooks/on-session-end.mjs +178 -18
  42. package/hooks/on-session-start.mjs +30 -4
  43. package/hooks/post-bash-write-verify.mjs +977 -0
  44. package/hooks/post-subagent-discovery-validator.mjs +256 -41
  45. package/hooks/pre-bash-destructive-guard.mjs +616 -164
  46. package/hooks/pre-bash-issue-budget.mjs +167 -0
  47. package/hooks/pre-bash-sessions-ledger-guard.mjs +1054 -0
  48. package/hooks/pre-bash-templates-first.mjs +96 -63
  49. package/hooks/subagent-telemetry.mjs +527 -37
  50. package/package.json +6 -3
  51. package/pi/prompts/contract-version-bump.md +12 -0
  52. package/rules/README.md +32 -0
  53. package/scripts/archive-closed-prds.mjs +12 -22
  54. package/scripts/autopilot-multi.mjs +103 -20
  55. package/scripts/backfill-abandoned-sessions.mjs +160 -4
  56. package/scripts/backfill-learnings-from-vault.mjs +967 -0
  57. package/scripts/check-doc-consistency.sh +17 -1
  58. package/scripts/emit-session.mjs +3 -40
  59. package/scripts/eval-session.mjs +50 -9
  60. package/scripts/fleet-instruction-scan.mjs +141 -0
  61. package/scripts/lib/autopilot/mr-draft.mjs +31 -1
  62. package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
  63. package/scripts/lib/backlog-scan.mjs +39 -6
  64. package/scripts/lib/blocked-commands-policy.mjs +340 -0
  65. package/scripts/lib/ci-status-banner.mjs +75 -12
  66. package/scripts/lib/claude-md-budget-lint.mjs +283 -34
  67. package/scripts/lib/command-blocker.mjs +1273 -58
  68. package/scripts/lib/config/config-protection.mjs +2 -1
  69. package/scripts/lib/config/drift-check.mjs +9 -1
  70. package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
  71. package/scripts/lib/config/issue-budget.mjs +123 -0
  72. package/scripts/lib/config/reconcile.mjs +21 -0
  73. package/scripts/lib/config/section-extractor.mjs +121 -1
  74. package/scripts/lib/config-schema.mjs +23 -3
  75. package/scripts/lib/config.mjs +17 -0
  76. package/scripts/lib/convergence-monitor.mjs +49 -3
  77. package/scripts/lib/description-surface.mjs +535 -0
  78. package/scripts/lib/dispatcher/enumerate.mjs +26 -40
  79. package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
  80. package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
  81. package/scripts/lib/eval/engine.mjs +47 -5
  82. package/scripts/lib/events.mjs +59 -7
  83. package/scripts/lib/gates/gate-full.mjs +15 -3
  84. package/scripts/lib/gates/gate-helpers.mjs +132 -6
  85. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
  86. package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
  87. package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
  88. package/scripts/lib/handover-gate.mjs +7 -3
  89. package/scripts/lib/hardening.mjs +9 -9
  90. package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
  91. package/scripts/lib/instruction-budget-guard.mjs +402 -51
  92. package/scripts/lib/io.mjs +345 -10
  93. package/scripts/lib/issue-budget.mjs +269 -0
  94. package/scripts/lib/issue-close-strip-labels.mjs +39 -9
  95. package/scripts/lib/label-scope.mjs +47 -0
  96. package/scripts/lib/learnings/affinity.mjs +434 -0
  97. package/scripts/lib/learnings/candidates.mjs +736 -0
  98. package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
  99. package/scripts/lib/learnings/judgment.mjs +782 -0
  100. package/scripts/lib/learnings/kebab.mjs +128 -0
  101. package/scripts/lib/learnings/schema.mjs +43 -3
  102. package/scripts/lib/learnings/select.mjs +550 -0
  103. package/scripts/lib/lock-reaper.mjs +1 -2
  104. package/scripts/lib/memory-proposals/schema.mjs +36 -1
  105. package/scripts/lib/peer-discovery.mjs +645 -0
  106. package/scripts/lib/pi-hook-bridge.mjs +146 -17
  107. package/scripts/lib/product-repo-detect.mjs +9 -8
  108. package/scripts/lib/project-hygiene.mjs +432 -0
  109. package/scripts/lib/quality-gate.mjs +167 -0
  110. package/scripts/lib/recommendations-v0.mjs +1 -1
  111. package/scripts/lib/reconcile/eligibility.mjs +1 -1
  112. package/scripts/lib/reconcile/emitter.mjs +128 -24
  113. package/scripts/lib/reconcile/engine.mjs +156 -54
  114. package/scripts/lib/reconcile/idempotency.mjs +114 -14
  115. package/scripts/lib/reconcile/renderer.mjs +141 -25
  116. package/scripts/lib/reconcile/sanitize.mjs +518 -0
  117. package/scripts/lib/reconcile/writer.mjs +95 -1
  118. package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
  119. package/scripts/lib/resource-probe/evaluate.mjs +70 -4
  120. package/scripts/lib/resource-probe.mjs +19 -0
  121. package/scripts/lib/rule-loader.mjs +6 -0
  122. package/scripts/lib/scope-baseline.mjs +564 -0
  123. package/scripts/lib/scope-gate.mjs +568 -145
  124. package/scripts/lib/session-close-backfill.mjs +63 -8
  125. package/scripts/lib/session-end/phase-skip.mjs +1 -0
  126. package/scripts/lib/session-id.mjs +221 -41
  127. package/scripts/lib/session-lock.mjs +304 -6
  128. package/scripts/lib/session-record-repair.mjs +551 -0
  129. package/scripts/lib/session-schema/constants.mjs +22 -3
  130. package/scripts/lib/session-schema/serializer.mjs +54 -0
  131. package/scripts/lib/session-schema/validator.mjs +16 -0
  132. package/scripts/lib/session-schema.mjs +1 -0
  133. package/scripts/lib/session-token-rollup.mjs +68 -6
  134. package/scripts/lib/sessions-integrity-banner.mjs +294 -0
  135. package/scripts/lib/sessions-staleness-banner.mjs +121 -12
  136. package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
  137. package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
  138. package/scripts/lib/soul-resolve.mjs +12 -0
  139. package/scripts/lib/spiral-carryover.mjs +142 -30
  140. package/scripts/lib/state-md/mission-status.mjs +53 -3
  141. package/scripts/lib/subagents-schema.mjs +43 -9
  142. package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
  143. package/scripts/lib/tests-src-ratio.mjs +484 -0
  144. package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
  145. package/scripts/lib/validate/check-agents.mjs +56 -0
  146. package/scripts/lib/validate/check-banner-parity.mjs +376 -0
  147. package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
  148. package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
  149. package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
  150. package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
  151. package/scripts/lib/validate/check-rules.mjs +244 -36
  152. package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
  153. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  154. package/scripts/lib/validate/check-unwired-features.mjs +549 -0
  155. package/scripts/lib/validate-vendored-rules.mjs +10 -2
  156. package/scripts/lib/vault-archive.mjs +17 -2
  157. package/scripts/lib/vault-backfill/glab.mjs +8 -0
  158. package/scripts/lib/vault-mirror/process.mjs +30 -0
  159. package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
  160. package/scripts/lib/vcs-repo-spec.mjs +362 -0
  161. package/scripts/lib/wave-resource-gate.mjs +115 -11
  162. package/scripts/lib/worktree/listing.mjs +44 -7
  163. package/scripts/mcp-server.sh +17 -3
  164. package/scripts/measure-context-overhead.sh +151 -0
  165. package/scripts/memory-propose.mjs +72 -9
  166. package/scripts/print-applicable-rules.mjs +218 -16
  167. package/scripts/print-learnings-index.mjs +474 -0
  168. package/scripts/release.mjs +534 -0
  169. package/scripts/repair-invalid-sessions.mjs +209 -0
  170. package/scripts/run-quality-gate.mjs +123 -5
  171. package/scripts/sweep-expired-learnings.mjs +192 -32
  172. package/scripts/validate-plugin.mjs +21 -0
  173. package/scripts/validate-wave-scope.mjs +182 -17
  174. package/scripts/vault-integration-watcher.mjs +32 -10
  175. package/skills/_shared/config-reading.md +2 -2
  176. package/skills/bootstrap/fast-template.md +1 -1
  177. package/skills/brainstorm/soul.md +47 -1
  178. package/skills/claude-md-drift-check/checker.mjs +145 -28
  179. package/skills/contract-version-bump/SKILL.md +219 -0
  180. package/skills/discovery/SKILL.md +4 -4
  181. package/skills/discovery/issue-templates.md +11 -11
  182. package/skills/discovery/probes-audit.md +1 -1
  183. package/skills/discovery/probes-feature.md +1 -1
  184. package/skills/discovery/probes-session.md +26 -5
  185. package/skills/ecosystem-health/SKILL.md +1 -1
  186. package/skills/ecosystem-health/wizard.md +4 -4
  187. package/skills/evolve/SKILL.md +117 -18
  188. package/skills/gitlab-ops/SKILL.md +25 -12
  189. package/skills/gitlab-portfolio/SKILL.md +2 -2
  190. package/skills/grill/soul.md +44 -1
  191. package/skills/hook-development/SKILL.md +1 -1
  192. package/skills/mode-selector/SKILL.md +1 -1
  193. package/skills/npm-publish/SKILL.md +17 -1
  194. package/skills/plan/SKILL.md +5 -5
  195. package/skills/plan/mode-feature.md +4 -4
  196. package/skills/plan/mode-new.md +10 -10
  197. package/skills/plan/mode-retro.md +1 -1
  198. package/skills/plan/soul.md +46 -3
  199. package/skills/quality-gates/SKILL.md +1 -1
  200. package/skills/reconcile/SKILL.md +21 -4
  201. package/skills/session-end/SKILL.md +34 -36
  202. package/skills/session-end/discovery-scan.md +4 -2
  203. package/skills/session-end/drift-operations.md +4 -4
  204. package/skills/session-end/metrics-collection.md +13 -0
  205. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  206. package/skills/session-end/phase-3-6-tail.md +32 -2
  207. package/skills/session-end/plan-verification.md +6 -7
  208. package/skills/session-end/session-metrics-write.md +2 -0
  209. package/skills/session-end/vault-operations.md +1 -1
  210. package/skills/session-end/verification-checklist.md +1 -1
  211. package/skills/session-plan/SKILL.md +6 -2
  212. package/skills/session-plan/wave-template.md +2 -0
  213. package/skills/session-start/SKILL.md +75 -7
  214. package/skills/session-start/phase-4-5-resource-health.md +15 -2
  215. package/skills/session-start/soul.md +41 -1
  216. package/skills/test-runner/SKILL.md +2 -2
  217. package/skills/vault-sync/validator.mjs +108 -7
  218. package/skills/wave-executor/SKILL.md +6 -7
  219. package/skills/wave-executor/circuit-breaker.md +2 -0
  220. package/skills/wave-executor/wave-loop.md +198 -80
  221. package/templates/_shared/loop.md +4 -4
@@ -14,6 +14,8 @@
14
14
  import { existsSync, readFileSync } from 'node:fs';
15
15
  import path from 'node:path';
16
16
 
17
+ import { tokenizeCommand, splitChainSegments, resolveSegmentVerb } from './command-blocker.mjs';
18
+
17
19
  /**
18
20
  * Find the wave-scope.json file for the given project root.
19
21
  *
@@ -223,6 +225,285 @@ export function assertFileScopeSubset(fileScope, allowedPaths) {
223
225
  return { ok: missing.length === 0, missing };
224
226
  }
225
227
 
228
+ /**
229
+ * Default production→test sibling rules (#970). `source` is matched with
230
+ * {@link pathMatchesPattern}; `sibling` is a TEMPLATE expanded with `{basename}`
231
+ * (filename without its extension) and `{dir}` (dirname, no trailing slash).
232
+ *
233
+ * This repo's layout only. Consumer repos override via `opts.rules` — observed
234
+ * shapes there are `{ source: '**\/*.ts', sibling: '{dir}/{basename}.test.*' }`,
235
+ * `{ source: '**\/*.ts', sibling: '{dir}/__tests__/**' }` and
236
+ * `{ source: 'supabase/migrations/**', sibling: 'supabase/tests/**' }`.
237
+ *
238
+ * @type {ReadonlyArray<{source: string, sibling: string}>}
239
+ */
240
+ export const DEFAULT_TEST_SIBLING_RULES = Object.freeze([
241
+ Object.freeze({ source: '**/*.mjs', sibling: 'tests/**/{basename}*.test.mjs' }),
242
+ ]);
243
+
244
+ /**
245
+ * Patterns that identify an entry as ALREADY test-side. Used for the
246
+ * no-inverse-expansion rule and, transitively, for idempotency.
247
+ * @type {ReadonlyArray<string>}
248
+ */
249
+ export const DEFAULT_TEST_PATH_PATTERNS = Object.freeze([
250
+ 'tests/**',
251
+ 'test/**',
252
+ '**/__tests__/**',
253
+ '**/*.test.*',
254
+ '**/*.spec.*',
255
+ ]);
256
+
257
+ /**
258
+ * The wave roles for which test-sibling expansion (#970) fires. THE list — the
259
+ * prose in `skills/wave-executor/wave-loop.md` § Scope Manifest and
260
+ * `.cursor/rules/030-wave-execution.mdc` describes this constant; it does not
261
+ * restate it. Canonical casing; comparison is case-insensitive (see
262
+ * {@link testSiblingExpansionApplies}).
263
+ *
264
+ * Impl-Core / Impl-Polish are exactly where the incident occurred (an agent
265
+ * changes a production file and must update its test). Every other role is a
266
+ * documented non-expansion case: Discovery is deny-all `[]`, Quality phase 1
267
+ * (Simplification) MUST NOT gain test-write access, Quality phase 2 is already
268
+ * test-only (expansion is inert), Finalization writes no source.
269
+ *
270
+ * @type {ReadonlyArray<string>}
271
+ */
272
+ export const TEST_SIBLING_EXPANSION_ROLES = Object.freeze(['Impl-Core', 'Impl-Polish']);
273
+
274
+ /** Lower-cased lookup set for {@link testSiblingExpansionApplies}. @type {ReadonlySet<string>} */
275
+ const EXPANSION_ROLE_KEYS = new Set(TEST_SIBLING_EXPANSION_ROLES.map((r) => r.toLowerCase()));
276
+
277
+ /**
278
+ * Does test-sibling expansion (#970) apply for these options? The SINGLE
279
+ * predicate behind both {@link expandTestSiblings} (which grants the siblings)
280
+ * and {@link assertTestSiblingCoverage} (which requires them). They must agree:
281
+ * a check that demands coverage the expander never produced blocks a dispatch
282
+ * for a requirement nobody was told to satisfy.
283
+ *
284
+ * Precedence — `enabled` (explicit, BOTH directions) > `role` > fail-closed:
285
+ * 1. `enabled === false` → NO. The unconditional opt-out; nothing overrides it.
286
+ * 2. `enabled === true` → YES. Explicit opt-in, for callers whose role
287
+ * vocabulary is not this repo's (consumer repos) and for focused tests.
288
+ * 3. `role` present → YES iff it is in {@link TEST_SIBLING_EXPANSION_ROLES}
289
+ * (trimmed, case-insensitive — plans and `wave-scope.json` are written by
290
+ * LLM prose, and `impl-core` vs `Impl-Core` must not silently change
291
+ * behaviour).
292
+ * 4. Otherwise (role ABSENT, non-string, or UNRECOGNISED) → NO.
293
+ *
294
+ * ## Why absent-role fails CLOSED (the load-bearing choice)
295
+ * The two failure directions are not symmetric. Expansion wrongly OMITTED
296
+ * blocks an agent's write: it fails loudly, at the tool boundary, and the agent
297
+ * reports `blocked` — recoverable in one re-union. Expansion wrongly APPLIED
298
+ * silently hands write access to the test suite; in a Quality phase-1
299
+ * Simplification wave that is the "delete a dead export, then edit the test to
300
+ * match" failure mode, which defeats the very gate the wave exists to run.
301
+ * Silent-permissive is strictly worse than loud-restrictive, and this module's
302
+ * convention is already "cannot assert → treat as failure"
303
+ * (see {@link assertFileScopeSubset}). So a caller must NAME a role (or opt in
304
+ * explicitly); forgetting is not a grant.
305
+ *
306
+ * @param {{enabled?: boolean, role?: string}} [opts]
307
+ * @returns {boolean}
308
+ */
309
+ export function testSiblingExpansionApplies(opts = {}) {
310
+ if (opts === null || typeof opts !== 'object') return false;
311
+ if (opts.enabled === false) return false;
312
+ if (opts.enabled === true) return true;
313
+ if (typeof opts.role !== 'string') return false;
314
+ return EXPANSION_ROLE_KEYS.has(opts.role.trim().toLowerCase());
315
+ }
316
+
317
+ /**
318
+ * Expand a wave scope list with the TEST SIBLING of every concrete production
319
+ * file it grants (#970).
320
+ *
321
+ * ## The bug this closes
322
+ * An `allowedPaths` / agent `fileScope` entry that lists a production file
323
+ * WITHOUT its test sibling mechanically prevents the agent from updating that
324
+ * test. Observed three times in one consumer-repo session: a SQL regression test
325
+ * that could not be written, a cross-tenant security test left unwritable while
326
+ * its subject file stayed red, and a suite left red because the importing test
327
+ * lay outside every agent's scope. The scope guard enforced exactly the
328
+ * inconsistency the quality gate exists to catch.
329
+ *
330
+ * ## Why a GLOB and not a computed path
331
+ * The emitted sibling is `tests/**\/{basename}*.test.mjs`, never a concrete
332
+ * path. Measured over all 430 tracked production `.mjs` in this repo:
333
+ * a naive 1:1 mirror (`scripts/lib/X.mjs → tests/lib/X.test.mjs`) is right
334
+ * 301/430 = 70.0% (`hooks/_lib` 0/5, `scripts/ci/` 0/1); a same-basename test
335
+ * ANYWHERE under `tests/` is right 368/430 = 85.6%. The glob takes the 85.6%
336
+ * form, and its failure mode is HARMLESS — it grants write access to files that
337
+ * may not exist. A computed concrete path is wrong 30% of the time AND still
338
+ * denies the real test, which is the original bug wearing a new face.
339
+ *
340
+ * ## Required behaviours (each is a nameable regression)
341
+ * 1. `[]` → `[]` STRUCTURALLY. Discovery waves use an empty scope as a
342
+ * deliberate deny-all contract (#256 NO-OP); expansion must never be able to
343
+ * resurrect a write there.
344
+ * 2. IDEMPOTENT — `expand(expand(x))` deep-equals `expand(x)`. The #796
345
+ * re-union path rewrites the manifest mid-wave and the scope MUST NEVER
346
+ * shrink, so expansion has to compose with a re-run. Guaranteed by (4): every
347
+ * synthesized entry is itself a test path and is therefore inert on re-entry.
348
+ * 3. ABSOLUTE entries pass through untouched — a Gate-5b out-of-repo grant
349
+ * (`/Users/…/vault/**`) must not sprout a synthetic `tests/**` sibling
350
+ * outside the repo.
351
+ * 4. NO INVERSE EXPANSION — a test path never causes a production path to be
352
+ * added. Quality phase 2's scope is already test-only; expansion is inert there.
353
+ * 5. ROLE-GATED, fail-closed — the wave's `role` decides, via the shared
354
+ * predicate {@link testSiblingExpansionApplies}, and `opts.enabled` overrides
355
+ * it in both directions. Quality phase 1 (Simplification) must NOT expand, or
356
+ * simplification agents gain write access to the suite (the "delete a dead
357
+ * export, then edit the test to match" failure mode). An absent or
358
+ * unrecognised role does not expand — see the predicate for why.
359
+ *
360
+ * Glob source entries (`src/**`, `src/`) are NOT expanded: they have no single
361
+ * basename, and the incident shape is a concrete production FILE.
362
+ *
363
+ * APPEND-ONLY: the returned array starts with the input entries in their original
364
+ * order (deduplicated), then the synthesized siblings in first-seen order.
365
+ *
366
+ * Pure: no I/O, no filesystem probing, never throws. A non-array input returns `[]`.
367
+ *
368
+ * @param {string[]} paths — a wave `allowedPaths` union OR one agent's `fileScope`
369
+ * @param {{enabled?: boolean, role?: string,
370
+ * rules?: ReadonlyArray<{source: string, sibling: string}>,
371
+ * testPathPatterns?: ReadonlyArray<string>}} [opts]
372
+ * @returns {string[]} the input entries followed by synthesized test-sibling globs
373
+ */
374
+ export function expandTestSiblings(paths, opts = {}) {
375
+ if (!Array.isArray(paths) || paths.length === 0) return []; // behaviour 1
376
+ const entries = paths.filter((e) => typeof e === 'string' && e.length > 0);
377
+ const seen = new Set(entries);
378
+ const out = [...new Set(entries)];
379
+ if (!testSiblingExpansionApplies(opts)) return out; // behaviour 5
380
+
381
+ for (const sibling of testSiblingsFor(entries, opts)) {
382
+ if (seen.has(sibling)) continue;
383
+ seen.add(sibling);
384
+ out.push(sibling);
385
+ }
386
+ return out;
387
+ }
388
+
389
+ /**
390
+ * The synthesized test-sibling globs for a scope list, in first-seen order —
391
+ * WITHOUT the original entries. Shared by {@link expandTestSiblings} and the
392
+ * `--assert-subset` coverage check so both agree on what a sibling is.
393
+ *
394
+ * @param {string[]} entries — already-filtered non-empty string entries
395
+ * @param {{rules?: ReadonlyArray<{source: string, sibling: string}>,
396
+ * testPathPatterns?: ReadonlyArray<string>}} [opts]
397
+ * @returns {string[]}
398
+ */
399
+ export function testSiblingsFor(entries, opts = {}) {
400
+ const rules = Array.isArray(opts.rules) ? opts.rules : DEFAULT_TEST_SIBLING_RULES;
401
+ const testPatterns = Array.isArray(opts.testPathPatterns)
402
+ ? opts.testPathPatterns
403
+ : DEFAULT_TEST_PATH_PATTERNS;
404
+ const out = [];
405
+ const seen = new Set();
406
+ for (const entry of Array.isArray(entries) ? entries : []) {
407
+ if (typeof entry !== 'string' || entry.length === 0) continue;
408
+ if (path.isAbsolute(entry)) continue; // behaviour 3
409
+ if (isGlobScopeEntry(entry)) continue; // no single basename to mirror
410
+ if (isTestPathEntry(entry, testPatterns)) continue; // behaviours 2 + 4
411
+ for (const rule of rules) {
412
+ if (!rule || typeof rule.source !== 'string' || typeof rule.sibling !== 'string') continue;
413
+ if (!pathMatchesPattern(entry, rule.source)) continue;
414
+ const sibling = renderSiblingTemplate(rule.sibling, entry);
415
+ if (!sibling || seen.has(sibling)) continue;
416
+ seen.add(sibling);
417
+ out.push(sibling);
418
+ }
419
+ }
420
+ return out;
421
+ }
422
+
423
+ /**
424
+ * Is this entry already test-side? Behaviours 2 + 4 both reduce to this check.
425
+ * @param {string} entry
426
+ * @param {ReadonlyArray<string>} testPatterns
427
+ * @returns {boolean}
428
+ */
429
+ function isTestPathEntry(entry, testPatterns) {
430
+ return testPatterns.some((p) => pathMatchesPattern(entry, p));
431
+ }
432
+
433
+ /**
434
+ * Expand `{basename}` / `{dir}` in a sibling template against a production path.
435
+ * `{basename}` is the filename with its final extension removed.
436
+ * @param {string} template
437
+ * @param {string} entry
438
+ * @returns {string}
439
+ */
440
+ function renderSiblingTemplate(template, entry) {
441
+ const base = entry.slice(entry.lastIndexOf('/') + 1);
442
+ const dot = base.lastIndexOf('.');
443
+ const basename = dot > 0 ? base.slice(0, dot) : base;
444
+ const slash = entry.lastIndexOf('/');
445
+ const dir = slash === -1 ? '.' : entry.slice(0, slash);
446
+ return template.replaceAll('{basename}', basename).replaceAll('{dir}', dir);
447
+ }
448
+
449
+ /**
450
+ * Assert that the wave's `allowedPaths` union grants the TEST SIBLING of every
451
+ * concrete production file in an agent's declared `fileScope` (#970).
452
+ *
453
+ * Companion to {@link assertFileScopeSubset}, wired into
454
+ * `validate-wave-scope.mjs --assert-subset --expand-test-siblings`. That flag is
455
+ * the one mechanical, fail-closed enforcement point in the dispatch pipeline (it
456
+ * exits 1); a warn there would be decorative, since warnings in that script do
457
+ * not affect the exit code.
458
+ *
459
+ * DIRECTION MATTERS: this expands the AGENT'S fileScope and requires the union to
460
+ * cover the result. Expanding `allowedPaths` instead would WEAKEN
461
+ * `--assert-subset` — its value is that it is a conservative over-approximation,
462
+ * and growing the union makes more things trivially "covered". This only ever
463
+ * ADDS a requirement; a manifest that passed the plain subset check can now fail,
464
+ * never the reverse.
465
+ *
466
+ * A synthesized glob `G` counts as covered when ANY of:
467
+ * 1. `G` is present verbatim in allowedPaths;
468
+ * 2. some allowedPaths entry MATCHES `G` — the union already grants a concrete
469
+ * file that IS a test sibling (`tests/lib/x.test.mjs` ⊨ `tests/**\/x*.test.mjs`);
470
+ * 3. `G`'s literal prefix is covered by an allowedPaths pattern — the same
471
+ * glob rule {@link assertFileScopeSubset} uses (a broad `tests/**` grant).
472
+ *
473
+ * ROLE-GATED BY THE SAME PREDICATE as the expander
474
+ * ({@link testSiblingExpansionApplies}). This is not symmetry for its own sake:
475
+ * a Quality phase-1 union is production files with tests deliberately excluded,
476
+ * so an ungated assertion would demand coverage that phase must never grant and
477
+ * would hard-block its dispatch. When the gate is off the assertion adds NO
478
+ * requirement (`{ ok: true }`) rather than failing closed — "expansion did not
479
+ * fire here" and "the union is deficient" are different facts, and only the
480
+ * second is a dispatch blocker. Non-array inputs still fail closed
481
+ * (`{ ok: false }`), matching the module convention.
482
+ *
483
+ * @param {string[]} fileScope — one agent's declared file scope
484
+ * @param {string[]} allowedPaths — the wave's allowedPaths union
485
+ * @param {object} [opts] — same shape as {@link expandTestSiblings} (incl. `role`)
486
+ * @returns {{ok: boolean, missing: string[]}} missing = uncovered sibling globs
487
+ */
488
+ export function assertTestSiblingCoverage(fileScope, allowedPaths, opts = {}) {
489
+ if (!Array.isArray(fileScope) || !Array.isArray(allowedPaths)) {
490
+ return { ok: false, missing: [] };
491
+ }
492
+ if (!testSiblingExpansionApplies(opts)) return { ok: true, missing: [] };
493
+ const missing = [];
494
+ for (const sibling of testSiblingsFor(fileScope, opts)) {
495
+ const prefix = literalScopePrefix(sibling);
496
+ const covered = allowedPaths.some(
497
+ (p) =>
498
+ typeof p === 'string' &&
499
+ p.length > 0 &&
500
+ (p === sibling || pathMatchesPattern(p, sibling) || pathMatchesPattern(prefix, p)),
501
+ );
502
+ if (!covered) missing.push(sibling);
503
+ }
504
+ return { ok: missing.length === 0, missing };
505
+ }
506
+
226
507
  /**
227
508
  * Extract likely file-write TARGETS from a Bash command string (#800).
228
509
  *
@@ -262,6 +543,55 @@ export function assertFileScopeSubset(fileScope, allowedPaths) {
262
543
  * relatively, absolute where the command used an absolute path) and de-duplicated
263
544
  * in first-seen order. The caller relativises + matches against allowedPaths.
264
545
  *
546
+ * ## Lexing is DELEGATED to the shared tokenizer (#970)
547
+ *
548
+ * This function used to carry its OWN Bash lexer (`tokenizeShellForWrites`) —
549
+ * the third in this repo that had to know about quoting. It knew nothing about
550
+ * comments, here-doc BODIES or ANSI-C `$'…'` quotes, so it carried the exact
551
+ * quote-desync defect #965 fixed in `tokenizeCommand`: one apostrophe in an
552
+ * ordinary shell comment (`# don't`) wedged it in single-quote state and every
553
+ * later `>` became quoted text. Measured before the fix — `# don't\necho x >
554
+ * src/secret.ts` returned `[]`, i.e. an out-of-scope write was INVISIBLE to the
555
+ * wave-scope write guard. Two here-doc and ANSI-C variants did the same.
556
+ *
557
+ * It now delegates to `tokenizeCommand` from `./command-blocker.mjs`, which
558
+ * already emits the `>`/`>>`/`<<`/`;`/`|`/`&` operator tokens this pass needs
559
+ * and is the single place comment / here-doc / quote semantics live. Layering
560
+ * holds: `command-blocker.mjs` imports NOTHING (measured), so the edge
561
+ * scope-gate → command-blocker adds no cycle, and both stay pure/no-I/O-at-
562
+ * import for the hook hot path. The barrel `hardening.mjs` is not involved (a
563
+ * module importing the barrel WOULD cycle).
564
+ *
565
+ * Two deltas the shared token stream does not carry, handled locally below:
566
+ * - `(`/`)`: `tokenizeCommand` keeps them inside word text (they are not
567
+ * control operators for its purpose). {@link peelSubshellParens} splits them
568
+ * back out as separators so `(echo x > y)` still yields `y` and
569
+ * `> >(cat)` still yields nothing.
570
+ * - here-doc BODY tokens arrive as ordinary quoted words. They are DATA, and a
571
+ * body is multi-line, so the whitespace skip rule in
572
+ * {@link shouldSkipWriteTarget} drops them. (The delimiter word itself now
573
+ * emits no token at all — an improvement: `tee out.txt <<EOF` used to report
574
+ * the literal `EOF` as a second write target.)
575
+ *
576
+ * ## Segment-first, wrapper-aware verb resolution (#996.2)
577
+ *
578
+ * The interpretation is SEGMENT-first: the token stream is split on shell chain
579
+ * operators / newlines by {@link splitChainSegments} BEFORE it is interpreted,
580
+ * and each segment's real command verb is resolved through the shared
581
+ * {@link resolveSegmentVerb} wrapper table. A lone `expectCommand` flag used to
582
+ * read the FIRST word of a segment as the command head, so a transparent wrapper
583
+ * (`sudo`, `env FOO=1`, `timeout 5`, `nice -n 10`, `command`, `/usr/bin/time`)
584
+ * consumed the head slot and the REAL verb (`tee`/`sed`/`dd`) landed in argument
585
+ * position where it was never matched — measured `sudo tee src/x.ts` → `[]`, a
586
+ * DETECTION LOSS. This is the same fail-open class #991 closed in the ledger
587
+ * guard, in a warn-only (#800) consumer. `resolveSegmentVerb` unwraps the
588
+ * wrappers and returns a BASENAME-normalized verb, so an absolute `/usr/bin/tee`
589
+ * resolves to `tee` for free. It ALSO reports wrapper-written file operands
590
+ * (`writesFile: true`, the #992 discriminator) — `/usr/bin/time -o FILE npm test`
591
+ * truncates FILE while the verb is `npm`, a write with no redirect operator and
592
+ * no tee/sed/dd head; those are harvested exactly as {@link extractRedirectTargets}
593
+ * does.
594
+ *
265
595
  * Hook-safe: pure, deterministic, no I/O. Never throws — a non-string / empty
266
596
  * input returns `[]`.
267
597
  *
@@ -271,83 +601,123 @@ export function assertFileScopeSubset(fileScope, allowedPaths) {
271
601
  export function extractBashWriteTargets(command) {
272
602
  if (typeof command !== 'string' || command.length === 0) return [];
273
603
 
274
- const tokens = tokenizeShellForWrites(command);
275
-
276
604
  const out = [];
277
605
  const seen = new Set();
278
- const add = (value, hadSpace) => {
279
- if (shouldSkipWriteTarget(value, hadSpace)) return;
606
+ const add = (value) => {
607
+ if (shouldSkipWriteTarget(value)) return;
280
608
  const v = value.replace(/^\.\//, '');
281
609
  if (!v || seen.has(v)) return;
282
610
  seen.add(v);
283
611
  out.push(v);
284
612
  };
285
613
 
286
- // Second pass: interpret the token stream. `mode` tracks a command-head that
287
- // owns following args (tee/sed/dd); `pendingRedirect` marks that the NEXT word
288
- // token is a redirect target.
289
- let mode = null; // null | 'tee' | 'sed' | 'dd'
290
- let pendingRedirect = false;
291
- let expectCommand = true; // next word is the command head of this segment
292
- let sedArgs = []; // { value } collected for a `sed` head
293
- let sedInPlace = false;
614
+ // Segment-first (#996.2): split on shell chain operators / newlines BEFORE
615
+ // interpreting, then resolve each segment's REAL verb through the shared
616
+ // wrapper table. `out`/`seen`/`add` stay outside the loop so de-duplication
617
+ // and first-seen ordering hold across segments (`echo a > x; echo b >> x`).
618
+ for (const segment of splitChainSegments(tokenizeCommand(command))) {
619
+ // #996.2 namespace fix: resolve the verb in the SAME (paren-peeled,
620
+ // redirect-target-excluded) namespace the interpretation loop's `wordsSeen`
621
+ // counter walks below — see {@link verbResolutionSegment}. Passing the RAW
622
+ // segment mis-resolved a LEADING redirect (`> a.txt tee b.ts` → verb `>`) or a
623
+ // LEADING subshell paren (`(tee inner.ts)` → verb `(tee`): mode never became
624
+ // `tee`/`sed`/`dd` and the `index + 1` head-skip then swallowed the real
625
+ // write target. For A5's 10 wrapper forms (no leading redirect/paren) the view
626
+ // is a no-op, so verb/index/wrapperArgs — incl. the #992 `writesFile` marks —
627
+ // are byte-identical to the raw call.
628
+ const { verb, index, wrapperArgs } = resolveSegmentVerb(verbResolutionSegment(segment));
629
+ const tokens = classifyShellTokens(segment);
630
+
631
+ // A wrapper can WRITE a file through its OWN option, with no redirect and no
632
+ // tee/sed/dd verb: `/usr/bin/time -o src/report.txt npm test` truncates
633
+ // src/report.txt while the resolved verb is `npm`. `resolveSegmentVerb` marks
634
+ // exactly those operands `writesFile: true` (the #992 discriminator that
635
+ // keeps `nice -n 10` / `sudo -u root` operands OUT). Harvest them exactly as
636
+ // collectRedirectTargets does — the write is otherwise invisible to this pass.
637
+ for (const wa of wrapperArgs) {
638
+ if (wa.writesFile === true && typeof wa.value === 'string') add(wa.value);
639
+ }
640
+
641
+ // `mode` tracks a command-head that owns following args (tee/sed/dd). It is
642
+ // set ONCE per segment from the resolved verb, which `resolveSegmentVerb`
643
+ // returns basename-normalized — so an absolute `/usr/bin/tee` resolves to
644
+ // `tee` for free. `pendingRedirect` marks that the NEXT word is a redirect
645
+ // target; `heredocOpen` that a here-doc BODY word (DATA) follows.
646
+ const mode = verb === 'tee' || verb === 'sed' || verb === 'dd' ? verb : null;
647
+ let pendingRedirect = false;
648
+ let heredocOpen = false;
649
+ const sedArgs = []; // classified word tokens collected for a `sed -i` head
650
+ let sedInPlace = false;
651
+
652
+ // THE ONE CARE POINT of the #996.2 refactor: the first `index + 1` WORD
653
+ // tokens that REACH the `wordsSeen` counter are the wrapper chain + the verb
654
+ // itself, never file arguments. `index` now comes from
655
+ // {@link verbResolutionSegment}, which resolves the verb in the SAME namespace
656
+ // this loop walks — paren-peeled words with redirect targets removed — so
657
+ // `index` is 1:1 with the `word` tokens that reach the counter below (a
658
+ // leading redirect or subshell paren no longer shifts it out of alignment).
659
+ // `peelSubshellParens` emits AT MOST one `word` per raw token.
660
+ const headWordsToSkip = index + 1;
661
+ let wordsSeen = 0;
662
+
663
+ for (const tk of tokens) {
664
+ if (tk.type === 'redirect') {
665
+ pendingRedirect = true;
666
+ continue;
667
+ }
668
+ if (tk.type === 'heredoc') {
669
+ // here-doc: the delimiter emits no token; the BODY arrives later as data
670
+ pendingRedirect = false;
671
+ heredocOpen = true;
672
+ continue;
673
+ }
674
+ if (tk.type === 'in') {
675
+ // input redirect / here-string — not a write target
676
+ pendingRedirect = false;
677
+ continue;
678
+ }
679
+ if (tk.type === 'sep') {
680
+ // Within a segment the ONLY seps are peeled subshell parens — chain
681
+ // operators were already consumed by splitChainSegments. Reset only the
682
+ // pending redirect so `> >(cat)` stays inert (without it the peeled `cat`
683
+ // reads as the redirect target); mode/heredoc reset live at segment end.
684
+ pendingRedirect = false;
685
+ continue;
686
+ }
687
+ // word token
688
+ if (pendingRedirect) {
689
+ add(tk.value);
690
+ pendingRedirect = false;
691
+ continue;
692
+ }
693
+ if (wordsSeen < headWordsToSkip) {
694
+ wordsSeen++; // wrapper chain + verb head — not a file argument
695
+ continue;
696
+ }
697
+ // subsequent argument words, interpreted per active command-head mode
698
+ if (heredocOpen) continue; // DATA, not a file argument
699
+ if (mode === 'tee') {
700
+ if (!isShellFlag(tk.value)) add(tk.value);
701
+ } else if (mode === 'sed') {
702
+ if (/^-i/.test(tk.value)) sedInPlace = true;
703
+ sedArgs.push(tk);
704
+ } else if (mode === 'dd') {
705
+ if (tk.value.startsWith('of=')) add(tk.value.slice(3));
706
+ }
707
+ }
294
708
 
295
- const flushSed = () => {
709
+ // Segment end: flush a pending `sed -i` file argument (last non-flag arg).
710
+ // Only reached once per segment now that each segment carries a single verb,
711
+ // so the former sep-branch / defensive flushes are unnecessary.
296
712
  if (mode === 'sed' && sedInPlace) {
297
713
  for (let i = sedArgs.length - 1; i >= 0; i--) {
298
714
  if (!isShellFlag(sedArgs[i].value)) {
299
- add(sedArgs[i].value, sedArgs[i].hadSpace);
715
+ add(sedArgs[i].value);
300
716
  break;
301
717
  }
302
718
  }
303
719
  }
304
- sedArgs = [];
305
- sedInPlace = false;
306
- };
307
-
308
- for (const tk of tokens) {
309
- if (tk.type === 'redirect') {
310
- pendingRedirect = true;
311
- continue;
312
- }
313
- if (tk.type === 'in') {
314
- // input redirect / heredoc delimiter — not a write target
315
- pendingRedirect = false;
316
- continue;
317
- }
318
- if (tk.type === 'sep') {
319
- flushSed();
320
- mode = null;
321
- pendingRedirect = false;
322
- expectCommand = true;
323
- continue;
324
- }
325
- // word token
326
- if (pendingRedirect) {
327
- add(tk.value, tk.hadSpace);
328
- pendingRedirect = false;
329
- continue;
330
- }
331
- if (expectCommand) {
332
- expectCommand = false;
333
- flushSed(); // flush any prior sed segment defensively
334
- if (tk.value === 'tee') { mode = 'tee'; continue; }
335
- if (tk.value === 'sed') { mode = 'sed'; continue; }
336
- if (tk.value === 'dd') { mode = 'dd'; continue; }
337
- mode = null;
338
- continue;
339
- }
340
- // subsequent argument words, interpreted per active command-head mode
341
- if (mode === 'tee') {
342
- if (!isShellFlag(tk.value)) add(tk.value, tk.hadSpace);
343
- } else if (mode === 'sed') {
344
- if (/^-i/.test(tk.value)) sedInPlace = true;
345
- sedArgs.push(tk);
346
- } else if (mode === 'dd') {
347
- if (tk.value.startsWith('of=')) add(tk.value.slice(3), tk.hadSpace);
348
- }
349
720
  }
350
- flushSed();
351
721
 
352
722
  return out;
353
723
  }
@@ -365,13 +735,15 @@ function isShellFlag(v) {
365
735
  /**
366
736
  * Skip-rule gate for a candidate write target — see the documented skip list on
367
737
  * {@link extractBashWriteTargets}. Returns true when the candidate must be dropped.
368
- * @param {string} value — unquoted target text
369
- * @param {boolean} hadSpace — true if the source token was quoted AND contained a space
738
+ * @param {string} value — unquoted target text (quotes already stripped by the lexer)
370
739
  * @returns {boolean}
371
740
  */
372
- function shouldSkipWriteTarget(value, hadSpace) {
741
+ function shouldSkipWriteTarget(value) {
373
742
  if (typeof value !== 'string' || value.length === 0) return true;
374
- if (hadSpace || value.includes(' ')) return true; // quoted-with-space (best-effort)
743
+ // Any embedded whitespace: a quoted-with-space path (best-effort — far more
744
+ // likely a quoting artefact than a real wave-scoped file) and, since #970, a
745
+ // multi-line here-doc BODY token that reached an argument slot as DATA.
746
+ if (/\s/.test(value)) return true;
375
747
  if (value.startsWith('$') || value.startsWith('~')) return true; // variable / expansion
376
748
  if (value.includes('$')) return true; // any embedded expansion (covers ${TMPDIR})
377
749
  if (value.includes('(') || value.includes(')')) return true; // process-sub remnants
@@ -380,99 +752,150 @@ function shouldSkipWriteTarget(value, hadSpace) {
380
752
  return false;
381
753
  }
382
754
 
755
+ /** Unquoted `tokenizeCommand` operator texts that break the current command. */
756
+ const SHELL_SEPARATOR_OPS = new Set([';', '|', '||', '&', '&&']);
757
+ /** Write redirects: `>`, `>>`, `>|`, and fd-prefixed forms (`2>`, `2>>`). */
758
+ const WRITE_REDIRECT_OP_RE = /^\d*(?:>>|>\|?)$/;
759
+ /** Here-doc operators `<<` / `<<-` — a DATA body token follows at the next newline. */
760
+ const HEREDOC_OP_RE = /^\d*<<-?$/;
761
+ /** Other input redirects `<` / `<<<` (here-string) — never a write target, no body. */
762
+ const INPUT_REDIRECT_OP_RE = /^\d*(?:<<<|<)$/;
763
+
383
764
  /**
384
- * Minimal quote-aware tokenizer for write-target extraction. Walks the command
385
- * left-to-right tracking single/double quote state; recognises redirect / input /
386
- * separator operators ONLY outside quotes, and emits everything else as `word`
387
- * tokens with the quotes stripped. Not a general shell tokenizer — it captures
388
- * exactly what {@link extractBashWriteTargets} needs.
765
+ * Split leading `(` / trailing `)` off an UNQUOTED word token into standalone
766
+ * separator tokens.
767
+ *
768
+ * `tokenizeCommand` does not treat parentheses as control operators (it does not
769
+ * need to), so `(echo x > y)` arrives as words `(echo` … `y)`. Without this the
770
+ * subshell's redirect target would read as the literal `y)` and be dropped by the
771
+ * paren skip rule in {@link shouldSkipWriteTarget} — a silent DETECTION LOSS
772
+ * versus the pre-#970 lexer. Peeling restores it, and process substitution
773
+ * `> >(cat)` still yields nothing because the peeled `cat` lands in command-head
774
+ * position rather than redirect-target position.
775
+ *
776
+ * Quoted tokens are never peeled: `echo '(' x` must keep its literal paren.
777
+ *
778
+ * @param {{text: string, quoted: boolean}} tok
779
+ * @returns {Array<{type: string, value?: string}>}
780
+ */
781
+ function peelSubshellParens(tok) {
782
+ if (tok.quoted) return [{ type: 'word', value: tok.text }];
783
+ let text = tok.text;
784
+ const out = [];
785
+ while (text.startsWith('(')) { out.push({ type: 'sep' }); text = text.slice(1); }
786
+ const trailing = [];
787
+ while (text.endsWith(')')) { trailing.push({ type: 'sep' }); text = text.slice(0, -1); }
788
+ if (text.length > 0 || (out.length === 0 && trailing.length === 0)) {
789
+ out.push({ type: 'word', value: text });
790
+ }
791
+ return out.concat(trailing);
792
+ }
793
+
794
+ /**
795
+ * Re-shape the shared `tokenizeCommand` output into the redirect/in/sep/word
796
+ * stream {@link extractBashWriteTargets} interprets. Operator classification is
797
+ * applied ONLY to unquoted tokens, so `echo '>' x` keeps its `>` as literal text.
798
+ *
799
+ * `&>` is not a distinct token here: `tokenizeCommand` deliberately lexes it as
800
+ * `&` followed by `>` (#965 Risk C). That still works — the redirect branch of the
801
+ * interpretation loop is checked before the command-head branch, so `cmd &> log`
802
+ * yields `log`. `2>&1` likewise yields nothing: the `&` separator resets the
803
+ * pending redirect before `1` is read.
389
804
  *
390
805
  * Token shapes: { type: 'redirect' } | { type: 'in' } | { type: 'sep' }
391
- * | { type: 'word', value: string, hadSpace: boolean }
806
+ * | { type: 'word', value: string }
392
807
  *
393
- * @param {string} command
394
- * @returns {Array<{type:string, value?:string, hadSpace?:boolean}>}
808
+ * @param {Array<{text: string, quoted: boolean}>} tokens
809
+ * @returns {Array<{type: string, value?: string}>}
395
810
  */
396
- function tokenizeShellForWrites(command) {
397
- const tokens = [];
398
- const n = command.length;
399
- let i = 0;
400
- const isWs = (c) => c === ' ' || c === '\t' || c === '\n' || c === '\r';
401
-
402
- while (i < n) {
403
- const c = command[i];
404
- const next = command[i + 1];
405
-
406
- if (isWs(c)) { i++; continue; }
407
-
408
- // `&>` / `&>>` — redirect stdout+stderr to a file (write target follows)
409
- if (c === '&' && next === '>') {
410
- i += 2;
411
- if (command[i] === '>') i++;
412
- tokens.push({ type: 'redirect' });
413
- continue;
414
- }
415
- // `&&` / `&` — command separators
416
- if (c === '&') {
417
- i += next === '&' ? 2 : 1;
418
- tokens.push({ type: 'sep' });
419
- continue;
420
- }
421
- // `>&` — fd duplication (NOT a file target); consume the dup + trailing fd/`-`
422
- if (c === '>' && next === '&') {
423
- i += 2;
424
- while (i < n && (/[0-9]/.test(command[i]) || command[i] === '-')) i++;
425
- continue; // no token — dup carries no write target
426
- }
427
- // `>(` — process substitution: leave the `>` inert; `(` is emitted as a sep
428
- if (c === '>' && next === '(') { i++; continue; }
429
- // `>>` / `>` — write redirects
430
- if (c === '>') {
431
- i += next === '>' ? 2 : 1;
432
- tokens.push({ type: 'redirect' });
433
- continue;
434
- }
435
- // `<<` / `<` — input redirects / heredoc delimiters (never a write target)
436
- if (c === '<') {
437
- i += next === '<' ? 2 : 1;
438
- tokens.push({ type: 'in' });
439
- continue;
811
+ function classifyShellTokens(tokens) {
812
+ const out = [];
813
+ for (const tok of tokens) {
814
+ if (!tok.quoted) {
815
+ if (SHELL_SEPARATOR_OPS.has(tok.text)) { out.push({ type: 'sep' }); continue; }
816
+ if (WRITE_REDIRECT_OP_RE.test(tok.text)) { out.push({ type: 'redirect' }); continue; }
817
+ if (HEREDOC_OP_RE.test(tok.text)) { out.push({ type: 'heredoc' }); continue; }
818
+ if (INPUT_REDIRECT_OP_RE.test(tok.text)) { out.push({ type: 'in' }); continue; }
440
819
  }
441
- // `||` / `|` / `;` / `(` / `)` — separators (break the current command)
442
- if (c === '|') { i += next === '|' ? 2 : 1; tokens.push({ type: 'sep' }); continue; }
443
- if (c === ';') { i++; tokens.push({ type: 'sep' }); continue; }
444
- if (c === '(' || c === ')') { i++; tokens.push({ type: 'sep' }); continue; }
445
-
446
- // Otherwise: read a WORD, honouring single/double quotes (quotes stripped).
447
- let value = '';
448
- let quoted = false;
449
- let hadSpace = false;
450
- while (i < n) {
451
- const ch = command[i];
452
- if (ch === "'") {
453
- quoted = true;
454
- i++;
455
- while (i < n && command[i] !== "'") { if (command[i] === ' ') hadSpace = true; value += command[i]; i++; }
456
- i++; // closing quote (or EOF)
820
+ out.push(...peelSubshellParens(tok));
821
+ }
822
+ return out;
823
+ }
824
+
825
+ /**
826
+ * Project a raw chain segment into the token list used for VERB resolution,
827
+ * in the SAME namespace {@link extractBashWriteTargets}'s interpretation loop
828
+ * walks with its `wordsSeen` counter (#996.2 namespace fix).
829
+ *
830
+ * {@link resolveSegmentVerb} resolves argv[0] from RAW tokens. Two segment shapes
831
+ * put a NON-verb token in raw position 0 and so mis-resolve the command head,
832
+ * losing the write target the pre-#996.2 pass had detected:
833
+ * - a LEADING write-redirect — `> a.txt tee b.ts` resolves the `>` operator as
834
+ * the "verb" (mode never becomes `tee`), and the loop's `index + 1` head-skip
835
+ * then swallows `tee`, dropping `b.ts`.
836
+ * - a LEADING subshell paren — `(tee inner.ts)` keeps the raw token `(tee`
837
+ * (`.replace(/^.*\//,'')` leaves it untouched, no `/`), so the wrapper table
838
+ * never matches: verb is the literal `(tee`, again mode-less. The classified
839
+ * stream peels the paren AFTER, so the raw verb index and the peeled word
840
+ * stream diverge.
841
+ *
842
+ * The projection makes both namespaces agree: peel leading/trailing parens off
843
+ * each unquoted token (matching {@link peelSubshellParens}' WORD output) and drop
844
+ * every write-redirect operator together with its target word — exactly the
845
+ * tokens the interpretation loop keeps OUT of `wordsSeen` (redirect operators are
846
+ * not `word`s; their targets are consumed by the `pendingRedirect` branch before
847
+ * the counter). The resulting segment's verb INDEX is then 1:1 with `wordsSeen`,
848
+ * so `index + 1` skips the wrapper chain + verb and no more.
849
+ *
850
+ * A5's 10 transparent-wrapper forms carry neither a leading redirect nor a paren,
851
+ * so this is a NO-OP for them: `resolveSegmentVerb`'s verb/index/wrapperArgs (incl.
852
+ * the #992 `writesFile` operand marks) are byte-identical to the raw-segment call.
853
+ *
854
+ * @param {Array<{ text: string, quoted: boolean }>} segment
855
+ * @returns {Array<{ text: string, quoted: boolean }>}
856
+ */
857
+ function verbResolutionSegment(segment) {
858
+ const out = [];
859
+ let pendingRedirectTarget = false;
860
+ for (const tok of segment) {
861
+ if (!tok.quoted) {
862
+ // A write redirect consumes the NEXT token as its target — neither the
863
+ // operator nor its target reaches `wordsSeen`, so both must be absent here
864
+ // to keep the verb index aligned.
865
+ if (WRITE_REDIRECT_OP_RE.test(tok.text)) {
866
+ pendingRedirectTarget = true;
457
867
  continue;
458
868
  }
459
- if (ch === '"') {
460
- quoted = true;
461
- i++;
462
- while (i < n && command[i] !== '"') { if (command[i] === ' ') hadSpace = true; value += command[i]; i++; }
463
- i++; // closing quote (or EOF)
869
+ // heredoc / input-redirect operators carry no immediate target token that
870
+ // reaches `wordsSeen` (the here-doc delimiter emits nothing; an input target
871
+ // DOES reach the counter and so is kept) — drop only the operator itself.
872
+ if (HEREDOC_OP_RE.test(tok.text) || INPUT_REDIRECT_OP_RE.test(tok.text)) {
873
+ pendingRedirectTarget = false;
464
874
  continue;
465
875
  }
466
- if (isWs(ch)) break;
467
- // unquoted operator chars end the word
468
- if (ch === '>' || ch === '<' || ch === '|' || ch === ';' || ch === '&' || ch === '(' || ch === ')') break;
469
- value += ch;
470
- i++;
876
+ // Within a segment the only separator that can appear is an fd-dup `&`
877
+ // (e.g. `2>&1`), which classifyShellTokens also treats as a `sep` that
878
+ // resets the pending redirect mirror that so the target after it stays.
879
+ if (SHELL_SEPARATOR_OPS.has(tok.text)) {
880
+ pendingRedirectTarget = false;
881
+ continue;
882
+ }
883
+ }
884
+ if (pendingRedirectTarget) {
885
+ pendingRedirectTarget = false;
886
+ continue; // this token is the dropped write-redirect target
471
887
  }
472
- tokens.push({ type: 'word', value, hadSpace, quoted });
888
+ if (tok.quoted) {
889
+ out.push(tok);
890
+ continue;
891
+ }
892
+ let text = tok.text;
893
+ while (text.startsWith('(')) text = text.slice(1);
894
+ while (text.endsWith(')')) text = text.slice(0, -1);
895
+ if (text.length === 0) continue; // pure paren = a separator, not a word
896
+ out.push({ text, quoted: false });
473
897
  }
474
-
475
- return tokens;
898
+ return out;
476
899
  }
477
900
 
478
901
  /**