session-orchestrator 3.16.0 → 3.19.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 (220) 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 +130 -412
  6. package/README.md +17 -12
  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/docs/README.md +2 -1
  21. package/docs/USER-GUIDE.md +8 -3
  22. package/docs/ci-setup.md +121 -7
  23. package/docs/codex-setup.md +1 -1
  24. package/docs/components.md +7 -7
  25. package/docs/cursor-setup.md +22 -9
  26. package/docs/events-schema.md +5 -1
  27. package/docs/instruction-delivery.md +444 -0
  28. package/docs/pi-setup.md +1 -1
  29. package/docs/rule-authoring.md +58 -9
  30. package/docs/session-config-reference.md +306 -6
  31. package/docs/session-config-template.md +66 -3
  32. package/docs/telemetry/telemetry-claims.md +204 -0
  33. package/docs/telemetry.md +158 -0
  34. package/hooks/_lib/guard-source-loader.mjs +467 -0
  35. package/hooks/_lib/lock-bootstrap.mjs +21 -0
  36. package/hooks/_lib/vcs-create-matcher.mjs +119 -0
  37. package/hooks/config-protection.mjs +0 -0
  38. package/hooks/enforce-commands.mjs +10 -2
  39. package/hooks/hooks-codex.json +1 -1
  40. package/hooks/hooks-cursor.json +11 -2
  41. package/hooks/hooks-pi.json +10 -0
  42. package/hooks/hooks.json +21 -1
  43. package/hooks/on-session-end.mjs +178 -18
  44. package/hooks/on-session-start.mjs +23 -0
  45. package/hooks/post-bash-write-verify.mjs +977 -0
  46. package/hooks/post-subagent-discovery-validator.mjs +256 -41
  47. package/hooks/pre-bash-destructive-guard.mjs +525 -160
  48. package/hooks/pre-bash-issue-budget.mjs +167 -0
  49. package/hooks/pre-bash-sessions-ledger-guard.mjs +627 -0
  50. package/hooks/pre-bash-templates-first.mjs +96 -63
  51. package/hooks/skill-invocation-telemetry.mjs +109 -10
  52. package/hooks/subagent-telemetry.mjs +527 -37
  53. package/package.json +16 -3
  54. package/pi/prompts/contract-version-bump.md +12 -0
  55. package/rules/README.md +32 -0
  56. package/scripts/archive-closed-prds.mjs +12 -22
  57. package/scripts/autopilot-multi.mjs +103 -20
  58. package/scripts/backfill-abandoned-sessions.mjs +160 -4
  59. package/scripts/check-doc-consistency.sh +17 -1
  60. package/scripts/compute-grounding-injection.sh +18 -3
  61. package/scripts/dialectic-deriver.mjs +7 -2
  62. package/scripts/eval-session.mjs +50 -9
  63. package/scripts/fleet-instruction-scan.mjs +141 -0
  64. package/scripts/lib/auto-dialectic.mjs +11 -2
  65. package/scripts/lib/auto-dream.mjs +16 -5
  66. package/scripts/lib/autopilot/mr-draft.mjs +31 -1
  67. package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
  68. package/scripts/lib/backlog-scan.mjs +39 -6
  69. package/scripts/lib/blocked-commands-policy.mjs +340 -0
  70. package/scripts/lib/build-live-signals.mjs +7 -4
  71. package/scripts/lib/ci-status-banner.mjs +75 -12
  72. package/scripts/lib/claude-md-budget-lint.mjs +283 -34
  73. package/scripts/lib/command-blocker.mjs +1013 -58
  74. package/scripts/lib/config/config-protection.mjs +2 -1
  75. package/scripts/lib/config/context-coverage.mjs +82 -0
  76. package/scripts/lib/config/drift-check.mjs +9 -1
  77. package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
  78. package/scripts/lib/config/issue-budget.mjs +123 -0
  79. package/scripts/lib/config/moc-staleness.mjs +98 -0
  80. package/scripts/lib/config/reconcile.mjs +21 -0
  81. package/scripts/lib/config/section-extractor.mjs +121 -1
  82. package/scripts/lib/config/worktree-orphans.mjs +138 -0
  83. package/scripts/lib/config-schema.mjs +23 -3
  84. package/scripts/lib/config.mjs +32 -0
  85. package/scripts/lib/context-coverage-banner.mjs +223 -0
  86. package/scripts/lib/convergence-monitor.mjs +49 -3
  87. package/scripts/lib/description-surface.mjs +535 -0
  88. package/scripts/lib/dispatcher/enumerate.mjs +136 -30
  89. package/scripts/lib/dispatcher/rank.mjs +22 -8
  90. package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
  91. package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
  92. package/scripts/lib/eval/engine.mjs +47 -5
  93. package/scripts/lib/events.mjs +59 -7
  94. package/scripts/lib/evolve/autonomy-verdict.mjs +5 -0
  95. package/scripts/lib/evolve/autopilot-effectiveness.mjs +54 -7
  96. package/scripts/lib/gates/gate-full.mjs +15 -3
  97. package/scripts/lib/gates/gate-helpers.mjs +132 -6
  98. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
  99. package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
  100. package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
  101. package/scripts/lib/handover-gate.mjs +7 -3
  102. package/scripts/lib/harness-audit/categories/category4.mjs +22 -5
  103. package/scripts/lib/instruction-budget-guard.mjs +402 -51
  104. package/scripts/lib/io.mjs +345 -10
  105. package/scripts/lib/issue-budget.mjs +269 -0
  106. package/scripts/lib/issue-close-strip-labels.mjs +39 -9
  107. package/scripts/lib/label-scope.mjs +47 -0
  108. package/scripts/lib/learnings/schema.mjs +43 -3
  109. package/scripts/lib/lock-reaper.mjs +1 -2
  110. package/scripts/lib/memory-proposals/schema.mjs +36 -1
  111. package/scripts/lib/moc-staleness-banner.mjs +267 -0
  112. package/scripts/lib/peer-discovery.mjs +645 -0
  113. package/scripts/lib/pi-hook-bridge.mjs +146 -17
  114. package/scripts/lib/product-repo-detect.mjs +9 -8
  115. package/scripts/lib/project-hygiene.mjs +432 -0
  116. package/scripts/lib/quality-gate.mjs +167 -0
  117. package/scripts/lib/recommendations-v0.mjs +1 -1
  118. package/scripts/lib/reconcile/eligibility.mjs +1 -1
  119. package/scripts/lib/reconcile/emitter.mjs +23 -4
  120. package/scripts/lib/reconcile/engine.mjs +147 -39
  121. package/scripts/lib/reconcile/idempotency.mjs +114 -14
  122. package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
  123. package/scripts/lib/resource-probe/evaluate.mjs +70 -4
  124. package/scripts/lib/resource-probe.mjs +19 -0
  125. package/scripts/lib/rule-loader.mjs +6 -0
  126. package/scripts/lib/scope-baseline.mjs +564 -0
  127. package/scripts/lib/scope-gate.mjs +399 -98
  128. package/scripts/lib/session-close-backfill.mjs +61 -6
  129. package/scripts/lib/session-end/phase-skip.mjs +1 -0
  130. package/scripts/lib/session-end/worktree-orphan-sweep.mjs +252 -0
  131. package/scripts/lib/session-id.mjs +221 -41
  132. package/scripts/lib/session-lock.mjs +304 -6
  133. package/scripts/lib/session-schema/constants.mjs +22 -3
  134. package/scripts/lib/session-schema/filters.mjs +88 -0
  135. package/scripts/lib/session-schema/validator.mjs +16 -0
  136. package/scripts/lib/session-schema.mjs +1 -0
  137. package/scripts/lib/sessions-integrity-banner.mjs +294 -0
  138. package/scripts/lib/sessions-staleness-banner.mjs +121 -12
  139. package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
  140. package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
  141. package/scripts/lib/skill-health/join.mjs +35 -9
  142. package/scripts/lib/spiral-carryover.mjs +142 -30
  143. package/scripts/lib/state-md/mission-status.mjs +53 -3
  144. package/scripts/lib/subagents-schema.mjs +43 -9
  145. package/scripts/lib/telemetry/anon-id.mjs +141 -0
  146. package/scripts/lib/telemetry/consent.mjs +299 -0
  147. package/scripts/lib/telemetry/paths.mjs +27 -0
  148. package/scripts/lib/telemetry/queue.mjs +287 -0
  149. package/scripts/lib/telemetry/schema.mjs +384 -0
  150. package/scripts/lib/telemetry/sync.mjs +312 -0
  151. package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
  152. package/scripts/lib/tests-src-ratio.mjs +484 -0
  153. package/scripts/lib/validate/check-agents.mjs +56 -0
  154. package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
  155. package/scripts/lib/validate/check-rules.mjs +217 -35
  156. package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
  157. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  158. package/scripts/lib/validate-vendored-rules.mjs +10 -2
  159. package/scripts/lib/vault-archive.mjs +17 -2
  160. package/scripts/lib/vault-backfill/glab.mjs +8 -0
  161. package/scripts/lib/vault-mirror/process.mjs +30 -0
  162. package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
  163. package/scripts/lib/vault-status/board-writer.mjs +63 -5
  164. package/scripts/lib/vault-status/narrative-mirror.mjs +13 -7
  165. package/scripts/lib/vcs-repo-spec.mjs +362 -0
  166. package/scripts/lib/wave-resource-gate.mjs +115 -11
  167. package/scripts/lib/worktree/listing.mjs +44 -7
  168. package/scripts/mcp-server.sh +32 -6
  169. package/scripts/measure-context-overhead.sh +151 -0
  170. package/scripts/memory-propose.mjs +72 -9
  171. package/scripts/print-applicable-rules.mjs +51 -12
  172. package/scripts/release.mjs +534 -0
  173. package/scripts/run-quality-gate.mjs +123 -5
  174. package/scripts/telemetry.mjs +250 -0
  175. package/scripts/validate-wave-scope.mjs +182 -17
  176. package/scripts/vault-integration-watcher.mjs +32 -10
  177. package/skills/_shared/config-reading.md +2 -2
  178. package/skills/bootstrap/fast-template.md +1 -1
  179. package/skills/claude-md-drift-check/checker.mjs +145 -28
  180. package/skills/contract-version-bump/SKILL.md +219 -0
  181. package/skills/discovery/SKILL.md +4 -4
  182. package/skills/discovery/issue-templates.md +11 -11
  183. package/skills/discovery/probes-audit.md +1 -1
  184. package/skills/discovery/probes-feature.md +1 -1
  185. package/skills/discovery/probes-session.md +26 -5
  186. package/skills/ecosystem-health/SKILL.md +1 -1
  187. package/skills/ecosystem-health/wizard.md +4 -4
  188. package/skills/evolve/SKILL.md +1 -0
  189. package/skills/gitlab-ops/SKILL.md +20 -12
  190. package/skills/gitlab-portfolio/SKILL.md +2 -2
  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 +97 -0
  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/quality-gates/SKILL.md +1 -1
  199. package/skills/reconcile/SKILL.md +21 -4
  200. package/skills/session-end/SKILL.md +108 -14
  201. package/skills/session-end/discovery-scan.md +4 -2
  202. package/skills/session-end/drift-operations.md +4 -4
  203. package/skills/session-end/metrics-collection.md +13 -0
  204. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  205. package/skills/session-end/phase-3-6-tail.md +2 -1
  206. package/skills/session-end/plan-verification.md +5 -2
  207. package/skills/session-end/vault-operations.md +1 -1
  208. package/skills/session-end/verification-checklist.md +1 -1
  209. package/skills/session-plan/SKILL.md +6 -2
  210. package/skills/session-plan/wave-template.md +2 -0
  211. package/skills/session-start/SKILL.md +149 -7
  212. package/skills/session-start/phase-4-5-resource-health.md +15 -2
  213. package/skills/test-runner/SKILL.md +2 -2
  214. package/skills/vault-sync/SKILL.md +1 -1
  215. package/skills/vault-sync/package-lock.json +3 -3
  216. package/skills/vault-sync/validator.mjs +228 -40
  217. package/skills/wave-executor/SKILL.md +5 -2
  218. package/skills/wave-executor/circuit-breaker.md +2 -0
  219. package/skills/wave-executor/wave-loop.md +163 -10
  220. 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 } 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,36 @@ 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
+ *
265
576
  * Hook-safe: pure, deterministic, no I/O. Never throws — a non-string / empty
266
577
  * input returns `[]`.
267
578
  *
@@ -271,12 +582,12 @@ export function assertFileScopeSubset(fileScope, allowedPaths) {
271
582
  export function extractBashWriteTargets(command) {
272
583
  if (typeof command !== 'string' || command.length === 0) return [];
273
584
 
274
- const tokens = tokenizeShellForWrites(command);
585
+ const tokens = classifyShellTokens(tokenizeCommand(command));
275
586
 
276
587
  const out = [];
277
588
  const seen = new Set();
278
- const add = (value, hadSpace) => {
279
- if (shouldSkipWriteTarget(value, hadSpace)) return;
589
+ const add = (value) => {
590
+ if (shouldSkipWriteTarget(value)) return;
280
591
  const v = value.replace(/^\.\//, '');
281
592
  if (!v || seen.has(v)) return;
282
593
  seen.add(v);
@@ -288,6 +599,11 @@ export function extractBashWriteTargets(command) {
288
599
  // token is a redirect target.
289
600
  let mode = null; // null | 'tee' | 'sed' | 'dd'
290
601
  let pendingRedirect = false;
602
+ // A here-doc was opened: `tokenizeCommand` will emit its BODY as an ordinary
603
+ // quoted word token, and a single-word body (`hello`) would otherwise be read
604
+ // as a `tee`/`sed`/`dd` file argument. Explicit `>` redirect targets stay
605
+ // accepted — those carry their own operator and cannot be body text.
606
+ let heredocOpen = false;
291
607
  let expectCommand = true; // next word is the command head of this segment
292
608
  let sedArgs = []; // { value } collected for a `sed` head
293
609
  let sedInPlace = false;
@@ -296,7 +612,7 @@ export function extractBashWriteTargets(command) {
296
612
  if (mode === 'sed' && sedInPlace) {
297
613
  for (let i = sedArgs.length - 1; i >= 0; i--) {
298
614
  if (!isShellFlag(sedArgs[i].value)) {
299
- add(sedArgs[i].value, sedArgs[i].hadSpace);
615
+ add(sedArgs[i].value);
300
616
  break;
301
617
  }
302
618
  }
@@ -310,8 +626,14 @@ export function extractBashWriteTargets(command) {
310
626
  pendingRedirect = true;
311
627
  continue;
312
628
  }
629
+ if (tk.type === 'heredoc') {
630
+ // here-doc: the delimiter emits no token; the BODY arrives later as data
631
+ pendingRedirect = false;
632
+ heredocOpen = true;
633
+ continue;
634
+ }
313
635
  if (tk.type === 'in') {
314
- // input redirect / heredoc delimiter — not a write target
636
+ // input redirect / here-string — not a write target
315
637
  pendingRedirect = false;
316
638
  continue;
317
639
  }
@@ -319,12 +641,13 @@ export function extractBashWriteTargets(command) {
319
641
  flushSed();
320
642
  mode = null;
321
643
  pendingRedirect = false;
644
+ heredocOpen = false;
322
645
  expectCommand = true;
323
646
  continue;
324
647
  }
325
648
  // word token
326
649
  if (pendingRedirect) {
327
- add(tk.value, tk.hadSpace);
650
+ add(tk.value);
328
651
  pendingRedirect = false;
329
652
  continue;
330
653
  }
@@ -338,13 +661,14 @@ export function extractBashWriteTargets(command) {
338
661
  continue;
339
662
  }
340
663
  // subsequent argument words, interpreted per active command-head mode
664
+ if (heredocOpen) continue; // DATA, not a file argument
341
665
  if (mode === 'tee') {
342
- if (!isShellFlag(tk.value)) add(tk.value, tk.hadSpace);
666
+ if (!isShellFlag(tk.value)) add(tk.value);
343
667
  } else if (mode === 'sed') {
344
668
  if (/^-i/.test(tk.value)) sedInPlace = true;
345
669
  sedArgs.push(tk);
346
670
  } else if (mode === 'dd') {
347
- if (tk.value.startsWith('of=')) add(tk.value.slice(3), tk.hadSpace);
671
+ if (tk.value.startsWith('of=')) add(tk.value.slice(3));
348
672
  }
349
673
  }
350
674
  flushSed();
@@ -365,13 +689,15 @@ function isShellFlag(v) {
365
689
  /**
366
690
  * Skip-rule gate for a candidate write target — see the documented skip list on
367
691
  * {@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
692
+ * @param {string} value — unquoted target text (quotes already stripped by the lexer)
370
693
  * @returns {boolean}
371
694
  */
372
- function shouldSkipWriteTarget(value, hadSpace) {
695
+ function shouldSkipWriteTarget(value) {
373
696
  if (typeof value !== 'string' || value.length === 0) return true;
374
- if (hadSpace || value.includes(' ')) return true; // quoted-with-space (best-effort)
697
+ // Any embedded whitespace: a quoted-with-space path (best-effort — far more
698
+ // likely a quoting artefact than a real wave-scoped file) and, since #970, a
699
+ // multi-line here-doc BODY token that reached an argument slot as DATA.
700
+ if (/\s/.test(value)) return true;
375
701
  if (value.startsWith('$') || value.startsWith('~')) return true; // variable / expansion
376
702
  if (value.includes('$')) return true; // any embedded expansion (covers ${TMPDIR})
377
703
  if (value.includes('(') || value.includes(')')) return true; // process-sub remnants
@@ -380,99 +706,74 @@ function shouldSkipWriteTarget(value, hadSpace) {
380
706
  return false;
381
707
  }
382
708
 
709
+ /** Unquoted `tokenizeCommand` operator texts that break the current command. */
710
+ const SHELL_SEPARATOR_OPS = new Set([';', '|', '||', '&', '&&']);
711
+ /** Write redirects: `>`, `>>`, `>|`, and fd-prefixed forms (`2>`, `2>>`). */
712
+ const WRITE_REDIRECT_OP_RE = /^\d*(?:>>|>\|?)$/;
713
+ /** Here-doc operators `<<` / `<<-` — a DATA body token follows at the next newline. */
714
+ const HEREDOC_OP_RE = /^\d*<<-?$/;
715
+ /** Other input redirects `<` / `<<<` (here-string) — never a write target, no body. */
716
+ const INPUT_REDIRECT_OP_RE = /^\d*(?:<<<|<)$/;
717
+
718
+ /**
719
+ * Split leading `(` / trailing `)` off an UNQUOTED word token into standalone
720
+ * separator tokens.
721
+ *
722
+ * `tokenizeCommand` does not treat parentheses as control operators (it does not
723
+ * need to), so `(echo x > y)` arrives as words `(echo` … `y)`. Without this the
724
+ * subshell's redirect target would read as the literal `y)` and be dropped by the
725
+ * paren skip rule in {@link shouldSkipWriteTarget} — a silent DETECTION LOSS
726
+ * versus the pre-#970 lexer. Peeling restores it, and process substitution
727
+ * `> >(cat)` still yields nothing because the peeled `cat` lands in command-head
728
+ * position rather than redirect-target position.
729
+ *
730
+ * Quoted tokens are never peeled: `echo '(' x` must keep its literal paren.
731
+ *
732
+ * @param {{text: string, quoted: boolean}} tok
733
+ * @returns {Array<{type: string, value?: string}>}
734
+ */
735
+ function peelSubshellParens(tok) {
736
+ if (tok.quoted) return [{ type: 'word', value: tok.text }];
737
+ let text = tok.text;
738
+ const out = [];
739
+ while (text.startsWith('(')) { out.push({ type: 'sep' }); text = text.slice(1); }
740
+ const trailing = [];
741
+ while (text.endsWith(')')) { trailing.push({ type: 'sep' }); text = text.slice(0, -1); }
742
+ if (text.length > 0 || (out.length === 0 && trailing.length === 0)) {
743
+ out.push({ type: 'word', value: text });
744
+ }
745
+ return out.concat(trailing);
746
+ }
747
+
383
748
  /**
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.
749
+ * Re-shape the shared `tokenizeCommand` output into the redirect/in/sep/word
750
+ * stream {@link extractBashWriteTargets} interprets. Operator classification is
751
+ * applied ONLY to unquoted tokens, so `echo '>' x` keeps its `>` as literal text.
752
+ *
753
+ * `&>` is not a distinct token here: `tokenizeCommand` deliberately lexes it as
754
+ * `&` followed by `>` (#965 Risk C). That still works — the redirect branch of the
755
+ * interpretation loop is checked before the command-head branch, so `cmd &> log`
756
+ * yields `log`. `2>&1` likewise yields nothing: the `&` separator resets the
757
+ * pending redirect before `1` is read.
389
758
  *
390
759
  * Token shapes: { type: 'redirect' } | { type: 'in' } | { type: 'sep' }
391
- * | { type: 'word', value: string, hadSpace: boolean }
760
+ * | { type: 'word', value: string }
392
761
  *
393
- * @param {string} command
394
- * @returns {Array<{type:string, value?:string, hadSpace?:boolean}>}
762
+ * @param {Array<{text: string, quoted: boolean}>} tokens
763
+ * @returns {Array<{type: string, value?: string}>}
395
764
  */
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;
440
- }
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)
457
- continue;
458
- }
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)
464
- continue;
465
- }
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++;
765
+ function classifyShellTokens(tokens) {
766
+ const out = [];
767
+ for (const tok of tokens) {
768
+ if (!tok.quoted) {
769
+ if (SHELL_SEPARATOR_OPS.has(tok.text)) { out.push({ type: 'sep' }); continue; }
770
+ if (WRITE_REDIRECT_OP_RE.test(tok.text)) { out.push({ type: 'redirect' }); continue; }
771
+ if (HEREDOC_OP_RE.test(tok.text)) { out.push({ type: 'heredoc' }); continue; }
772
+ if (INPUT_REDIRECT_OP_RE.test(tok.text)) { out.push({ type: 'in' }); continue; }
471
773
  }
472
- tokens.push({ type: 'word', value, hadSpace, quoted });
774
+ out.push(...peelSubshellParens(tok));
473
775
  }
474
-
475
- return tokens;
776
+ return out;
476
777
  }
477
778
 
478
779
  /**