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
@@ -15,6 +15,17 @@
15
15
  * fileScope file (a JSON array of strings) and assert
16
16
  * it is a subset of wave-scope.allowedPaths (#796).
17
17
  * Fails (exit 1) with "missing: [...]" on violation.
18
+ * --expand-test-siblings With --assert-subset: ALSO require allowedPaths to
19
+ * grant the test sibling of every concrete production
20
+ * file in the agent fileScope (#970). Default OFF.
21
+ * Only ever ADDS a requirement — never relaxes the
22
+ * subset assertion above.
23
+ * GATED ON THE MANIFEST'S OWN `role`: the assertion
24
+ * fires only for the roles where expansion fires
25
+ * (scope-gate.mjs TEST_SIBLING_EXPANSION_ROLES), so
26
+ * the caller may pass the flag unconditionally on
27
+ * every pre-dispatch check. A skip is announced on
28
+ * stderr as a WARN.
18
29
  *
19
30
  * Exit codes:
20
31
  * 0 — valid (validated JSON echoed to stdout)
@@ -22,9 +33,15 @@
22
33
  * 2 — I/O error (file not found, unreadable stdin, unreadable --assert-subset file)
23
34
  */
24
35
 
36
+ import path from 'node:path';
25
37
  import { readFileSync, existsSync, statSync } from 'node:fs';
26
38
  import { warn } from './lib/common.mjs';
27
- import { assertFileScopeSubset } from './lib/scope-gate.mjs';
39
+ import {
40
+ assertFileScopeSubset,
41
+ assertTestSiblingCoverage,
42
+ testSiblingExpansionApplies,
43
+ TEST_SIBLING_EXPANSION_ROLES,
44
+ } from './lib/scope-gate.mjs';
28
45
 
29
46
  /**
30
47
  * Write an error to stderr and exit with the given code.
@@ -40,19 +57,23 @@ function die(msg, code = 1) {
40
57
  /**
41
58
  * Parse CLI flags out of argv, leaving positional args behind.
42
59
  *
43
- * Recognised: `--assert-subset <path>` (#796). Everything else is treated as a
44
- * positional argument (the wave-scope.json file path), preserving legacy
45
- * behaviour where argv[2] is the input file.
60
+ * Recognised: `--assert-subset <path>` (#796) and `--expand-test-siblings`
61
+ * (#970). Everything else is treated as a positional argument (the
62
+ * wave-scope.json file path), preserving legacy behaviour where argv[2] is the
63
+ * input file.
46
64
  *
47
65
  * @param {string[]} argv - full process.argv
48
- * @returns {{ assertSubset: string|null, positionals: string[] }}
66
+ * @returns {{ assertSubset: string|null, expandTestSiblings: boolean, positionals: string[] }}
49
67
  */
50
68
  function parseArgs(argv) {
51
69
  const positionals = [];
52
70
  let assertSubset = null;
71
+ let expandTestSiblings = false;
53
72
  for (let i = 2; i < argv.length; i++) {
54
73
  const a = argv[i];
55
- if (a === '--assert-subset') {
74
+ if (a === '--expand-test-siblings') {
75
+ expandTestSiblings = true;
76
+ } else if (a === '--assert-subset') {
56
77
  assertSubset = argv[i + 1];
57
78
  if (assertSubset === undefined) {
58
79
  die('--assert-subset requires a file-path argument', 1);
@@ -62,7 +83,7 @@ function parseArgs(argv) {
62
83
  positionals.push(a);
63
84
  }
64
85
  }
65
- return { assertSubset, positionals };
86
+ return { assertSubset, expandTestSiblings, positionals };
66
87
  }
67
88
 
68
89
  /**
@@ -139,9 +160,91 @@ function validateRequired(obj, errors) {
139
160
  }
140
161
  }
141
162
 
163
+ /**
164
+ * Literal filesystem-root forms — POSIX "/" and the Windows equivalents "\"
165
+ * and a bare drive root ("C:\", "C:\\", ...). Checked independently of
166
+ * `path.isAbsolute()` because that primitive is platform-native: on a POSIX
167
+ * host (this repo's dev/CI hosts) it never reports `C:\` as absolute, so a
168
+ * Windows-literal-root entry would otherwise slip past every check below.
169
+ * @type {ReadonlySet<string>}
170
+ */
171
+ const FILESYSTEM_ROOT_LITERALS = new Set(['/', '\\']);
172
+ const WINDOWS_DRIVE_ROOT_RE = /^[A-Za-z]:\\+$/;
173
+
174
+ /**
175
+ * @param {string} entry
176
+ * @returns {boolean}
177
+ */
178
+ function isFilesystemRootLiteral(entry) {
179
+ return FILESYSTEM_ROOT_LITERALS.has(entry) || WINDOWS_DRIVE_ROOT_RE.test(entry);
180
+ }
181
+
182
+ /**
183
+ * Well-known top-level system/home directories. A FIXED DENYLIST (not a
184
+ * segment-count threshold): the #792 legitimate grant
185
+ * (`/private/tmp/<session>/scratchpad/**`) is itself a single-segment-deep
186
+ * grant under an unusual root ("private"), so any segment-count heuristic
187
+ * tight enough to catch `/etc/**` risks catching that too, or must be tuned
188
+ * loosely enough to leave a gap. A fixed list of the directories a
189
+ * hallucinated/mis-copied wave-scope entry could plausibly land on is
190
+ * predictable, auditable in a one-line diff, and does not touch legitimate
191
+ * deep grants under any other root.
192
+ * @type {ReadonlySet<string>}
193
+ */
194
+ const DENIED_ABSOLUTE_TOP_SEGMENTS = new Set([
195
+ 'etc',
196
+ 'Users',
197
+ 'home',
198
+ 'root',
199
+ 'bin',
200
+ 'sbin',
201
+ 'usr',
202
+ 'System',
203
+ 'var',
204
+ 'boot',
205
+ 'dev',
206
+ 'proc',
207
+ 'sys',
208
+ 'Library',
209
+ 'Applications',
210
+ 'Windows',
211
+ ]);
212
+
213
+ /**
214
+ * The top-level path segment of an absolute POSIX-style entry (the segment
215
+ * immediately after the leading "/"), if it is on the denylist above.
216
+ * @param {string} entry
217
+ * @returns {string|null}
218
+ */
219
+ function deniedTopSegment(entry) {
220
+ const first = entry.split('/').filter(Boolean)[0];
221
+ return first && DENIED_ABSOLUTE_TOP_SEGMENTS.has(first) ? first : null;
222
+ }
223
+
224
+ /**
225
+ * Does this entry contain a glob wildcard? Mirrors this codebase's own glob
226
+ * convention (`isGlobScopeEntry` in scripts/lib/scope-gate.mjs / #796): `*`
227
+ * is the sole wildcard metachar used in allowedPaths/fileScope entries
228
+ * throughout this repo (no `?`/`[]`/`{}` glob syntax is supported or tested
229
+ * anywhere else in scope-gate.mjs or enforce-scope.mjs).
230
+ * @param {string} entry
231
+ * @returns {boolean}
232
+ */
233
+ function hasWildcard(entry) {
234
+ return entry.includes('*');
235
+ }
236
+
142
237
  /**
143
238
  * Validate allowedPaths array: must exist, be an array of non-empty strings,
144
- * with no absolute paths and no path-traversal segments.
239
+ * with no path-traversal segments. Absolute (out-of-repo) entries are a
240
+ * SANCTIONED Gate 5b grant (#792) — WARN, not reject; see #870. A narrow
241
+ * catastrophic subclass of absolute entries is instead a hard ERROR
242
+ * (#870-followup security review, confidence 0.85): allowedPaths is not
243
+ * hand-authored — wave-loop.md's Scope Manifest computes it PROGRAMMATICALLY
244
+ * as the union of LLM-authored per-agent "Files:" scopes — so a
245
+ * hallucinated/mis-copied/injected entry reaching one of these shapes must
246
+ * hard-fail rather than rely on a stderr WARN nothing guarantees a human
247
+ * reads before dispatch.
145
248
  * @param {Record<string, unknown>} obj
146
249
  * @param {string[]} errors
147
250
  * @param {string[]} warnings
@@ -161,11 +264,46 @@ function validateAllowedPaths(obj, errors, warnings) {
161
264
  errors.push('allowedPaths contains empty string');
162
265
  continue;
163
266
  }
164
- // Reject absolute paths (must be repo-relative)
165
- if (entry.startsWith('/')) {
166
- errors.push(`allowedPaths contains absolute path: ${entry}`);
267
+ // #870: an explicit absolute entry is a SANCTIONED out-of-repo grant — mirrors
268
+ // hooks/enforce-scope.mjs Gate 5b (matchesAbsoluteAllowlist), which honours ANY
269
+ // syntactically-absolute allowedPaths entry (path.isAbsolute) against the
270
+ // realpath-resolved write candidate. Using `path.isAbsolute` (not a hand-rolled
271
+ // `startsWith('/')`) keeps the same platform-native semantics Gate 5b uses.
272
+ // WARN, not reject — the validator must not contradict the hook it validates
273
+ // for (#792 / #870) — EXCEPT for the narrow catastrophic subclass below
274
+ // (#870-followup), which hard-rejects regardless of what Gate 5b would do
275
+ // with it: pre-flight validation exists precisely to catch a grant this bad
276
+ // before the hook is ever consulted.
277
+ if (path.isAbsolute(entry) || isFilesystemRootLiteral(entry)) {
278
+ if (isFilesystemRootLiteral(entry)) {
279
+ errors.push(
280
+ `allowedPaths grants the entire filesystem root: ${entry} — refused unconditionally, this can never be a valid wave scope`,
281
+ );
282
+ } else {
283
+ const denied = deniedTopSegment(entry);
284
+ if (denied) {
285
+ errors.push(
286
+ `allowedPaths contains a well-known system/home directory grant: ${entry} (top-level segment "${denied}" is denylisted) — refused, scope a narrower path instead`,
287
+ );
288
+ } else if (!hasWildcard(entry)) {
289
+ errors.push(
290
+ `allowedPaths contains a bare absolute file grant with no wildcard: ${entry} — a single concrete out-of-repo file has no established legitimate use in this codebase; scope a glob instead`,
291
+ );
292
+ } else {
293
+ warnings.push(
294
+ `allowedPaths contains an absolute (out-of-repo) path: ${entry} — honoured by hooks/enforce-scope.mjs Gate 5b; verify this grant is intentional`,
295
+ );
296
+ }
297
+ }
167
298
  }
168
- // Reject path traversal: any `../` segment
299
+ // Reject path traversal: any `../` segment. INDEPENDENT of the absolute checks
300
+ // above — an absolute entry that ALSO contains `../` must still be rejected
301
+ // here, unconditionally, even when it was already rejected above for a
302
+ // different reason. (Such an entry could never actually match Gate 5b's
303
+ // realpath-canonicalised candidate anyway, since realpath strips `..`
304
+ // segments before Gate 5b ever compares it — see enforce-scope.mjs REQ-09 —
305
+ // but validate-time rejection gives the operator immediate, actionable
306
+ // feedback instead of leaving a silently-dead allowedPaths entry.)
169
307
  if (entry.includes('../')) {
170
308
  errors.push(`allowedPaths contains path traversal: ${entry}`);
171
309
  }
@@ -224,8 +362,9 @@ function validateGates(obj, errors) {
224
362
  *
225
363
  * @param {Record<string, unknown>} obj - the already schema-validated wave-scope object
226
364
  * @param {string} fileScopePath - path to the agent fileScope JSON file
365
+ * @param {boolean} [expandTestSiblings] - also assert #970 test-sibling coverage
227
366
  */
228
- function assertSubsetOrDie(obj, fileScopePath) {
367
+ function assertSubsetOrDie(obj, fileScopePath, expandTestSiblings = false) {
229
368
  if (!existsSync(fileScopePath) || !statSync(fileScopePath).isFile()) {
230
369
  die(`Cannot read --assert-subset file: ${fileScopePath}`, 2);
231
370
  }
@@ -248,14 +387,40 @@ function assertSubsetOrDie(obj, fileScopePath) {
248
387
  if (!ok) {
249
388
  die(`agent fileScope not ⊆ allowedPaths — missing: [${missing.join(', ')}]`, 1);
250
389
  }
390
+ // #970 — opt-in, and deliberately AFTER the plain subset assertion so the
391
+ // pre-existing failure mode keeps its exact message. This only ever adds a
392
+ // requirement: the union must also grant each production file's test sibling,
393
+ // or the agent is mechanically unable to update the test it just broke.
394
+ //
395
+ // The manifest's OWN `role` (already schema-validated as a non-empty string
396
+ // above) gates it, through the same predicate the expander uses. That is what
397
+ // lets the pre-dispatch command carry the flag unconditionally: on a Quality
398
+ // phase-1 manifest — production files with tests deliberately excluded — an
399
+ // ungated assertion would block every dispatch of that phase.
400
+ if (expandTestSiblings) {
401
+ if (!testSiblingExpansionApplies({ role: obj.role })) {
402
+ warn(
403
+ `--expand-test-siblings: skipped for role "${obj.role}" — test-sibling expansion applies only to [${TEST_SIBLING_EXPANSION_ROLES.join(', ')}] (scripts/lib/scope-gate.mjs)`,
404
+ );
405
+ return;
406
+ }
407
+ const sib = assertTestSiblingCoverage(fileScope, obj.allowedPaths, { role: obj.role });
408
+ if (!sib.ok) {
409
+ die(
410
+ `allowedPaths does not grant the test sibling of every production file in the agent fileScope (#970) — missing: [${sib.missing.join(', ')}]. Re-run the Scope Manifest step: allowedPaths must be expandTestSiblings(union, { role }) — see skills/wave-executor/wave-loop.md § Scope Manifest #3`,
411
+ 1,
412
+ );
413
+ }
414
+ }
251
415
  }
252
416
 
253
417
  /**
254
418
  * Main validation entry point. Reads input, validates, exits with appropriate code.
255
419
  * @param {string} input - raw JSON string
256
420
  * @param {string|null} [assertSubsetPath] - optional agent fileScope file for the #796 subset assertion
421
+ * @param {boolean} [expandTestSiblings] - opt-in #970 test-sibling coverage assertion
257
422
  */
258
- function validate(input, assertSubsetPath = null) {
423
+ function validate(input, assertSubsetPath = null, expandTestSiblings = false) {
259
424
  const obj = parseJson(input);
260
425
  const errors = [];
261
426
  const warnings = [];
@@ -278,12 +443,12 @@ function validate(input, assertSubsetPath = null) {
278
443
 
279
444
  // #796 — optional dispatch-time subset assertion (runs only after schema validation passes)
280
445
  if (assertSubsetPath) {
281
- assertSubsetOrDie(obj, assertSubsetPath);
446
+ assertSubsetOrDie(obj, assertSubsetPath, expandTestSiblings);
282
447
  }
283
448
 
284
449
  // Echo validated JSON to stdout (trailing newline normalised)
285
450
  process.stdout.write(input.endsWith('\n') ? input : input + '\n');
286
451
  }
287
452
 
288
- const { assertSubset, positionals } = parseArgs(process.argv);
289
- validate(readInput(positionals[0]), assertSubset);
453
+ const { assertSubset, expandTestSiblings, positionals } = parseArgs(process.argv);
454
+ validate(readInput(positionals[0]), assertSubset, expandTestSiblings);
@@ -9,6 +9,9 @@
9
9
  * CLI usage:
10
10
  * node scripts/vault-integration-watcher.mjs [--dry-run] [--issue <id>] [--verbose]
11
11
  * [--glab-bin <path>] override glab binary (used by tests)
12
+ * [--repo <spec>] override the -R/--repo host-pinning spec (#872;
13
+ * used by tests — default: resolveRepoSpec() against
14
+ * the process cwd, degrading gracefully to no -R)
12
15
  * [--issue <id>] override tracking issue (default: 305)
13
16
  * [--dep-issues <id,id>] override dependency issues (default: 303,304)
14
17
  * [--dry-run] compute + print, no glab posts
@@ -32,6 +35,7 @@ import { join } from 'node:path';
32
35
  import { homedir } from 'node:os';
33
36
  import { getCrossRepoProjects, getConfinementRoot } from './lib/config/cross-repo.mjs';
34
37
  import { validatePathInsideProject } from './lib/path-utils.mjs';
38
+ import { resolveRepoSpec, redactUrlCredentials } from './lib/vcs-repo-spec.mjs';
35
39
 
36
40
  // ── Argument parsing ──────────────────────────────────────────────────────────
37
41
 
@@ -49,6 +53,17 @@ const DEP_ISSUES_RAW = getArg('--dep-issues') ?? '303,304';
49
53
  const DEP_ISSUES = DEP_ISSUES_RAW.split(',').map((s) => s.trim());
50
54
  const GLAB_BIN = getArg('--glab-bin') ?? process.env.GLAB_BIN ?? 'glab';
51
55
 
56
+ // -R/--repo host-pinning spec (#872) — resolved ONCE at module load, reused
57
+ // by every glab call site below (glabJson + postComment cover all 4 call
58
+ // sites: fetchIssues, fetchComments, the tracking-issue view in main(), and
59
+ // the note-add write). `--repo <spec>` is an explicit override (analogous to
60
+ // `--glab-bin`, used by tests); otherwise falls back to the real
61
+ // `resolveRepoSpec` (shells out to `git remote get-url`). Graceful
62
+ // degradation: undefined ⇒ no -R appended anywhere, matching the ambient
63
+ // glab/GITLAB_HOST fallback this watcher always used pre-#872 — never a hard
64
+ // fail, per #839's "never emit -R undefined" contract.
65
+ const REPO_SPEC = getArg('--repo') ?? resolveRepoSpec({ repoRoot: process.cwd(), vcs: 'gitlab' });
66
+
52
67
  // ── Logging ───────────────────────────────────────────────────────────────────
53
68
 
54
69
  function log(msg) {
@@ -56,7 +71,12 @@ function log(msg) {
56
71
  }
57
72
 
58
73
  function verbose(msg) {
59
- if (VERBOSE) log(msg);
74
+ // Defense-in-depth (#907, CWE-214): the resolved REPO_SPEC is already
75
+ // credential-stripped at the source (resolveRepoSpec), but a `--repo <spec>`
76
+ // override bypasses that strip and flows verbatim into the `glab … -R <spec>`
77
+ // line logged here. Redact any embedded userinfo credential before it reaches
78
+ // the CI job's stderr.
79
+ if (VERBOSE) log(redactUrlCredentials(msg));
60
80
  }
61
81
 
62
82
  function emit(action) {
@@ -66,13 +86,17 @@ function emit(action) {
66
86
  // ── glab helpers ──────────────────────────────────────────────────────────────
67
87
 
68
88
  /**
69
- * Run a glab command and return parsed JSON output.
89
+ * Run a glab command and return parsed JSON output. Every call is pinned to
90
+ * -R <REPO_SPEC> (#872) when a spec resolved — appended centrally here so
91
+ * all glabJson call sites (fetchIssues, fetchComments, main()'s tracking-issue
92
+ * view) inherit the pin without each needing to build it themselves.
70
93
  * @param {string[]} args
71
94
  * @returns {unknown}
72
95
  */
73
96
  function glabJson(args) {
74
- verbose(`glab ${args.join(' ')}`);
75
- const result = spawnSync(GLAB_BIN, args, { encoding: 'utf8', timeout: 30_000 });
97
+ const fullArgs = REPO_SPEC ? [...args, '-R', REPO_SPEC] : args;
98
+ verbose(`glab ${fullArgs.join(' ')}`);
99
+ const result = spawnSync(GLAB_BIN, fullArgs, { encoding: 'utf8', timeout: 30_000 });
76
100
  if (result.error) {
77
101
  log(`glab spawn error: ${result.error.message}`);
78
102
  process.exit(2);
@@ -90,7 +114,7 @@ function glabJson(args) {
90
114
  }
91
115
 
92
116
  /**
93
- * Post a comment to an issue.
117
+ * Post a comment to an issue. Pinned to -R <REPO_SPEC> (#872) — see glabJson.
94
118
  * @param {string} issueId
95
119
  * @param {string} body
96
120
  */
@@ -101,11 +125,9 @@ function postComment(issueId, body) {
101
125
  return;
102
126
  }
103
127
  verbose(`posting comment to #${issueId}`);
104
- const result = spawnSync(
105
- GLAB_BIN,
106
- ['issue', 'note', 'add', issueId, '--message', body],
107
- { encoding: 'utf8', timeout: 30_000 }
108
- );
128
+ const args = ['issue', 'note', 'add', issueId, '--message', body];
129
+ if (REPO_SPEC) args.push('-R', REPO_SPEC);
130
+ const result = spawnSync(GLAB_BIN, args, { encoding: 'utf8', timeout: 30_000 });
109
131
  if (result.error) {
110
132
  log(`glab note add spawn error: ${result.error.message}`);
111
133
  process.exit(2);
@@ -195,7 +195,7 @@ Flow-style arrays are also accepted: `globs: ["src/**", "tests/**"]`.
195
195
  In the live path the coordinator does NOT call the loader directly — it runs the CLI `scripts/print-applicable-rules.mjs` (which calls the loader) once per wave and captures stdout as the `<APPLICABLE-RULES>` block:
196
196
 
197
197
  ```sh
198
- RULES_BLOCK="$(node "$PLUGIN_ROOT/scripts/print-applicable-rules.mjs" 2>/dev/null)"
198
+ RULES_BLOCK="$(node "$PLUGIN_ROOT/scripts/print-applicable-rules.mjs" --context wave 2>/dev/null)"
199
199
  # Empty stdout (no .claude/rules/, no matches, or any failure) → inject nothing.
200
200
  ```
201
201
 
@@ -219,7 +219,7 @@ const rules = loadApplicableRules({
219
219
  ### Backward compatibility
220
220
 
221
221
  - Rule files without any frontmatter continue to load as always-on. No migration required.
222
- - Files already using the old `paths:` frontmatter key do not match `globs:` they are treated as always-on until updated.
222
+ - `paths:` is a full alias for `globs:` (issue #795) — files using the old `paths:` frontmatter key load correctly SCOPED, not always-on. When BOTH `globs:` and `paths:` are present on the same rule, `globs:` wins silently (no merge, no warning) and `paths:` is discarded.
223
223
 
224
224
  ### Failure mode
225
225
 
@@ -90,7 +90,7 @@ node "$PLUGIN_ROOT/scripts/lib/claude-md-budget-lint.mjs" --repo-root "$REPO_ROO
90
90
  ```
91
91
 
92
92
  `--mode warn` is deliberate at Anlage-time — the lint informs, the operator decides; it never blocks the scaffold. Interpret the JSON `violations[]`:
93
- - `max-lines` — the instruction file is already over the lean-root ceiling (150 lines default). Recommend trimming to pointers per the lean-root convention (delegate detail to `README.md` / `.orchestrator/steering/` / `.claude/rules/*.md` — see this plugin's own `CLAUDE.md` for a worked example of the pointer pattern — note it predates the lint and currently exceeds the 150-line ceiling itself).
93
+ - `max-lines` — the instruction file is already over the lean-root ceiling. The ceiling's SSOT is the exported constant `DEFAULT_MAX_LINES` in `scripts/lib/claude-md-budget-lint.mjs` (read it, do not memorise it; `= 80` as of 2026-08-03), overridable per run with `--max-lines`. The budget counts **non-exempt effective lines**, not the raw `wc -l`: the runtime-critical `## Session Config` block is excluded, because it is machine-parsed configuration rather than trimmable prose and applying a raw-line ceiling to it was structurally unreachable (#959 — the ceiling was re-derived in these units at the same time). Recommend trimming to pointers per the lean-root convention (delegate detail to `README.md` / `.orchestrator/steering/` / `.claude/rules/*.md` — see this plugin's own `CLAUDE.md` for a worked example of the pointer pattern; it **passes** this lint, 61 non-exempt of 209 raw lines against the ceiling of 80, measured 2026-08-03 at HEAD `730ee9d` — re-run the command above rather than trusting that number).
94
94
  - `max-line-chars` — a single line exceeds the char ceiling (400 default); surface the line number for a quick manual wrap.
95
95
  - `provenance-header` — line 1 lacks a `<!-- source: ...` attribution. On the `claude init` path (Public Fast Tier, Claude Code) this is a WARN only, never a hard failure — `claude init` output is not plugin-authored and has no reason to carry the plugin's provenance convention.
96
96