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
@@ -38,6 +38,7 @@
38
38
  import { execFile as execFileCb } from 'node:child_process';
39
39
  import { promisify } from 'node:util';
40
40
  import { fingerprintFinding } from './fingerprint.mjs';
41
+ import { resolveRepoSpec } from '../vcs-repo-spec.mjs';
41
42
 
42
43
  const realExecFile = promisify(execFileCb);
43
44
 
@@ -293,6 +294,9 @@ function levenshtein(a, b) {
293
294
  * @param {Set<string>} opts.existingFingerprints - fingerprints of issues already filed
294
295
  * @param {boolean} [opts.dryRun] - if true, return command without spawning
295
296
  * @param {Function} [opts.execFile] - DI seam for testing; defaults to node:child_process execFile
297
+ * @param {string} [opts.project] - GitLab project path (passed via --repo if provided; #872: otherwise auto-detected)
298
+ * @param {string} [opts.repoRoot=process.cwd()] - cwd passed to resolveRepoSpecFn for remote auto-detection
299
+ * @param {(o: { repoRoot: string, vcs: 'gitlab' }) => string|undefined} [opts.resolveRepoSpecFn] - injectable `--repo` auto-detector; defaults to the real `resolveRepoSpec`
296
300
  * @returns {Promise<{action: 'create'|'noop', iid?: number, command?: string[]}>}
297
301
  */
298
302
  export async function reconcileFinding({
@@ -300,6 +304,9 @@ export async function reconcileFinding({
300
304
  existingFingerprints,
301
305
  dryRun = false,
302
306
  execFile: execFileOpt,
307
+ project,
308
+ repoRoot = process.cwd(),
309
+ resolveRepoSpecFn = resolveRepoSpec,
303
310
  }) {
304
311
  // Resolve execFile — DI seam for tests; never caller-supplied binary path (ADR-364 §C5 HIGH fix).
305
312
  const execFileFn = typeof execFileOpt === 'function' ? execFileOpt : realExecFile;
@@ -354,6 +361,18 @@ export async function reconcileFinding({
354
361
  body,
355
362
  ];
356
363
 
364
+ // #872: explicit project always wins; otherwise auto-detect a --repo spec
365
+ // from the local git remotes (host-pinning — a bare glab spawn falls back
366
+ // to the ambient GITLAB_HOST, which can silently target the wrong
367
+ // instance on a multi-instance host).
368
+ const effectiveProject = project ?? resolveRepoSpecFn({ repoRoot, vcs: 'gitlab' });
369
+ if (effectiveProject !== undefined) {
370
+ if (ARG_BOUNDARY_DANGEROUS.test(effectiveProject)) {
371
+ throw new ReconcileError('project contains forbidden characters', 'VALIDATION');
372
+ }
373
+ cmd.push('--repo', effectiveProject);
374
+ }
375
+
357
376
  if (dryRun) {
358
377
  return { action: 'create', command: cmd };
359
378
  }
@@ -385,10 +404,12 @@ export async function reconcileFinding({
385
404
  * fingerprints from issue bodies for downstream dedup.
386
405
  *
387
406
  * @param {object} [opts]
388
- * @param {string} [opts.project] - GitLab project path (passed via --repo if provided)
407
+ * @param {string} [opts.project] - GitLab project path (passed via --repo if provided; #872: otherwise auto-detected)
389
408
  * @param {string} [opts.label='from:test-runner'] - label filter for the query
390
409
  * @param {number} [opts.maxBuffer=4194304] - maxBuffer for execFile (4 MB default, #389)
391
410
  * @param {Function} [opts.execFile] - DI seam for testing; defaults to node:child_process execFile
411
+ * @param {string} [opts.repoRoot=process.cwd()] - cwd passed to resolveRepoSpecFn for remote auto-detection
412
+ * @param {(o: { repoRoot: string, vcs: 'gitlab' }) => string|undefined} [opts.resolveRepoSpecFn] - injectable `--repo` auto-detector; defaults to the real `resolveRepoSpec`
392
413
  * @returns {Promise<
393
414
  * {ok: true, issues: Array<{iid: number, title: string, body: string}>, fingerprints: Set<string>}
394
415
  * | {ok: false, error: {code: string, message: string}}
@@ -399,6 +420,8 @@ export async function listExistingFindings({
399
420
  label = 'from:test-runner',
400
421
  maxBuffer = DEFAULT_MAX_BUFFER,
401
422
  execFile: execFileOpt,
423
+ repoRoot = process.cwd(),
424
+ resolveRepoSpecFn = resolveRepoSpec,
402
425
  } = {}) {
403
426
  // Binary is always the allowlisted value — never caller-supplied (ADR-364 §C5 HIGH fix).
404
427
  const bin = RECONCILE_ALLOWLISTED_BINS.glab;
@@ -414,15 +437,18 @@ export async function listExistingFindings({
414
437
 
415
438
  const args = ['issue', 'list', '--label', label, '--output', 'json'];
416
439
 
417
- // Optionally scope to a specific project (--repo flag)
418
- if (project !== undefined) {
419
- if (ARG_BOUNDARY_DANGEROUS.test(project)) {
440
+ // Optionally scope to a specific project (--repo flag). #872: explicit
441
+ // project always wins; otherwise auto-detect a --repo spec from the local
442
+ // git remotes (host-pinning).
443
+ const effectiveProject = project ?? resolveRepoSpecFn({ repoRoot, vcs: 'gitlab' });
444
+ if (effectiveProject !== undefined) {
445
+ if (ARG_BOUNDARY_DANGEROUS.test(effectiveProject)) {
420
446
  return {
421
447
  ok: false,
422
448
  error: { code: 'VALIDATION', message: 'project contains forbidden characters' },
423
449
  };
424
450
  }
425
- args.push('--repo', project);
451
+ args.push('--repo', effectiveProject);
426
452
  }
427
453
 
428
454
  let stdout;
@@ -501,7 +527,7 @@ export async function listExistingFindings({
501
527
  * Create a new finding as a GitLab issue.
502
528
  *
503
529
  * @param {object} opts
504
- * @param {string} [opts.project] - GitLab project path (--repo)
530
+ * @param {string} [opts.project] - GitLab project path (--repo; #872: otherwise auto-detected)
505
531
  * @param {string} opts.fingerprint - 16-hex fingerprint (appended as sentinel)
506
532
  * @param {string} opts.title - issue title (no [Test] prefix added here — caller decides)
507
533
  * @param {string} opts.body - issue description body; must not exceed 65536 bytes (#389)
@@ -509,6 +535,8 @@ export async function listExistingFindings({
509
535
  * @param {boolean} [opts.dryRun=false] - if true, return command without spawning
510
536
  * @param {number} [opts.maxBuffer=4194304] - maxBuffer for execFile (4 MB, #389)
511
537
  * @param {Function} [opts.execFile] - DI seam for testing; defaults to node:child_process execFile
538
+ * @param {string} [opts.repoRoot=process.cwd()] - cwd passed to resolveRepoSpecFn for remote auto-detection
539
+ * @param {(o: { repoRoot: string, vcs: 'gitlab' }) => string|undefined} [opts.resolveRepoSpecFn] - injectable `--repo` auto-detector; defaults to the real `resolveRepoSpec`
512
540
  * @returns {Promise<
513
541
  * {ok: true, action: 'create', iid?: number, command?: string[]}
514
542
  * | {ok: false, error: {code: string, message: string}}
@@ -523,6 +551,8 @@ export async function createFinding({
523
551
  dryRun = false,
524
552
  maxBuffer = DEFAULT_MAX_BUFFER,
525
553
  execFile: execFileOpt,
554
+ repoRoot = process.cwd(),
555
+ resolveRepoSpecFn = resolveRepoSpec,
526
556
  }) {
527
557
  // --- Input validation ---
528
558
  if (typeof title !== 'string' || title.length === 0) {
@@ -562,14 +592,17 @@ export async function createFinding({
562
592
 
563
593
  const args = ['issue', 'create', '--title', title, '--label', labels, '--description', body];
564
594
 
565
- if (project !== undefined) {
566
- if (ARG_BOUNDARY_DANGEROUS.test(project)) {
595
+ // #872: explicit project always wins; otherwise auto-detect a --repo spec
596
+ // from the local git remotes (host-pinning).
597
+ const effectiveProject = project ?? resolveRepoSpecFn({ repoRoot, vcs: 'gitlab' });
598
+ if (effectiveProject !== undefined) {
599
+ if (ARG_BOUNDARY_DANGEROUS.test(effectiveProject)) {
567
600
  return {
568
601
  ok: false,
569
602
  error: { code: 'VALIDATION', message: 'project contains forbidden characters' },
570
603
  };
571
604
  }
572
- args.push('--repo', project);
605
+ args.push('--repo', effectiveProject);
573
606
  }
574
607
 
575
608
  if (dryRun) {
@@ -616,12 +649,14 @@ export async function createFinding({
616
649
  * Add a comment to an existing finding issue.
617
650
  *
618
651
  * @param {object} opts
619
- * @param {string} [opts.project] - GitLab project path (--repo)
652
+ * @param {string} [opts.project] - GitLab project path (--repo; #872: otherwise auto-detected)
620
653
  * @param {number} opts.iid - issue IID to comment on
621
654
  * @param {string} opts.comment - comment body; must not exceed 65536 bytes (#389)
622
655
  * @param {boolean} [opts.dryRun=false] - if true, return command without spawning
623
656
  * @param {number} [opts.maxBuffer=4194304] - maxBuffer for execFile (4 MB, #389)
624
657
  * @param {Function} [opts.execFile] - DI seam for testing; defaults to node:child_process execFile
658
+ * @param {string} [opts.repoRoot=process.cwd()] - cwd passed to resolveRepoSpecFn for remote auto-detection
659
+ * @param {(o: { repoRoot: string, vcs: 'gitlab' }) => string|undefined} [opts.resolveRepoSpecFn] - injectable `--repo` auto-detector; defaults to the real `resolveRepoSpec`
625
660
  * @returns {Promise<
626
661
  * {ok: true, action: 'comment', command?: string[]}
627
662
  * | {ok: false, error: {code: string, message: string}}
@@ -634,6 +669,8 @@ export async function updateFinding({
634
669
  dryRun = false,
635
670
  maxBuffer = DEFAULT_MAX_BUFFER,
636
671
  execFile: execFileOpt,
672
+ repoRoot = process.cwd(),
673
+ resolveRepoSpecFn = resolveRepoSpec,
637
674
  }) {
638
675
  // --- Input validation ---
639
676
  if (!Number.isInteger(iid) || iid < 1) {
@@ -657,14 +694,17 @@ export async function updateFinding({
657
694
  // iid is a number — safe to convert to string; no arg-boundary concerns
658
695
  const args = ['issue', 'note', String(iid), '--message', comment];
659
696
 
660
- if (project !== undefined) {
661
- if (ARG_BOUNDARY_DANGEROUS.test(project)) {
697
+ // #872: explicit project always wins; otherwise auto-detect a --repo spec
698
+ // from the local git remotes (host-pinning).
699
+ const effectiveProject = project ?? resolveRepoSpecFn({ repoRoot, vcs: 'gitlab' });
700
+ if (effectiveProject !== undefined) {
701
+ if (ARG_BOUNDARY_DANGEROUS.test(effectiveProject)) {
662
702
  return {
663
703
  ok: false,
664
704
  error: { code: 'VALIDATION', message: 'project contains forbidden characters' },
665
705
  };
666
706
  }
667
- args.push('--repo', project);
707
+ args.push('--repo', effectiveProject);
668
708
  }
669
709
 
670
710
  if (dryRun) {
@@ -0,0 +1,484 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * tests-src-ratio.mjs — THE canonical tests:src LOC measurement for TV-003
4
+ * (`.claude/rules/test-value.md` § TV-003 Budget Corridor).
5
+ *
6
+ * ## Why this file exists
7
+ *
8
+ * TV-003 named a ceiling but no recipe, so the recipe lived in prose — and prose
9
+ * re-derives differently for every person who measures. On 2026-07-30 SIX numbers
10
+ * for the one metric were in simultaneous circulation (1.7142 / 1.7360 / 1.74 /
11
+ * 1.7779 / 1.8032 / 1.8084), differing only in which files each measurer chose to
12
+ * count. A threshold steered by six numbers is steered blind. This module is the
13
+ * single answer: the recipe is code, the number is reproducible at a SHA, and the
14
+ * rule text points here instead of describing a seventh calculation.
15
+ *
16
+ * ## The recipe (a PARTITION, not two independent globs)
17
+ *
18
+ * Every tracked code file lands in EXACTLY ONE bucket — that property is the
19
+ * whole design. Two independently-authored globs are how the six numbers arose:
20
+ * each hand-picked src list (`scripts+hooks` vs `+skills` vs `everything`) both
21
+ * dropped files silently and invited the next measurer to pick differently.
22
+ * Here `src` is defined by NEGATION, so a new top-level directory joins the
23
+ * denominator the moment it is committed, with no rule edit and no re-derivation.
24
+ *
25
+ * universe `git ls-files`, filtered to CODE_EXTENSIONS
26
+ * numerator tracked code under `tests/`
27
+ * denominator every OTHER tracked code file
28
+ *
29
+ * ## The four questions, answered in code rather than left open
30
+ *
31
+ * Do `skills/**.mjs` count as src? YES — shipped product code, and no
32
+ * hand-picked list decides it; negation does.
33
+ * Do `.md` files count? NO — neither side. Documentation volume
34
+ * is a different budget (instruction-budget);
35
+ * mixing them makes the ratio movable by
36
+ * writing prose, which catches no bug.
37
+ * Only git-tracked? YES — reproducible at a SHA, and it is
38
+ * what keeps node_modules/, coverage/ and
39
+ * untracked scratch out without an ignore list.
40
+ * Do `tests/fixtures/` count as test LOC? YES when they are code (`.mjs`/`.js`/
41
+ * `.cjs`). A code fixture is maintained
42
+ * code that exists only to serve the suite —
43
+ * that is test-corpus cost. Non-code
44
+ * fixtures (`.json`, `.jsonl`, `.md`) are
45
+ * excluded by the same extension filter
46
+ * that governs the denominator.
47
+ * Blank lines and comments? COUNTED — see countPhysicalLines().
48
+ *
49
+ * ## What this is NOT
50
+ *
51
+ * Not a ratchet, and deliberately so. TV-003 is a CORRIDOR: exceeding the ceiling
52
+ * switches the consolidation rule on, it does not break the build. `--check` is a
53
+ * machine-readable answer to "is the consolidation wave required?", which is the
54
+ * only job TV-003 gives the ratio ("the ratio is merely the trigger that switches
55
+ * it on"). Wiring it as a blocking CI gate would manufacture standing deletion
56
+ * pressure with no nameable target per file — the precise thing TV-001 and TV-002
57
+ * forbid, and the documented reason the predecessor ceiling of 1.20 was abandoned.
58
+ *
59
+ * Usage:
60
+ * tests-src-ratio.mjs [<repo-root>] [--json] [--check] [--ceiling <n>] [--stdin]
61
+ *
62
+ * <repo-root> defaults to process.cwd()
63
+ * --json emit a single JSON object on stdout, nothing else
64
+ * --check exit 1 when the ratio exceeds the ceiling (consolidation
65
+ * wave required); exit 0 when inside the corridor
66
+ * --ceiling <n> override the TV-003 ceiling (default 1.60)
67
+ * --stdin take newline-separated paths from stdin instead of
68
+ * enumerating via `git ls-files` (test seam / staged-only mode)
69
+ *
70
+ * Exit codes:
71
+ * 0 — measurement completed (and, under --check, ratio is within the corridor)
72
+ * 1 — --check only: ratio exceeds the ceiling
73
+ * 2 — tool error (missing/unreadable root, bad argv)
74
+ */
75
+
76
+ import { readFileSync, existsSync, statSync } from 'node:fs';
77
+ import { join, isAbsolute, relative, resolve } from 'node:path';
78
+ import { execFileSync } from 'node:child_process';
79
+ import { fileURLToPath } from 'node:url';
80
+
81
+ import { writeStdoutLineSync } from './io.mjs';
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // The definition — the part that must never be re-derived by hand
85
+ // ---------------------------------------------------------------------------
86
+
87
+ /**
88
+ * Extensions that count as CODE on both sides of the ratio. Anything else is in
89
+ * neither bucket: `.md` is documentation, `.json`/`.jsonl`/`.yml` are data.
90
+ * Applied symmetrically to numerator and denominator by construction — a file
91
+ * type can never inflate one side while being invisible to the other.
92
+ */
93
+ export const CODE_EXTENSIONS = Object.freeze(['.mjs', '.js', '.cjs']);
94
+
95
+ /** Path prefix that makes a tracked code file part of the TEST corpus. */
96
+ export const TEST_PREFIX = 'tests/';
97
+
98
+ /** The TV-003 ceiling. Exceeding it switches the consolidation rule on. */
99
+ export const DEFAULT_CEILING = 1.6;
100
+
101
+ /** Machine-readable schema tag for the --json envelope. */
102
+ export const SCHEMA = 'tests-src-ratio/1';
103
+
104
+ /**
105
+ * Which bucket a repo-relative path belongs to.
106
+ *
107
+ * `src` is defined by NEGATION — every tracked code file that is not under
108
+ * `tests/`. That is what makes the two buckets a partition rather than two
109
+ * globs that can overlap or leave a gap.
110
+ *
111
+ * @param {string} relPath repo-relative path, `/`-separated
112
+ * @returns {'test'|'src'|null} null = outside the metric entirely
113
+ */
114
+ export function classifyPath(relPath) {
115
+ const p = String(relPath).replace(/\\/g, '/').replace(/^\.\//, '');
116
+ if (!CODE_EXTENSIONS.some((ext) => p.endsWith(ext))) return null;
117
+ return p === TEST_PREFIX.slice(0, -1) || p.startsWith(TEST_PREFIX) ? 'test' : 'src';
118
+ }
119
+
120
+ /**
121
+ * Physical line count — blank lines and comment lines INCLUDED.
122
+ *
123
+ * Two deliberate choices:
124
+ *
125
+ * 1. No comment stripping. Stripping would need a real JS parser (a `//` inside
126
+ * a string, a regex literal, or a template literal is not a comment), so the
127
+ * stripper itself becomes a bug surface in the measuring instrument. It also
128
+ * inverts the incentive: comments sit in the DENOMINATOR too, so a stripper
129
+ * would reward deleting explanatory comments from src to move the ratio.
130
+ *
131
+ * 2. EOF-newline-insensitive. `wc -l` counts newline BYTES, so a file whose last
132
+ * line has no trailing newline is undercounted by one — an off-by-one that
133
+ * varies with an invisible byte. Here a trailing empty segment is dropped, so
134
+ * "10 lines" means ten lines with or without the final newline.
135
+ *
136
+ * @param {string} content
137
+ * @returns {number}
138
+ */
139
+ export function countPhysicalLines(content) {
140
+ if (content === '') return 0;
141
+ const parts = content.split('\n');
142
+ if (parts[parts.length - 1] === '') parts.pop();
143
+ return parts.length;
144
+ }
145
+
146
+ /**
147
+ * Measure the ratio over an explicit file list.
148
+ *
149
+ * Enumeration is injected rather than performed here, which is what lets the
150
+ * test drive a controlled fixture without a git repository.
151
+ *
152
+ * @param {object} opts
153
+ * @param {string[]} opts.files repo-relative paths
154
+ * @param {(relPath: string) => string|null} opts.readFile returns content, or null when unreadable
155
+ * @param {number} [opts.ceiling]
156
+ * @returns {{testFiles:number,testLoc:number,srcFiles:number,srcLoc:number,ratio:number|null,
157
+ * ceiling:number,withinCorridor:boolean,consolidationWaveRequired:boolean,skipped:number}}
158
+ */
159
+ export function measure({ files, readFile, ceiling = DEFAULT_CEILING }) {
160
+ let testFiles = 0;
161
+ let testLoc = 0;
162
+ let srcFiles = 0;
163
+ let srcLoc = 0;
164
+ let skipped = 0;
165
+
166
+ for (const rel of files) {
167
+ const bucket = classifyPath(rel);
168
+ if (bucket === null) continue;
169
+ const content = readFile(rel);
170
+ if (content === null || content === undefined) {
171
+ skipped++;
172
+ continue;
173
+ }
174
+ const lines = countPhysicalLines(content);
175
+ if (bucket === 'test') {
176
+ testFiles++;
177
+ testLoc += lines;
178
+ } else {
179
+ srcFiles++;
180
+ srcLoc += lines;
181
+ }
182
+ }
183
+
184
+ // A repo with no src code has an undefined ratio, not an infinite one. Saying
185
+ // `null` keeps the consumer from reading Infinity as a corridor breach.
186
+ const ratio = srcLoc === 0 ? null : Number((testLoc / srcLoc).toFixed(4));
187
+ const withinCorridor = ratio === null ? true : ratio <= ceiling;
188
+
189
+ return {
190
+ testFiles,
191
+ testLoc,
192
+ srcFiles,
193
+ srcLoc,
194
+ ratio,
195
+ ceiling,
196
+ withinCorridor,
197
+ consolidationWaveRequired: !withinCorridor,
198
+ skipped,
199
+ };
200
+ }
201
+
202
+ /**
203
+ * The self-describing definition block shipped inside every --json envelope, so
204
+ * a consumer never has to guess which recipe produced the number.
205
+ * @param {'git ls-files'|'stdin'} source
206
+ */
207
+ export function definitionOf(source) {
208
+ return {
209
+ source: source === 'stdin' ? 'stdin path list' : 'git ls-files (tracked files only)',
210
+ codeExtensions: [...CODE_EXTENSIONS],
211
+ numerator: `tracked code files under ${TEST_PREFIX}`,
212
+ denominator: `every OTHER tracked code file (src defined by negation, not by a directory list)`,
213
+ lineRule: 'physical lines; blank + comment lines counted; EOF-newline-insensitive',
214
+ excluded:
215
+ 'non-code extensions (.md, .json, .jsonl, .yml) on BOTH sides; untracked files (node_modules/, coverage/, scratch)',
216
+ };
217
+ }
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // Enumeration
221
+ // ---------------------------------------------------------------------------
222
+
223
+ /** @param {string} root */
224
+ function trackedFiles(root) {
225
+ const out = execFileSync('git', ['ls-files'], { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
226
+ return out
227
+ .split('\n')
228
+ .filter(Boolean)
229
+ .map((rel) => rel.replace(/\\/g, '/'));
230
+ }
231
+
232
+ /** @param {string} root */
233
+ function stdinPaths(root) {
234
+ let raw;
235
+ try {
236
+ raw = readFileSync(0, 'utf8');
237
+ } catch {
238
+ return [];
239
+ }
240
+ return raw
241
+ .split('\n')
242
+ .map((l) => l.trim())
243
+ .filter(Boolean)
244
+ .map((p) => (isAbsolute(p) ? relative(root, p) : p).replace(/\\/g, '/'));
245
+ }
246
+
247
+ /** Short HEAD SHA, or null outside a git repo — the PSA-006 "measured WHEN" anchor. */
248
+ function headRef(root) {
249
+ try {
250
+ return execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
251
+ cwd: root,
252
+ encoding: 'utf8',
253
+ stdio: ['ignore', 'pipe', 'ignore'],
254
+ }).trim();
255
+ } catch {
256
+ return null;
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Whether any TRACKED CODE file differs from the index/HEAD.
262
+ *
263
+ * Load-bearing, not cosmetic. Enumeration reads the git INDEX but line counts
264
+ * read the WORKING TREE, so on a dirty tree the pair (`ref`, `ratio`) is a claim
265
+ * nobody can reproduce at that SHA. Observed live while this module was written:
266
+ * a sibling agent grew one tracked src file 543 → 683 lines mid-measurement and
267
+ * the "same" ratio moved across three consecutive runs. `ref` alone would have
268
+ * stamped all three with the identical SHA.
269
+ *
270
+ * PSA-006 requires a measurement to carry WHEN it was taken; a SHA that does not
271
+ * reproduce the number fails that requirement while looking like it satisfies it.
272
+ *
273
+ * @returns {boolean|null} null when git cannot answer (not a repo)
274
+ */
275
+ function isDirty(root) {
276
+ try {
277
+ // --no-optional-locks is load-bearing, not tidiness: a plain `git status`
278
+ // opportunistically refreshes and therefore LOCKS .git/index, which races a
279
+ // parallel session's index write (PSA-007). This matters here specifically
280
+ // because .claude/rules/test-value.md now instructs agents to run this
281
+ // script, so it executes inside live sessions. Measured on git 2.50.1 with
282
+ // stale stat info: plain status rewrote .git/index, the flagged form did not.
283
+ // Same flag, same reason as hooks/post-bash-write-verify.mjs.
284
+ const out = execFileSync('git', ['--no-optional-locks', 'status', '--porcelain', '--untracked-files=no'], {
285
+ cwd: root,
286
+ encoding: 'utf8',
287
+ maxBuffer: 16 * 1024 * 1024,
288
+ stdio: ['ignore', 'pipe', 'ignore'],
289
+ });
290
+ return out
291
+ .split('\n')
292
+ .filter(Boolean)
293
+ .some((l) => classifyPath(l.slice(3).trim().split(' -> ').pop() ?? '') !== null);
294
+ } catch {
295
+ return null;
296
+ }
297
+ }
298
+
299
+ // ---------------------------------------------------------------------------
300
+ // CLI
301
+ // ---------------------------------------------------------------------------
302
+
303
+ const USAGE =
304
+ 'Usage: tests-src-ratio.mjs [<repo-root>] [--json] [--check] [--ceiling <n>] [--stdin]';
305
+
306
+ /** @param {string[]} argv */
307
+ /**
308
+ * Session-start Phase 4 banner probe.
309
+ *
310
+ * WHY THIS EXISTS: without it this module had zero consumers. TV-003 defines the
311
+ * ceiling as the trigger for a consolidation wave — and the trigger fired into a
312
+ * void, since the only references were two rule files asking a human to type the
313
+ * command. "Not a blocking gate" was conflated with "not wired at all"; the
314
+ * counter-example shipped in the same commit range, where `checkInstructionBudget`
315
+ * is equally non-blocking and does get a Phase 4 banner. This closes that asymmetry
316
+ * WITHOUT making the ratio a build gate — the arguments against a bidirectional
317
+ * ratchet in `.claude/rules/test-value.md` § TV-003 stand unchanged.
318
+ *
319
+ * Contract matches the sibling probes (`checkInstructionBudget`, `checkCiStatus`,
320
+ * `checkMocStaleness`): returns `null` for "nothing to say", or a single
321
+ * `{ severity, message }` record. Never throws — any failure degrades to silence,
322
+ * because a measurement problem must not block a session start.
323
+ *
324
+ * @param {{ repoRoot?: string, ceiling?: number }} [opts]
325
+ * @returns {{ severity: 'warn', message: string, ratio: number, ceiling: number } | null}
326
+ */
327
+ export function checkTestsSrcRatio({ repoRoot, ceiling = DEFAULT_CEILING } = {}) {
328
+ try {
329
+ if (!repoRoot || typeof repoRoot !== 'string') return null;
330
+ const root = resolve(repoRoot);
331
+ if (!existsSync(root)) return null;
332
+
333
+ const files = trackedFiles(root);
334
+ const readFile = (rel) => {
335
+ const abs = join(root, rel);
336
+ try {
337
+ if (!statSync(abs).isFile()) return null;
338
+ return readFileSync(abs, 'utf8');
339
+ } catch {
340
+ return null;
341
+ }
342
+ };
343
+
344
+ const result = measure({ files, readFile, ceiling });
345
+ if (result.ratio === null || result.withinCorridor) return null;
346
+
347
+ const dirty = isDirty(root);
348
+ return {
349
+ severity: 'warn',
350
+ ratio: result.ratio,
351
+ ceiling: result.ceiling,
352
+ message:
353
+ `⚠ tests:src ${result.ratio.toFixed(4)} > ceiling ${result.ceiling} — ` +
354
+ `TV-003 consolidation wave is ON: no new test lands without removing a redundant one ` +
355
+ `(${result.testLoc} test LOC / ${result.srcLoc} src LOC across ` +
356
+ `${result.testFiles} + ${result.srcFiles} files${dirty ? ', dirty tree' : ''}). ` +
357
+ `Detail: node scripts/lib/tests-src-ratio.mjs --json`,
358
+ };
359
+ } catch {
360
+ return null; // never block a session start on a measurement failure
361
+ }
362
+ }
363
+
364
+ export function parseArgs(argv) {
365
+ const KNOWN = new Set(['--json', '--check', '--stdin', '--ceiling', '--help']);
366
+ const positionals = [];
367
+ let json = false;
368
+ let check = false;
369
+ let stdin = false;
370
+ let help = false;
371
+ let ceiling = DEFAULT_CEILING;
372
+
373
+ for (let i = 0; i < argv.length; i++) {
374
+ const a = argv[i];
375
+ if (!a.startsWith('--')) {
376
+ positionals.push(a);
377
+ continue;
378
+ }
379
+ if (!KNOWN.has(a)) return { error: `Unknown flag: ${a}` };
380
+ if (a === '--json') json = true;
381
+ else if (a === '--check') check = true;
382
+ else if (a === '--stdin') stdin = true;
383
+ else if (a === '--help') help = true;
384
+ else if (a === '--ceiling') {
385
+ const v = Number(argv[++i]);
386
+ if (!Number.isFinite(v) || v <= 0) return { error: '--ceiling requires a positive number' };
387
+ ceiling = v;
388
+ }
389
+ }
390
+ if (positionals.length > 1) return { error: 'at most one positional <repo-root> is accepted' };
391
+ return { json, check, stdin, help, ceiling, root: positionals[0] };
392
+ }
393
+
394
+ function main() {
395
+ const args = parseArgs(process.argv.slice(2));
396
+ if (args.error) {
397
+ console.error(`Error: ${args.error}`);
398
+ console.error(USAGE);
399
+ process.exit(2);
400
+ }
401
+ if (args.help) {
402
+ console.log(USAGE);
403
+ console.log('');
404
+ console.log('The canonical tests:src LOC measurement for TV-003 (test-value.md).');
405
+ console.log(' numerator tracked code under tests/');
406
+ console.log(' denominator every other tracked code file (src by negation)');
407
+ console.log(` code exts ${CODE_EXTENSIONS.join(' ')} (.md / .json never counted)`);
408
+ console.log(' lines physical; blanks + comments counted; EOF-newline-insensitive');
409
+ console.log('');
410
+ console.log(' --json machine-readable envelope on stdout');
411
+ console.log(' --check exit 1 when the ratio exceeds the ceiling');
412
+ console.log(` --ceiling <n> override the TV-003 ceiling (default ${DEFAULT_CEILING})`);
413
+ console.log(' --stdin read newline-separated paths instead of git ls-files');
414
+ console.log('');
415
+ console.log('Exit: 0 ok / within corridor · 1 (--check) ceiling exceeded · 2 tool error');
416
+ process.exit(0);
417
+ }
418
+
419
+ const root = resolve(args.root ?? process.cwd());
420
+ if (!existsSync(root)) {
421
+ console.error(`Error: repo root does not exist: ${root}`);
422
+ process.exit(2);
423
+ }
424
+
425
+ let files;
426
+ try {
427
+ files = args.stdin ? stdinPaths(root) : trackedFiles(root);
428
+ } catch (err) {
429
+ console.error(`Error: could not enumerate files under ${root}: ${err?.message ?? err}`);
430
+ process.exit(2);
431
+ }
432
+
433
+ const readFile = (rel) => {
434
+ const abs = join(root, rel);
435
+ try {
436
+ if (!statSync(abs).isFile()) return null;
437
+ return readFileSync(abs, 'utf8');
438
+ } catch {
439
+ return null; // deleted/unreadable — counted as skipped, never as 0 lines
440
+ }
441
+ };
442
+
443
+ const result = measure({ files, readFile, ceiling: args.ceiling });
444
+ const dirty = args.stdin ? null : isDirty(root);
445
+ const envelope = {
446
+ schema: SCHEMA,
447
+ measuredAt: new Date().toISOString(),
448
+ root,
449
+ ref: headRef(root),
450
+ // `ref` is only a reproducible anchor when `dirty` is false — see isDirty().
451
+ dirty,
452
+ definition: definitionOf(args.stdin ? 'stdin' : 'git ls-files'),
453
+ ...result,
454
+ };
455
+
456
+ if (args.json) {
457
+ // writeStdoutLineSync, NOT console.log: stdout is async on a pipe on macOS,
458
+ // so anything past the ~64 KiB kernel buffer is discarded by process.exit().
459
+ // This envelope is small today, but --check exits explicitly below and the
460
+ // fail-open class is not worth re-litigating per payload size.
461
+ writeStdoutLineSync(JSON.stringify(envelope, null, 2));
462
+ } else {
463
+ const r = result.ratio === null ? 'n/a (no src code)' : result.ratio.toFixed(4);
464
+ const verdict = result.withinCorridor
465
+ ? 'within corridor'
466
+ : `ABOVE ceiling ${result.ceiling} — TV-003 consolidation wave required`;
467
+ const at = envelope.ref ? ` @ ${envelope.ref}${dirty ? '+dirty' : ''}` : '';
468
+ writeStdoutLineSync(
469
+ `tests:src = ${r} (${result.testLoc} test LOC / ${result.srcLoc} src LOC` +
470
+ `; ${result.testFiles} test + ${result.srcFiles} src files${at}) — ${verdict}` +
471
+ (dirty ? '\n NOTE: working tree is dirty — this number is NOT reproducible at that SHA' : ''),
472
+ );
473
+ }
474
+
475
+ process.exit(args.check && !result.withinCorridor ? 1 : 0);
476
+ }
477
+
478
+ const isMain =
479
+ typeof process !== 'undefined' &&
480
+ process.argv[1] !== null &&
481
+ process.argv[1] !== undefined &&
482
+ resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
483
+
484
+ if (isMain) main();