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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/030-wave-execution.mdc +17 -1
- package/CHANGELOG.md +130 -412
- package/README.md +17 -12
- package/SECURITY.md +190 -27
- package/agents/AGENTS.md +20 -3
- package/agents/code-implementer.md +6 -6
- package/agents/db-specialist.md +1 -1
- package/agents/qa-strategist.md +31 -6
- package/agents/schemas/qa-strategist.schema.json +27 -0
- package/agents/schemas/test-writer.schema.json +60 -2
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +1 -1
- package/agents/test-writer.md +29 -10
- package/agents/ui-developer.md +1 -1
- package/commands/contract-version-bump.md +28 -0
- package/commands/portfolio.md +1 -1
- package/docs/README.md +2 -1
- package/docs/USER-GUIDE.md +8 -3
- package/docs/ci-setup.md +121 -7
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +7 -7
- package/docs/cursor-setup.md +22 -9
- package/docs/events-schema.md +5 -1
- package/docs/instruction-delivery.md +444 -0
- package/docs/pi-setup.md +1 -1
- package/docs/rule-authoring.md +58 -9
- package/docs/session-config-reference.md +306 -6
- package/docs/session-config-template.md +66 -3
- package/docs/telemetry/telemetry-claims.md +204 -0
- package/docs/telemetry.md +158 -0
- package/hooks/_lib/guard-source-loader.mjs +467 -0
- package/hooks/_lib/lock-bootstrap.mjs +21 -0
- package/hooks/_lib/vcs-create-matcher.mjs +119 -0
- package/hooks/config-protection.mjs +0 -0
- package/hooks/enforce-commands.mjs +10 -2
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +11 -2
- package/hooks/hooks-pi.json +10 -0
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +178 -18
- package/hooks/on-session-start.mjs +23 -0
- package/hooks/post-bash-write-verify.mjs +977 -0
- package/hooks/post-subagent-discovery-validator.mjs +256 -41
- package/hooks/pre-bash-destructive-guard.mjs +525 -160
- package/hooks/pre-bash-issue-budget.mjs +167 -0
- package/hooks/pre-bash-sessions-ledger-guard.mjs +627 -0
- package/hooks/pre-bash-templates-first.mjs +96 -63
- package/hooks/skill-invocation-telemetry.mjs +109 -10
- package/hooks/subagent-telemetry.mjs +527 -37
- package/package.json +16 -3
- package/pi/prompts/contract-version-bump.md +12 -0
- package/rules/README.md +32 -0
- package/scripts/archive-closed-prds.mjs +12 -22
- package/scripts/autopilot-multi.mjs +103 -20
- package/scripts/backfill-abandoned-sessions.mjs +160 -4
- package/scripts/check-doc-consistency.sh +17 -1
- package/scripts/compute-grounding-injection.sh +18 -3
- package/scripts/dialectic-deriver.mjs +7 -2
- package/scripts/eval-session.mjs +50 -9
- package/scripts/fleet-instruction-scan.mjs +141 -0
- package/scripts/lib/auto-dialectic.mjs +11 -2
- package/scripts/lib/auto-dream.mjs +16 -5
- package/scripts/lib/autopilot/mr-draft.mjs +31 -1
- package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
- package/scripts/lib/backlog-scan.mjs +39 -6
- package/scripts/lib/blocked-commands-policy.mjs +340 -0
- package/scripts/lib/build-live-signals.mjs +7 -4
- package/scripts/lib/ci-status-banner.mjs +75 -12
- package/scripts/lib/claude-md-budget-lint.mjs +283 -34
- package/scripts/lib/command-blocker.mjs +1013 -58
- package/scripts/lib/config/config-protection.mjs +2 -1
- package/scripts/lib/config/context-coverage.mjs +82 -0
- package/scripts/lib/config/drift-check.mjs +9 -1
- package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
- package/scripts/lib/config/issue-budget.mjs +123 -0
- package/scripts/lib/config/moc-staleness.mjs +98 -0
- package/scripts/lib/config/reconcile.mjs +21 -0
- package/scripts/lib/config/section-extractor.mjs +121 -1
- package/scripts/lib/config/worktree-orphans.mjs +138 -0
- package/scripts/lib/config-schema.mjs +23 -3
- package/scripts/lib/config.mjs +32 -0
- package/scripts/lib/context-coverage-banner.mjs +223 -0
- package/scripts/lib/convergence-monitor.mjs +49 -3
- package/scripts/lib/description-surface.mjs +535 -0
- package/scripts/lib/dispatcher/enumerate.mjs +136 -30
- package/scripts/lib/dispatcher/rank.mjs +22 -8
- package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
- package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
- package/scripts/lib/eval/engine.mjs +47 -5
- package/scripts/lib/events.mjs +59 -7
- package/scripts/lib/evolve/autonomy-verdict.mjs +5 -0
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +54 -7
- package/scripts/lib/gates/gate-full.mjs +15 -3
- package/scripts/lib/gates/gate-helpers.mjs +132 -6
- package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
- package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
- package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
- package/scripts/lib/handover-gate.mjs +7 -3
- package/scripts/lib/harness-audit/categories/category4.mjs +22 -5
- package/scripts/lib/instruction-budget-guard.mjs +402 -51
- package/scripts/lib/io.mjs +345 -10
- package/scripts/lib/issue-budget.mjs +269 -0
- package/scripts/lib/issue-close-strip-labels.mjs +39 -9
- package/scripts/lib/label-scope.mjs +47 -0
- package/scripts/lib/learnings/schema.mjs +43 -3
- package/scripts/lib/lock-reaper.mjs +1 -2
- package/scripts/lib/memory-proposals/schema.mjs +36 -1
- package/scripts/lib/moc-staleness-banner.mjs +267 -0
- package/scripts/lib/peer-discovery.mjs +645 -0
- package/scripts/lib/pi-hook-bridge.mjs +146 -17
- package/scripts/lib/product-repo-detect.mjs +9 -8
- package/scripts/lib/project-hygiene.mjs +432 -0
- package/scripts/lib/quality-gate.mjs +167 -0
- package/scripts/lib/recommendations-v0.mjs +1 -1
- package/scripts/lib/reconcile/eligibility.mjs +1 -1
- package/scripts/lib/reconcile/emitter.mjs +23 -4
- package/scripts/lib/reconcile/engine.mjs +147 -39
- package/scripts/lib/reconcile/idempotency.mjs +114 -14
- package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
- package/scripts/lib/resource-probe/evaluate.mjs +70 -4
- package/scripts/lib/resource-probe.mjs +19 -0
- package/scripts/lib/rule-loader.mjs +6 -0
- package/scripts/lib/scope-baseline.mjs +564 -0
- package/scripts/lib/scope-gate.mjs +399 -98
- package/scripts/lib/session-close-backfill.mjs +61 -6
- package/scripts/lib/session-end/phase-skip.mjs +1 -0
- package/scripts/lib/session-end/worktree-orphan-sweep.mjs +252 -0
- package/scripts/lib/session-id.mjs +221 -41
- package/scripts/lib/session-lock.mjs +304 -6
- package/scripts/lib/session-schema/constants.mjs +22 -3
- package/scripts/lib/session-schema/filters.mjs +88 -0
- package/scripts/lib/session-schema/validator.mjs +16 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/sessions-integrity-banner.mjs +294 -0
- package/scripts/lib/sessions-staleness-banner.mjs +121 -12
- package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
- package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
- package/scripts/lib/skill-health/join.mjs +35 -9
- package/scripts/lib/spiral-carryover.mjs +142 -30
- package/scripts/lib/state-md/mission-status.mjs +53 -3
- package/scripts/lib/subagents-schema.mjs +43 -9
- package/scripts/lib/telemetry/anon-id.mjs +141 -0
- package/scripts/lib/telemetry/consent.mjs +299 -0
- package/scripts/lib/telemetry/paths.mjs +27 -0
- package/scripts/lib/telemetry/queue.mjs +287 -0
- package/scripts/lib/telemetry/schema.mjs +384 -0
- package/scripts/lib/telemetry/sync.mjs +312 -0
- package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
- package/scripts/lib/tests-src-ratio.mjs +484 -0
- package/scripts/lib/validate/check-agents.mjs +56 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
- package/scripts/lib/validate/check-rules.mjs +217 -35
- package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate-vendored-rules.mjs +10 -2
- package/scripts/lib/vault-archive.mjs +17 -2
- package/scripts/lib/vault-backfill/glab.mjs +8 -0
- package/scripts/lib/vault-mirror/process.mjs +30 -0
- package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
- package/scripts/lib/vault-status/board-writer.mjs +63 -5
- package/scripts/lib/vault-status/narrative-mirror.mjs +13 -7
- package/scripts/lib/vcs-repo-spec.mjs +362 -0
- package/scripts/lib/wave-resource-gate.mjs +115 -11
- package/scripts/lib/worktree/listing.mjs +44 -7
- package/scripts/mcp-server.sh +32 -6
- package/scripts/measure-context-overhead.sh +151 -0
- package/scripts/memory-propose.mjs +72 -9
- package/scripts/print-applicable-rules.mjs +51 -12
- package/scripts/release.mjs +534 -0
- package/scripts/run-quality-gate.mjs +123 -5
- package/scripts/telemetry.mjs +250 -0
- package/scripts/validate-wave-scope.mjs +182 -17
- package/scripts/vault-integration-watcher.mjs +32 -10
- package/skills/_shared/config-reading.md +2 -2
- package/skills/bootstrap/fast-template.md +1 -1
- package/skills/claude-md-drift-check/checker.mjs +145 -28
- package/skills/contract-version-bump/SKILL.md +219 -0
- package/skills/discovery/SKILL.md +4 -4
- package/skills/discovery/issue-templates.md +11 -11
- package/skills/discovery/probes-audit.md +1 -1
- package/skills/discovery/probes-feature.md +1 -1
- package/skills/discovery/probes-session.md +26 -5
- package/skills/ecosystem-health/SKILL.md +1 -1
- package/skills/ecosystem-health/wizard.md +4 -4
- package/skills/evolve/SKILL.md +1 -0
- package/skills/gitlab-ops/SKILL.md +20 -12
- package/skills/gitlab-portfolio/SKILL.md +2 -2
- package/skills/hook-development/SKILL.md +1 -1
- package/skills/mode-selector/SKILL.md +1 -1
- package/skills/npm-publish/SKILL.md +97 -0
- package/skills/plan/SKILL.md +5 -5
- package/skills/plan/mode-feature.md +4 -4
- package/skills/plan/mode-new.md +10 -10
- package/skills/plan/mode-retro.md +1 -1
- package/skills/quality-gates/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +21 -4
- package/skills/session-end/SKILL.md +108 -14
- package/skills/session-end/discovery-scan.md +4 -2
- package/skills/session-end/drift-operations.md +4 -4
- package/skills/session-end/metrics-collection.md +13 -0
- package/skills/session-end/phase-3-2-docs-verification.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +2 -1
- package/skills/session-end/plan-verification.md +5 -2
- package/skills/session-end/vault-operations.md +1 -1
- package/skills/session-end/verification-checklist.md +1 -1
- package/skills/session-plan/SKILL.md +6 -2
- package/skills/session-plan/wave-template.md +2 -0
- package/skills/session-start/SKILL.md +149 -7
- package/skills/session-start/phase-4-5-resource-health.md +15 -2
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/vault-sync/SKILL.md +1 -1
- package/skills/vault-sync/package-lock.json +3 -3
- package/skills/vault-sync/validator.mjs +228 -40
- package/skills/wave-executor/SKILL.md +5 -2
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/wave-loop.md +163 -10
- 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
|
|
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
|
-
|
|
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 (
|
|
700
|
-
*
|
|
701
|
-
*
|
|
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
|
+
}
|