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
@@ -479,7 +479,13 @@ export function writeBoard(opts) {
479
479
  * @param {object} opts
480
480
  * @param {string} opts.repoRoot — the repo whose row is being updated.
481
481
  * @param {Array<{ repoRoot: string, repoName?: string, status?: string }>} [opts.repos]
482
- * Full repo list; defaults to `[{ repoRoot, status: explicitStatus }]`.
482
+ * Full repo list; defaults to a single-repo descriptor
483
+ * `[{ repoRoot, status: explicitStatus }]`. Whichever entry resolves to
484
+ * `repoRoot` — in the caller-supplied list as well as in the default — gets
485
+ * its `repoName` filled from Session Config `vault-integration.vault-name`
486
+ * (#660) unless it already carries one; entries for other repos are never
487
+ * touched. With no `vault-name` configured, {@link collectRows} falls back to
488
+ * `path.basename(repoRoot)` as before.
483
489
  * @param {string} [opts.explicitStatus] — per-repo status override ('closed' from session-end).
484
490
  * @param {Date} [opts.now]
485
491
  * @param {boolean} [opts.dryRun]
@@ -524,10 +530,51 @@ export async function mirrorBoard({ repoRoot, repos, explicitStatus, now = new D
524
530
  return { action: 'skipped-vault-disabled' };
525
531
  }
526
532
 
527
- const repoList = Array.isArray(repos) && repos.length > 0
533
+ // `vault-name` (#660) overrides the git-derived repo slug for per-project
534
+ // vault namespacing. Without it `collectRows` falls back to
535
+ // `path.basename(repoRoot)`, so a repo whose directory name differs from its
536
+ // configured vault name renders under the wrong board row.
537
+ //
538
+ // The override is applied during descriptor NORMALISATION, not descriptor
539
+ // CONSTRUCTION (#835). Applying it only to the fallback single-repo
540
+ // descriptor made it inert on the primary production path: `sweepBoard`
541
+ // (session-start Phase 1.7) ALWAYS passes a non-empty `repos` — see
542
+ // {@link buildSweepRepos}, which unconditionally appends `thisRepoRoot` and
543
+ // emits bare `{ repoRoot }` entries with NO `repoName`. So session-start
544
+ // keyed the row `foldKey(path.basename(repoRoot))` while session-end (which
545
+ // calls this function WITHOUT `repos`) keyed it `foldKey(vault-name)`. The
546
+ // merge key is `repoName`, so the close never updated the in-progress row:
547
+ // a duplicate row plus a permanently stale `in-progress` status.
548
+ //
549
+ // Scope: ONLY the entry whose `repoRoot` resolves to THIS repo's root is
550
+ // touched. Entries for FOREIGN repos are left alone — a foreign repo's
551
+ // `vault-name` lives in ITS own Session Config, which this function does not
552
+ // read; stamping our override onto it would mislabel someone else's row. An
553
+ // entry that already carries an explicit non-empty `repoName` also wins, so
554
+ // a caller can still name its own row deliberately (#832 contract).
555
+ const vaultName = vault['vault-name'];
556
+ const repoNameOverride = typeof vaultName === 'string' && vaultName.length > 0
557
+ ? vaultName
558
+ : undefined;
559
+
560
+ const baseRepoList = Array.isArray(repos) && repos.length > 0
528
561
  ? repos
529
562
  : [{ repoRoot, status: explicitStatus }];
530
563
 
564
+ const repoList = repoNameOverride === undefined
565
+ ? baseRepoList
566
+ : baseRepoList.map((entry) => {
567
+ if (!entry || typeof entry.repoRoot !== 'string' || entry.repoRoot.length === 0) return entry;
568
+ if (typeof entry.repoName === 'string' && entry.repoName.length > 0) return entry;
569
+ let isSelf;
570
+ try {
571
+ isSelf = path.resolve(entry.repoRoot) === path.resolve(repoRoot);
572
+ } catch {
573
+ isSelf = false;
574
+ }
575
+ return isSelf ? { ...entry, repoName: repoNameOverride } : entry;
576
+ });
577
+
531
578
  const outputPath = resolveBoardPath(vaultDir);
532
579
 
533
580
  // Read the EXISTING generator-owned board (if any) to:
@@ -696,9 +743,11 @@ export function buildSweepRepos(candidates, { thisRepoRoot } = {}) {
696
743
  * {@link mirrorBoard}'s idempotent merge, never dropped.
697
744
  * (c) The enumerate + collectRows path is synchronous fs (readdirSync /
698
745
  * existsSync / readLock per candidate) — O(repos) small reads, single-digit
699
- * ms at host scale (~31 repos observed). No timeout is applied: a sync
700
- * call cannot be preempted in-process, so a timeout would only convert a
701
- * slow sweep into a thrown error, not a faster one.
746
+ * ms at host scale (45 repos, ~0.9-1.9ms warm measured 2026-07-19 at the
747
+ * default walk depth of 2; pre-#832's depth-1 scan saw only 1 of 47). No
748
+ * timeout is applied: a sync call cannot be preempted in-process, so a
749
+ * timeout would only convert a slow sweep into a thrown error, not a
750
+ * faster one.
702
751
  * (d) Merge key is `repoName` (`path.basename`), case-insensitively folded via
703
752
  * {@link foldKey} (issue #719) — two rows differing only by case (e.g.
704
753
  * `some-repo` vs `Some-Repo`, the same physical directory on a
@@ -713,6 +762,15 @@ export function buildSweepRepos(candidates, { thisRepoRoot } = {}) {
713
762
  * remains a known limitation, inherited from {@link collectRows}/
714
763
  * {@link mirrorBoard}; not addressed here.
715
764
  *
765
+ * This limitation got materially WORSE with the depth-2 walk (#832):
766
+ * under the old depth-1 scan, `<org-a>/<name>` and `<org-b>/<name>` were
767
+ * both un-enumerable, so they could not collide. Both are now enumerated
768
+ * and fold to a single row. Two such basename collisions were measured on
769
+ * the reference host immediately after the change (same repo name under
770
+ * two different org directories). Fixing this requires re-keying rows on
771
+ * something path-derived rather than `path.basename` — deliberately out
772
+ * of scope for #832 and tracked as a follow-up.
773
+ *
716
774
  * Best-effort contract: `sweepBoard` itself never throws for an enumeration
717
775
  * failure — `enumerateCandidates` is wrapped in try/catch; on ANY failure the
718
776
  * sweep degrades to the pre-#716 single-repo write
@@ -489,13 +489,6 @@ export async function mirrorNarrative(opts) {
489
489
  return { action: 'skipped-vault-disabled' };
490
490
  }
491
491
 
492
- // Defense-in-depth: when the caller omits (or passes an empty) `repo`, derive
493
- // it from the repoRoot basename rather than mis-filing the narrative under the
494
- // 'unknown' slug. A missing repo name must never silently mis-file (#675 review).
495
- const repoName = (typeof repo === 'string' && repo.trim().length > 0)
496
- ? repo
497
- : path.basename(path.resolve(repoRoot));
498
-
499
492
  // Read Session Config (CLAUDE.md / AGENTS.md) and resolve vault settings.
500
493
  let config;
501
494
  try {
@@ -509,6 +502,19 @@ export async function mirrorNarrative(opts) {
509
502
  if (!vaultIntegration || vaultIntegration.enabled !== true) {
510
503
  return { action: 'skipped-vault-disabled' };
511
504
  }
505
+
506
+ // Defense-in-depth: when the caller omits (or passes an empty) `repo`, derive
507
+ // it from the operator-configured `vault-name` override (#660/#832) when set,
508
+ // else the repoRoot basename — never silently mis-file under 'unknown' (#675
509
+ // review). Precedence: explicit `repo` opt > `vault-name` > basename.
510
+ const vaultNameOverride =
511
+ typeof vaultIntegration['vault-name'] === 'string' && vaultIntegration['vault-name'].trim()
512
+ ? vaultIntegration['vault-name'].trim()
513
+ : null;
514
+ const repoName = (typeof repo === 'string' && repo.trim().length > 0)
515
+ ? repo
516
+ : vaultNameOverride ?? path.basename(path.resolve(repoRoot));
517
+
512
518
  const rawVaultDir = vaultIntegration['vault-dir'];
513
519
  if (!rawVaultDir || typeof rawVaultDir !== 'string') {
514
520
  return { action: 'skipped-vault-disabled' };
@@ -0,0 +1,362 @@
1
+ /**
2
+ * vcs-repo-spec.mjs — resolve a glab/gh `-R`/`--repo` host-pinning spec from
3
+ * the local git remotes (#839).
4
+ *
5
+ * Why this exists: a bare `glab`/`gh` spawn (no shell wrapper, no `-R`) falls
6
+ * back to the ambient `GITLAB_HOST`/`GH_HOST` env var to pick a host. On a
7
+ * multi-GitLab-instance host that can resolve to the WRONG instance and fail
8
+ * silently — the root cause of #839.
9
+ *
10
+ * Injecting `GITLAB_HOST` into the spawn env is NOT the fix — it was tested
11
+ * live and FAILS whenever `~/.ssh/config` maps the GitLab hostname to a
12
+ * `HostName` IP alias: glab then reports "none of the git remotes configured
13
+ * for this repository correspond to the GITLAB_HOST environment variable"
14
+ * because the remote it sees is the IP, not the hostname. The only mechanism
15
+ * verified to survive that mismatch is passing the raw remote URL (or a
16
+ * HOST/OWNER/REPO spec) via `-R`/`--repo`.
17
+ *
18
+ * Preference order:
19
+ * - vcs === 'github': remote `github` → `origin`
20
+ * - vcs === 'gitlab' (default): remote `gitlab` → `origin`
21
+ *
22
+ * Returns `undefined` when no matching remote resolves — callers MUST treat
23
+ * this as "could not auto-detect" and omit the `-R`/`--repo` flag entirely
24
+ * (never emit `-R undefined`).
25
+ *
26
+ * Spec format (#872 follow-up): `resolveRepoSpec` returns the RAW remote URL
27
+ * for `vcs: 'gitlab'`, but a NORMALIZED `HOST/OWNER/REPO` string for
28
+ * `vcs: 'github'` — `gh -R`/`--repo` documents only `[HOST/]OWNER/REPO` as
29
+ * its accepted spec shape, unlike `glab -R` which explicitly accepts a full
30
+ * URL. See `resolveRepoSpec`'s own docblock for the full rationale. This
31
+ * module also exports `resolveRepoHost` for the `glab api`/`gh api`
32
+ * call sites, which accept neither `-R` nor a URL — only `--hostname`.
33
+ *
34
+ * Credential safety (#907, CWE-214): every value returned by `resolveRepoSpec`
35
+ * / `resolveRepoHost` has any embedded userinfo credential
36
+ * (`https://user:token@host/...`, the GitLab-CI checkout pattern) stripped at
37
+ * the source, so a credential can never reach a `-R`/`--repo`/`--hostname`
38
+ * argv position (visible via `ps` / `/proc/<pid>/cmdline`). A scp-like SSH
39
+ * login (`git@host:path`) is NOT a credential and is preserved verbatim. The
40
+ * exported `redactUrlCredentials` is the log-line defense-in-depth counterpart
41
+ * for values that bypass the source strip (e.g. a `--repo` override). See
42
+ * `stripUrlCredentials` / `userinfoIsCredential`.
43
+ *
44
+ * Lifted out of `scripts/archive-closed-prds.mjs::defaultGlabRepo` (that
45
+ * script's docblock described this exact problem months before #839 was
46
+ * filed) into a shared `scripts/lib/` module so
47
+ * `scripts/lib/issue-close-strip-labels.mjs` and
48
+ * `scripts/lib/spiral-carryover.mjs` can reuse the same, single
49
+ * implementation instead of each re-deriving it.
50
+ */
51
+
52
+ import { execFileSync } from 'node:child_process';
53
+
54
+ /**
55
+ * argv-boundary guard (#872 follow-up, Q3-LOW centralization). A well-formed
56
+ * git remote URL or bare hostname never legitimately contains whitespace or a
57
+ * C0 control character — if a resolved value does, treat it as `undefined`
58
+ * ("could not auto-detect") rather than let it flow into a `-R`/`--repo`/
59
+ * `--hostname` argv position. This is the CENTRAL defense for both
60
+ * `resolveRepoSpec` and `resolveRepoHost`: the ~9 call sites across the repo
61
+ * that do a bare `if (spec) args.push('-R', spec)` do not re-check the value
62
+ * themselves, so they inherit this guard for free by going through either
63
+ * exported function here rather than reading a git remote URL directly.
64
+ */
65
+ // eslint-disable-next-line no-control-regex -- deliberate: the argv-boundary guard must catch ALL C0 control characters, not only the \s subset
66
+ const UNSAFE_ARGV_CHARS_RE = /[\s\x00-\x1f]/;
67
+
68
+ /**
69
+ * @param {unknown} value
70
+ * @returns {boolean} true when `value` is a string containing whitespace or a
71
+ * C0 control character — unsafe to forward as a single argv token.
72
+ */
73
+ function isUnsafeForArgv(value) {
74
+ return typeof value === 'string' && UNSAFE_ARGV_CHARS_RE.test(value);
75
+ }
76
+
77
+ /**
78
+ * Embedded-credential guard (#907, CWE-214). A `scheme://userinfo@host/...`
79
+ * remote URL carries its userinfo (`user:password`, or a bare `token`) in the
80
+ * clear. The GitLab-CI checkout pattern
81
+ * `https://gitlab-ci-token:<MASKED>@host/group/project.git` is the canonical
82
+ * source. If that raw URL reaches a `-R`/`--repo` argv position it is visible
83
+ * via `ps` / `/proc/<pid>/cmdline`, and if it reaches a `--verbose` log line
84
+ * it is written to CI job output — either way the credential leaks. The
85
+ * 2026-07-26 argv-boundary guard ({@link UNSAFE_ARGV_CHARS_RE}) does NOT catch
86
+ * this: an embedded credential contains no whitespace or control character.
87
+ *
88
+ * Matches ONLY the `scheme://` URL forms (`https://`, `http://`, `ssh://`,
89
+ * `git+ssh://`, …). The scp-like SSH form `git@host:path` has no `://` and is
90
+ * therefore never matched — a bare SSH login user is not a credential. The
91
+ * userinfo class `[^/\s]+` stops at the first `/`, so an `@` that appears in a
92
+ * PATH (e.g. `.../path@ref`) is never mistaken for userinfo. Because `@` is NOT
93
+ * excluded from the class, a greedy match binds `@` to the LAST `@` before the
94
+ * authority ends — the real userinfo/host separator per RFC 3986 (and how
95
+ * glab/gh parse it). This closes the residual-credential leak where a raw `@`
96
+ * inside the token/password (`user:gl@token@host`, or nested `a:b@c:d@host`)
97
+ * previously left a partial secret after a first-`@`-only match (#907 MED-1).
98
+ */
99
+ const URL_WITH_USERINFO_RE = /([a-z][a-z0-9+.-]*:\/\/)([^/\s]+)@/gi;
100
+
101
+ /**
102
+ * Decide whether a matched `scheme://userinfo@` is a CREDENTIAL (strip/redact)
103
+ * or a legitimate login username (leave untouched).
104
+ *
105
+ * A credential is either:
106
+ * - any userinfo carrying a password component (`user:pass`, a `:` present) —
107
+ * for ANY scheme, including `ssh://user:pass@host`; or
108
+ * - a bare userinfo on an `http`/`https` scheme (`https://token@host`) — HTTPS
109
+ * git auth passes tokens/PATs through the userinfo slot, so a bare userinfo
110
+ * there is a token, never a plain username.
111
+ *
112
+ * A bare userinfo on a non-HTTP scheme (`ssh://git@host`) is a login username,
113
+ * NOT a credential — SSH authenticates with keys, never a URL-embedded secret —
114
+ * so it is left untouched, consistent with the scp-like `git@host:path` case.
115
+ *
116
+ * @param {string} scheme e.g. `https://` (includes the trailing `://`)
117
+ * @param {string} userinfo the substring between `scheme` and `@`
118
+ * @returns {boolean}
119
+ */
120
+ function userinfoIsCredential(scheme, userinfo) {
121
+ if (userinfo.includes(':')) return true;
122
+ return /^https?:\/\/$/i.test(scheme);
123
+ }
124
+
125
+ /**
126
+ * Strip credential userinfo from a single remote URL, returning the URL with
127
+ * host/path/project-spec EXACTLY preserved. A credential-free URL (and the
128
+ * scp-like `git@host:path` SSH form) is returned BYTE-IDENTICAL — the strip is
129
+ * a no-op unless {@link userinfoIsCredential} classifies the userinfo as a
130
+ * secret. Never throws; a non-string returns unchanged.
131
+ *
132
+ * @param {string} url
133
+ * @returns {string}
134
+ */
135
+ export function stripUrlCredentials(url) {
136
+ if (typeof url !== 'string') return url;
137
+ return url.replace(URL_WITH_USERINFO_RE, (match, scheme, userinfo) =>
138
+ userinfoIsCredential(scheme, userinfo) ? scheme : match,
139
+ );
140
+ }
141
+
142
+ /**
143
+ * Defense-in-depth redactor for LOG output: replace any credential userinfo in
144
+ * an arbitrary text string (e.g. a verbose `glab … -R <spec>` line) with a
145
+ * `***` marker, leaving the surrounding text and the URL host/path intact. Used
146
+ * by `scripts/vault-integration-watcher.mjs`'s `verbose()` to cover the
147
+ * `--repo <spec>` override path, which bypasses the source-level strip in
148
+ * {@link resolveRawRemoteUrl}. Never throws; a non-string returns unchanged.
149
+ *
150
+ * @param {string} text
151
+ * @returns {string}
152
+ */
153
+ export function redactUrlCredentials(text) {
154
+ if (typeof text !== 'string') return text;
155
+ return text.replace(URL_WITH_USERINFO_RE, (match, scheme, userinfo) =>
156
+ userinfoIsCredential(scheme, userinfo) ? `${scheme}***@` : match,
157
+ );
158
+ }
159
+
160
+ /**
161
+ * Default git-remote runner: `git -C <repoRoot> remote get-url <name>`.
162
+ * Never throws — returns `{ ok:false, stdout:'', stderr }` on any failure
163
+ * (missing remote, not a git repo, git not on PATH, ...).
164
+ *
165
+ * @param {string[]} gitArgs
166
+ * @returns {{ ok: boolean, stdout: string, stderr: string }}
167
+ */
168
+ function defaultGitRun(gitArgs) {
169
+ try {
170
+ const stdout = execFileSync('git', gitArgs, {
171
+ encoding: 'utf8',
172
+ stdio: ['ignore', 'pipe', 'pipe'],
173
+ });
174
+ return { ok: true, stdout: String(stdout ?? ''), stderr: '' };
175
+ } catch (err) {
176
+ const stderr =
177
+ err && err.stderr ? String(err.stderr) : err && err.message ? String(err.message) : 'unknown error';
178
+ return { ok: false, stdout: '', stderr };
179
+ }
180
+ }
181
+
182
+ /** Remote-name preference order, per VCS. */
183
+ const REMOTE_PREFERENCE = {
184
+ gitlab: ['gitlab', 'origin'],
185
+ github: ['github', 'origin'],
186
+ };
187
+
188
+ /**
189
+ * The OTHER platform's well-known public host, per requested vcs — the one
190
+ * case a resolved remote URL can be PROVEN to belong to the wrong VCS family
191
+ * without any repo-specific host configuration (self-hosted GitLab/GitHub
192
+ * Enterprise instances can live at ANY domain, so this deliberately checks
193
+ * only the unambiguous public-host case, not a general host allow-list).
194
+ */
195
+ const WRONG_FAMILY_HOST = {
196
+ gitlab: 'github.com',
197
+ github: 'gitlab.com',
198
+ };
199
+
200
+ /**
201
+ * Extract the bare hostname from a git remote URL, handling both the HTTPS
202
+ * (`https://host/owner/repo.git`) and SSH (`git@host:owner/repo.git`) forms.
203
+ * Returns `null` for an unrecognized shape (never throws).
204
+ *
205
+ * @param {string} url
206
+ * @returns {string|null}
207
+ */
208
+ function extractHost(url) {
209
+ const httpsMatch = /^https?:\/\/([^/]+)/i.exec(url);
210
+ if (httpsMatch) return httpsMatch[1].toLowerCase();
211
+ const sshMatch = /^[^@\s]+@([^:\s]+):/i.exec(url);
212
+ if (sshMatch) return sshMatch[1].toLowerCase();
213
+ return null;
214
+ }
215
+
216
+ /**
217
+ * Normalize a github remote URL (HTTPS or SSH) into the `HOST/OWNER/REPO`
218
+ * shape `gh -R`/`--repo` documents as its accepted spec format. gh does NOT
219
+ * accept a raw remote URL the way `glab -R` does — only `[HOST/]OWNER/REPO`.
220
+ * Strips a trailing `.git` suffix and any trailing slash.
221
+ *
222
+ * Falls back to returning `url` unchanged when it does not match the
223
+ * expected `host/owner/repo` shape (never throws) — a raw URL is still
224
+ * strictly better than omitting `-R` entirely.
225
+ *
226
+ * @param {string} url
227
+ * @returns {string}
228
+ */
229
+ function normalizeGithubSpec(url) {
230
+ const httpsMatch = /^https?:\/\/([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/i.exec(url);
231
+ if (httpsMatch) {
232
+ const [, host, owner, repo] = httpsMatch;
233
+ return `${host.toLowerCase()}/${owner}/${repo}`;
234
+ }
235
+ const sshMatch = /^[^@\s]+@([^:\s]+):([^/]+)\/([^/]+?)(?:\.git)?\/?$/i.exec(url);
236
+ if (sshMatch) {
237
+ const [, host, owner, repo] = sshMatch;
238
+ return `${host.toLowerCase()}/${owner}/${repo}`;
239
+ }
240
+ return url;
241
+ }
242
+
243
+ /**
244
+ * Resolve the raw remote URL for the requested vcs — shared by
245
+ * `resolveRepoSpec` and `resolveRepoHost` so both apply the identical
246
+ * remote-preference-order + cross-family-guard resolution.
247
+ *
248
+ * Cross-family guard (#839 follow-up): a candidate URL whose host is the
249
+ * OTHER platform's well-known public host (`github.com` under vcs:'gitlab',
250
+ * `gitlab.com` under vcs:'github') is skipped rather than returned — passing
251
+ * it to `glab`/`gh -R` would be a guaranteed hard failure, strictly worse
252
+ * than the ambient-resolution fallback #839 replaced. This is a narrow,
253
+ * unambiguous check only; it does not attempt to validate self-hosted
254
+ * domains, which cannot be distinguished from a URL string alone.
255
+ *
256
+ * Credential guard (#907, CWE-214): the raw `git remote get-url` output can
257
+ * be `https://user:token@host/...` (GitLab-CI checkout pattern). The userinfo
258
+ * is stripped HERE, at the single source both `resolveRepoSpec` and
259
+ * `resolveRepoHost` flow through, BEFORE the cross-family host check and
260
+ * before the value can reach any `-R`/`--repo`/`--hostname` argv position —
261
+ * see {@link stripUrlCredentials}. A credential-free URL is unchanged
262
+ * (byte-identical), so #839/#872 behaviour is preserved.
263
+ *
264
+ * @param {{
265
+ * repoRoot?: string,
266
+ * vcs?: 'gitlab' | 'github',
267
+ * gitRun?: (args: string[]) => { ok: boolean, stdout: string, stderr: string }
268
+ * }} [opts]
269
+ * @returns {string|undefined}
270
+ */
271
+ function resolveRawRemoteUrl({ repoRoot, vcs = 'gitlab', gitRun = defaultGitRun } = {}) {
272
+ const vcsResolved = vcs === 'github' ? 'github' : 'gitlab';
273
+ const root = repoRoot ?? process.cwd();
274
+ const wrongFamilyHost = WRONG_FAMILY_HOST[vcsResolved];
275
+
276
+ for (const remote of REMOTE_PREFERENCE[vcsResolved]) {
277
+ const { ok, stdout } = gitRun(['-C', root, 'remote', 'get-url', remote]);
278
+ const url = ok ? stripUrlCredentials(stdout.trim()) : '';
279
+ if (!url) continue;
280
+ if (extractHost(url) === wrongFamilyHost) continue;
281
+ return url;
282
+ }
283
+ return undefined;
284
+ }
285
+
286
+ /**
287
+ * Resolve the glab/gh `-R`/`--repo` host-pinning spec.
288
+ *
289
+ * Format contract differs by vcs, because `glab -R` and `gh -R` accept
290
+ * different spec shapes:
291
+ * - `vcs: 'gitlab'` (default): the RAW remote URL, verbatim. `glab -R`
292
+ * explicitly accepts a full URL, and GitLab group namespaces can nest
293
+ * arbitrarily deep (`group/subgroup/project`), which makes a reliable
294
+ * `OWNER/REPO` derivation impossible from the URL alone — so the raw URL
295
+ * is the only unambiguous spec here.
296
+ * - `vcs: 'github'`: the NORMALIZED `HOST/OWNER/REPO` form (see
297
+ * `normalizeGithubSpec`). `gh -R`/`--repo` documents ONLY
298
+ * `[HOST/]OWNER/REPO` as accepted input — a raw URL is not guaranteed to
299
+ * parse the same way, and GitHub repos are always exactly two path
300
+ * segments (owner/repo), so the derivation is unambiguous.
301
+ *
302
+ * Returns `undefined` when no matching remote resolves — callers MUST treat
303
+ * this as "could not auto-detect" and omit the `-R`/`--repo` flag entirely
304
+ * (never emit `-R undefined`). Also returns `undefined` when the resolved
305
+ * spec contains whitespace or a control character — see the module-level
306
+ * argv-boundary guard ({@link isUnsafeForArgv}), which this function applies
307
+ * to the FINAL spec value (post `normalizeGithubSpec`, when applicable).
308
+ *
309
+ * @param {{
310
+ * repoRoot?: string,
311
+ * vcs?: 'gitlab' | 'github',
312
+ * gitRun?: (args: string[]) => { ok: boolean, stdout: string, stderr: string }
313
+ * }} [opts]
314
+ * @returns {string|undefined}
315
+ */
316
+ export function resolveRepoSpec({ repoRoot, vcs = 'gitlab', gitRun = defaultGitRun } = {}) {
317
+ const vcsResolved = vcs === 'github' ? 'github' : 'gitlab';
318
+ const url = resolveRawRemoteUrl({ repoRoot, vcs: vcsResolved, gitRun });
319
+ if (!url) return undefined;
320
+ const spec = vcsResolved === 'github' ? normalizeGithubSpec(url) : url;
321
+ return isUnsafeForArgv(spec) ? undefined : spec;
322
+ }
323
+
324
+ /**
325
+ * Resolve the bare hostname of the matching remote, for use with
326
+ * `glab api --hostname`/`gh api --hostname` — the `api` subcommand of both
327
+ * CLIs does NOT accept `-R`/`--repo` (it has no repo concept), only a
328
+ * `--hostname` flag to pin which instance the request targets. This is the
329
+ * host-pinning counterpart to `resolveRepoSpec` for those api-only call
330
+ * sites.
331
+ *
332
+ * Applies the identical remote-preference-order + cross-family-guard
333
+ * resolution as `resolveRepoSpec`, just returning the host instead of the
334
+ * full spec — same contract: `undefined` ⇒ caller omits the flag entirely.
335
+ * Also applies the same argv-boundary guard ({@link isUnsafeForArgv}) to the
336
+ * resolved host before returning it.
337
+ *
338
+ * @param {{
339
+ * repoRoot?: string,
340
+ * vcs?: 'gitlab' | 'github',
341
+ * gitRun?: (args: string[]) => { ok: boolean, stdout: string, stderr: string }
342
+ * }} [opts]
343
+ * @returns {string|undefined}
344
+ */
345
+ export function resolveRepoHost({ repoRoot, vcs, gitRun } = {}) {
346
+ const url = resolveRawRemoteUrl({ repoRoot, vcs, gitRun });
347
+ const host = url ? (extractHost(url) ?? undefined) : undefined;
348
+ return isUnsafeForArgv(host) ? undefined : host;
349
+ }
350
+
351
+ /**
352
+ * @deprecated Back-compat alias for `archive-closed-prds.mjs`'s original
353
+ * `defaultGlabRepo(repoRoot, gitRunFn)` positional signature (gitlab-only).
354
+ * New callers should use `resolveRepoSpec`.
355
+ *
356
+ * @param {string} repoRoot
357
+ * @param {(args: string[]) => { ok: boolean, stdout: string, stderr: string }} gitRunFn
358
+ * @returns {string|undefined}
359
+ */
360
+ export function defaultGlabRepo(repoRoot, gitRunFn) {
361
+ return resolveRepoSpec({ repoRoot, vcs: 'gitlab', gitRun: gitRunFn });
362
+ }