session-orchestrator 3.20.0 → 3.22.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 (202) 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/000-session-orchestrator.mdc +3 -2
  5. package/.cursor/rules/030-wave-execution.mdc +10 -8
  6. package/.cursor/rules/040-discovery.mdc +6 -6
  7. package/.cursor/rules/050-plan.mdc +8 -8
  8. package/CHANGELOG.md +515 -0
  9. package/README.md +16 -11
  10. package/agents/analyst.md +1 -1
  11. package/agents/architect-reviewer.md +1 -1
  12. package/agents/code-implementer.md +4 -2
  13. package/agents/db-specialist.md +1 -1
  14. package/agents/dialectic-deriver.md +1 -1
  15. package/agents/docs-writer.md +1 -1
  16. package/agents/memory-proposal-collector.md +7 -5
  17. package/agents/qa-strategist.md +1 -1
  18. package/agents/security-reviewer.md +1 -1
  19. package/agents/session-reviewer.md +42 -1
  20. package/agents/skill-applied-judge.md +1 -1
  21. package/agents/test-writer.md +1 -1
  22. package/agents/ui-developer.md +1 -1
  23. package/agents/ux-evaluator.md +1 -1
  24. package/commands/eli5.md +33 -0
  25. package/commands/release.md +62 -0
  26. package/commands/test.md +2 -2
  27. package/docs/components.md +6 -5
  28. package/docs/migration-v3.md +9 -6
  29. package/docs/persona-panel.md +3 -1
  30. package/docs/scope-collision-guard.md +167 -0
  31. package/docs/session-config-reference.md +31 -8
  32. package/hooks/_lib/lock-bootstrap.mjs +19 -13
  33. package/hooks/enforce-scope.mjs +103 -3
  34. package/hooks/hooks-codex.json +1 -1
  35. package/hooks/hooks.json +21 -1
  36. package/hooks/on-session-end.mjs +76 -97
  37. package/hooks/on-session-start.mjs +195 -104
  38. package/hooks/on-stop.mjs +127 -12
  39. package/hooks/post-bash-write-verify.mjs +8 -32
  40. package/hooks/pre-auq-clarity.mjs +787 -0
  41. package/hooks/pre-bash-issue-budget.mjs +17 -18
  42. package/hooks/pre-task-scope-disjoint.mjs +1042 -0
  43. package/package.json +3 -1
  44. package/pi/prompts/eli5.md +12 -0
  45. package/pi/prompts/release.md +12 -0
  46. package/scripts/auq-audit.mjs +825 -0
  47. package/scripts/autopilot.mjs +10 -9
  48. package/scripts/emit-session.mjs +42 -0
  49. package/scripts/export-hw-learnings.mjs +61 -2
  50. package/scripts/lib/auq/clarity.mjs +1314 -0
  51. package/scripts/lib/auq/parse.mjs +1006 -0
  52. package/scripts/lib/auq/schema.mjs +1457 -0
  53. package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
  54. package/scripts/lib/backlog-scan.mjs +106 -15
  55. package/scripts/lib/build-live-signals.mjs +7 -3
  56. package/scripts/lib/ci-status-banner.mjs +267 -77
  57. package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
  58. package/scripts/lib/config/vault-integration.mjs +12 -1
  59. package/scripts/lib/dispatcher/rank.mjs +4 -7
  60. package/scripts/lib/gates/gate-full.mjs +3 -3
  61. package/scripts/lib/gates/gate-helpers.mjs +17 -6
  62. package/scripts/lib/git-config-drift.mjs +471 -0
  63. package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
  64. package/scripts/lib/io.mjs +432 -7
  65. package/scripts/lib/issue-budget.mjs +63 -9
  66. package/scripts/lib/learnings/select.mjs +157 -3
  67. package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
  68. package/scripts/lib/mirror-issues-banner.mjs +266 -0
  69. package/scripts/lib/named-vault-resolver.mjs +105 -16
  70. package/scripts/lib/owner-interview.mjs +78 -32
  71. package/scripts/lib/peer-cards/schema.mjs +6 -2
  72. package/scripts/lib/peer-discovery.mjs +73 -22
  73. package/scripts/lib/project-hygiene.mjs +64 -4
  74. package/scripts/lib/reconcile/renderer.mjs +17 -4
  75. package/scripts/lib/reconcile/writer.mjs +69 -30
  76. package/scripts/lib/redact-spans.mjs +89 -0
  77. package/scripts/lib/resource-probe/evaluate.mjs +330 -149
  78. package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
  79. package/scripts/lib/resource-probe.mjs +18 -2
  80. package/scripts/lib/scope-baseline.mjs +77 -17
  81. package/scripts/lib/scope-gate.mjs +658 -0
  82. package/scripts/lib/secret-masker.mjs +262 -0
  83. package/scripts/lib/session-lock.mjs +34 -10
  84. package/scripts/lib/session-registry.mjs +9 -1
  85. package/scripts/lib/spiral-carryover.mjs +23 -2
  86. package/scripts/lib/state-md/mission-status.mjs +164 -58
  87. package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
  88. package/scripts/lib/validate/check-agents.mjs +77 -5
  89. package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
  90. package/scripts/lib/validate/check-commands.mjs +2 -20
  91. package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
  92. package/scripts/lib/validate/check-hooks-symmetry.mjs +48 -0
  93. package/scripts/lib/validate/check-owner-leakage.mjs +185 -17
  94. package/scripts/lib/validate/check-rules.mjs +153 -9
  95. package/scripts/lib/validate/check-skills.mjs +191 -0
  96. package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
  97. package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
  98. package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
  99. package/scripts/lib/validate/check-unwired-features.mjs +219 -11
  100. package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
  101. package/scripts/lib/validate/frontmatter-block.mjs +61 -0
  102. package/scripts/lib/validate/tier-inference.mjs +46 -8
  103. package/scripts/lib/vault-backfill/glab.mjs +91 -58
  104. package/scripts/lib/vault-backfill/manifest.mjs +28 -8
  105. package/scripts/lib/vault-mirror/namespace.mjs +146 -1
  106. package/scripts/lib/vault-mirror/process.mjs +264 -31
  107. package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
  108. package/scripts/lib/vault-status/board-writer.mjs +300 -56
  109. package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
  110. package/scripts/lib/vcs-repo-spec.mjs +680 -30
  111. package/scripts/lib/wave-resource-gate.mjs +67 -73
  112. package/scripts/materialize-wave-scope.mjs +281 -0
  113. package/scripts/print-learnings-index.mjs +30 -3
  114. package/scripts/release.mjs +983 -107
  115. package/scripts/run-quality-gate.mjs +14 -0
  116. package/scripts/site-numbers.mjs +1049 -0
  117. package/scripts/validate-plugin.mjs +64 -0
  118. package/scripts/validate-wave-scope.mjs +286 -12
  119. package/scripts/vault-backfill.mjs +32 -5
  120. package/scripts/vault-mirror.mjs +26 -1
  121. package/skills/_shared/monitor-patterns.md +24 -4
  122. package/skills/_shared/parallel-aware-auq.md +30 -24
  123. package/skills/_shared/parallel-aware-preamble.md +31 -2
  124. package/skills/_shared/state-ownership.md +49 -6
  125. package/skills/bootstrap/SKILL.md +2 -1
  126. package/skills/brainstorm/SKILL.md +18 -18
  127. package/skills/brainstorm/soul.md +12 -0
  128. package/skills/claude-md-drift-check/SKILL.md +9 -1
  129. package/skills/debug/SKILL.md +4 -1
  130. package/skills/discovery/SKILL.md +28 -24
  131. package/skills/discovery/issue-templates.md +4 -4
  132. package/skills/discovery/probes-code.md +2 -2
  133. package/skills/discovery/probes-feature.md +6 -6
  134. package/skills/discovery/probes-infra.md +2 -2
  135. package/skills/discovery/probes-session.md +5 -5
  136. package/skills/dispatcher/SKILL.md +10 -1
  137. package/skills/eli5/SKILL.md +43 -0
  138. package/skills/evolve/SKILL.md +8 -9
  139. package/skills/frontmatter-guard/SKILL.md +9 -1
  140. package/skills/gitlab-ops/SKILL.md +73 -59
  141. package/skills/gitlab-portfolio/SKILL.md +10 -1
  142. package/skills/grill/SKILL.md +6 -6
  143. package/skills/grill/soul.md +16 -0
  144. package/skills/memory-cleanup/SKILL.md +20 -7
  145. package/skills/npm-publish/SKILL.md +23 -51
  146. package/skills/peekaboo-driver/SKILL.md +3 -3
  147. package/skills/persona-panel/SKILL.md +3 -1
  148. package/skills/plan/SKILL.md +18 -16
  149. package/skills/plan/mode-feature.md +1 -1
  150. package/skills/plan/mode-new.md +42 -12
  151. package/skills/plan/soul.md +12 -0
  152. package/skills/reconcile/SKILL.md +3 -3
  153. package/skills/repo-audit/SKILL.md +10 -1
  154. package/skills/session-end/SKILL.md +97 -22
  155. package/skills/session-end/metrics-collection.md +1 -1
  156. package/skills/session-end/phase-3-6-tail.md +37 -2
  157. package/skills/session-end/session-metrics-write.md +4 -10
  158. package/skills/session-plan/SKILL.md +2 -2
  159. package/skills/session-plan/wave-template.md +1 -1
  160. package/skills/session-start/SKILL.md +82 -36
  161. package/skills/session-start/phase-2-5-docs-planning.md +8 -8
  162. package/skills/session-start/phase-4-5-resource-health.md +82 -19
  163. package/skills/session-start/soul.md +110 -0
  164. package/skills/spinout/SKILL.md +5 -1
  165. package/skills/sunset-review/SKILL.md +11 -1
  166. package/skills/test-runner/SKILL.md +2 -2
  167. package/skills/tmux-layout/SKILL.md +7 -2
  168. package/skills/using-orchestrator/SKILL.md +1 -1
  169. package/skills/vault-mirror/SKILL.md +10 -1
  170. package/skills/vault-sync/SKILL.md +10 -1
  171. package/skills/vault-sync/validator.mjs +55 -6
  172. package/skills/wave-executor/wave-loop.md +64 -12
  173. package/skills/write-executable-plan/SKILL.md +6 -6
  174. package/scripts/lib/mission-status-schema.mjs +0 -114
  175. package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
  176. package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
  177. package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
  178. package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
  179. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
  180. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
  181. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
  182. package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
  183. package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
  184. package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
  185. package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
  186. package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
  187. package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
  188. package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
  189. package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
  190. package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
  191. package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
  192. package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
  193. package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
  194. package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
  195. package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
  196. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
  197. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
  198. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
  199. package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
  200. package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
  201. package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
  202. package/skills/vault-sync/tests/schema-drift.test.mjs +0 -133
@@ -41,6 +41,19 @@
41
41
  * for values that bypass the source strip (e.g. a `--repo` override). See
42
42
  * `stripUrlCredentials` / `userinfoIsCredential`.
43
43
  *
44
+ * Remote-resolution core (#1039): since the `-R` spec is only ONE of several
45
+ * questions a caller asks about a repo's remotes, the file now carries a shared
46
+ * core below the frozen `-R`/`--hostname` exports — one primitive
47
+ * ({@link listRemotes}, a single `git remote -v` spawn) and three projections
48
+ * ({@link resolvePreferredRemote}, {@link detectVcsFamily},
49
+ * {@link resolveBaselineRange}). Every one of them returns a DISCRIMINATED
50
+ * result carrying a {@link REMOTE_RESOLUTION_REASONS} reason instead of a
51
+ * `T | null`, because `null` folds "no remote configured" onto "the query
52
+ * failed" — a fold that currently scores a fail-open 2/2 in
53
+ * `harness-audit/categories/category6.mjs`. `resolveRepoSpec` /
54
+ * `resolveRepoHost` keep their `string|undefined` contract verbatim and are now
55
+ * thin projections of that core.
56
+ *
44
57
  * Lifted out of `scripts/archive-closed-prds.mjs::defaultGlabRepo` (that
45
58
  * script's docblock described this exact problem months before #839 was
46
59
  * filed) into a shared `scripts/lib/` module so
@@ -158,12 +171,23 @@ export function redactUrlCredentials(text) {
158
171
  }
159
172
 
160
173
  /**
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, ...).
174
+ * Default git runner: `git <gitArgs>`.
175
+ * Never throws — returns `{ ok:false, stdout:'', stderr, status, code }` on any
176
+ * failure (missing remote, not a git repo, git not on PATH, ...).
177
+ *
178
+ * `status` (process exit code) and `code` (spawn errno, e.g. `'ENOENT'`) were
179
+ * added for {@link listRemotes}'s failure taxonomy: git's own exit codes are
180
+ * the ONLY signal that distinguishes "this is not a git repository" (128) from
181
+ * "git is not installed" (spawn ENOENT) from "there are simply no remotes"
182
+ * (exit 0, empty stdout). Folding those three onto one falsy value is the
183
+ * defect class #1039 was filed against — see {@link REMOTE_RESOLUTION_REASONS}.
184
+ *
185
+ * Both fields are OPTIONAL in the `gitRun` DI contract: an injected test stub
186
+ * that returns only `{ ok, stdout, stderr }` still works, and its failures
187
+ * classify as the generic `'git-error'`.
164
188
  *
165
189
  * @param {string[]} gitArgs
166
- * @returns {{ ok: boolean, stdout: string, stderr: string }}
190
+ * @returns {GitRunResult}
167
191
  */
168
192
  function defaultGitRun(gitArgs) {
169
193
  try {
@@ -171,11 +195,17 @@ function defaultGitRun(gitArgs) {
171
195
  encoding: 'utf8',
172
196
  stdio: ['ignore', 'pipe', 'pipe'],
173
197
  });
174
- return { ok: true, stdout: String(stdout ?? ''), stderr: '' };
198
+ return { ok: true, stdout: String(stdout ?? ''), stderr: '', status: 0 };
175
199
  } catch (err) {
176
200
  const stderr =
177
201
  err && err.stderr ? String(err.stderr) : err && err.message ? String(err.message) : 'unknown error';
178
- return { ok: false, stdout: '', stderr };
202
+ return {
203
+ ok: false,
204
+ stdout: '',
205
+ stderr,
206
+ status: err && typeof err.status === 'number' ? err.status : undefined,
207
+ code: err && typeof err.code === 'string' ? err.code : undefined,
208
+ };
179
209
  }
180
210
  }
181
211
 
@@ -197,17 +227,56 @@ const WRONG_FAMILY_HOST = {
197
227
  github: 'gitlab.com',
198
228
  };
199
229
 
230
+ /** URI schemes that can name a supported Git remote. */
231
+ const REMOTE_URI_PROTOCOLS = new Set(['http:', 'https:', 'ssh:']);
232
+
233
+ /**
234
+ * Parse a URI-style remote only when it uses one of this module's supported
235
+ * protocols. The `URL` parser makes hostname/port handling consistent between
236
+ * HTTP(S) and URI-style SSH while scp-style SSH stays a separate grammar.
237
+ *
238
+ * @param {string} url
239
+ * @returns {URL|null}
240
+ */
241
+ function parseRemoteUri(url) {
242
+ if (typeof url !== 'string' || !/^(?:https?|ssh):\/\//i.test(url)) return null;
243
+ try {
244
+ const parsed = new URL(url);
245
+ return REMOTE_URI_PROTOCOLS.has(parsed.protocol) ? parsed : null;
246
+ } catch {
247
+ return null;
248
+ }
249
+ }
250
+
200
251
  /**
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).
252
+ * Extract the operational host from a git remote URL, handling HTTPS
253
+ * (`https://host/owner/repo.git`), scp-style SSH (`git@host:owner/repo.git`),
254
+ * and URI-style SSH (`ssh://git@host/owner/repo.git`) forms. A non-default
255
+ * URI port is preserved because callers may need it to address a self-hosted
256
+ * instance. Returns `null` for an unrecognized shape (never throws).
204
257
  *
205
258
  * @param {string} url
206
259
  * @returns {string|null}
207
260
  */
208
261
  function extractHost(url) {
209
- const httpsMatch = /^https?:\/\/([^/]+)/i.exec(url);
210
- if (httpsMatch) return httpsMatch[1].toLowerCase();
262
+ const parsed = parseRemoteUri(url);
263
+ if (parsed !== null) return parsed.host.toLowerCase() || null;
264
+ const sshMatch = /^[^@\s]+@([^:\s]+):/i.exec(url);
265
+ if (sshMatch) return sshMatch[1].toLowerCase();
266
+ return null;
267
+ }
268
+
269
+ /**
270
+ * Extract a bare hostname for VCS-family comparisons. This deliberately drops
271
+ * a URI port: `github.com:443` is still the public GitHub host, while the
272
+ * operational `extractHost()` value retains a non-default self-hosted port.
273
+ *
274
+ * @param {string} url
275
+ * @returns {string|null}
276
+ */
277
+ function extractHostname(url) {
278
+ const parsed = parseRemoteUri(url);
279
+ if (parsed !== null) return parsed.hostname.toLowerCase() || null;
211
280
  const sshMatch = /^[^@\s]+@([^:\s]+):/i.exec(url);
212
281
  if (sshMatch) return sshMatch[1].toLowerCase();
213
282
  return null;
@@ -261,26 +330,29 @@ function normalizeGithubSpec(url) {
261
330
  * see {@link stripUrlCredentials}. A credential-free URL is unchanged
262
331
  * (byte-identical), so #839/#872 behaviour is preserved.
263
332
  *
333
+ * Since #1039 this is a THIN projection of {@link resolvePreferredRemote} and
334
+ * performs no git call of its own. Two reasons the delegation is load-bearing:
335
+ *
336
+ * 1. **One credential-strip source (#907, CWE-214).** The userinfo strip now
337
+ * lives in {@link listRemotes}, at the single point every remote URL in
338
+ * this module enters from. A second code path *around* that source would
339
+ * re-open the leak — which is exactly why this function must not read a
340
+ * remote URL itself.
341
+ * 2. **One git call instead of N.** The former implementation ran one
342
+ * `git remote get-url <name>` spawn PER preference entry, on the
343
+ * session-start hot path. `listRemotes` runs `git remote -v` exactly once.
344
+ *
264
345
  * @param {{
265
346
  * repoRoot?: string,
266
347
  * vcs?: 'gitlab' | 'github',
267
- * gitRun?: (args: string[]) => { ok: boolean, stdout: string, stderr: string }
348
+ * gitRun?: GitRun
268
349
  * }} [opts]
269
350
  * @returns {string|undefined}
270
351
  */
271
352
  function resolveRawRemoteUrl({ repoRoot, vcs = 'gitlab', gitRun = defaultGitRun } = {}) {
272
353
  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;
354
+ const resolved = resolvePreferredRemote({ repoRoot, vcs: vcsResolved, gitRun });
355
+ return resolved.ok ? resolved.url : undefined;
284
356
  }
285
357
 
286
358
  /**
@@ -306,10 +378,22 @@ function resolveRawRemoteUrl({ repoRoot, vcs = 'gitlab', gitRun = defaultGitRun
306
378
  * argv-boundary guard ({@link isUnsafeForArgv}), which this function applies
307
379
  * to the FINAL spec value (post `normalizeGithubSpec`, when applicable).
308
380
  *
381
+ * **CHANGELOG-worthy behaviour change (#1039, operator-approved.)** This
382
+ * function inherits the SOLE-REMOTE FALLBACK from the shared core (see
383
+ * {@link resolvePreferredRemote}): a repo whose only remote is named something
384
+ * else (`upstream` in a fork, `gl` in a hand-configured clone) now resolves to
385
+ * that remote instead of returning `undefined`. The fallback fires EXCLUSIVELY
386
+ * where `undefined` was returned before — it can never redirect an
387
+ * already-resolving repo to a DIFFERENT target, because the preference order
388
+ * ({@link REMOTE_PREFERENCE}) is still consulted first and is byte-identical to
389
+ * the pre-#1039 list. The cross-family guard applies to the fallback candidate
390
+ * too, so a lone `github.com` remote under `vcs:'gitlab'` still yields
391
+ * `undefined` rather than a spec `glab` is guaranteed to reject.
392
+ *
309
393
  * @param {{
310
394
  * repoRoot?: string,
311
395
  * vcs?: 'gitlab' | 'github',
312
- * gitRun?: (args: string[]) => { ok: boolean, stdout: string, stderr: string }
396
+ * gitRun?: GitRun
313
397
  * }} [opts]
314
398
  * @returns {string|undefined}
315
399
  */
@@ -322,12 +406,12 @@ export function resolveRepoSpec({ repoRoot, vcs = 'gitlab', gitRun = defaultGitR
322
406
  }
323
407
 
324
408
  /**
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.
409
+ * Resolve the operational host of the matching remote, preserving a
410
+ * non-default self-hosted port for `glab api --hostname`/`gh api --hostname`.
411
+ * The `api` subcommand of both CLIs does NOT accept `-R`/`--repo` (it has no
412
+ * repo concept), only `--hostname` to pin which instance the request targets.
413
+ * This is the host-pinning counterpart to `resolveRepoSpec` for those api-only
414
+ * call sites.
331
415
  *
332
416
  * Applies the identical remote-preference-order + cross-family-guard
333
417
  * resolution as `resolveRepoSpec`, just returning the host instead of the
@@ -348,6 +432,136 @@ export function resolveRepoHost({ repoRoot, vcs, gitRun } = {}) {
348
432
  return isUnsafeForArgv(host) ? undefined : host;
349
433
  }
350
434
 
435
+ /** Project-path characters that cannot name a GitLab namespace/project. */
436
+ // eslint-disable-next-line no-control-regex -- validate every C0/C1 control before encoding a GitLab API path
437
+ const UNSAFE_PROJECT_PATH_CHARS_RE = /[\\\x00-\x1f\x7f-\x9f?#]/;
438
+
439
+ /**
440
+ * Decode a transport project path exactly once, then validate its canonical
441
+ * namespace/project shape. A percent sign surviving the one decode is rejected
442
+ * because `%252e%252e` is indistinguishable from a literal encoded escape; do
443
+ * not normalize that ambiguity into an API target.
444
+ *
445
+ * @param {string} rawProjectPath
446
+ * @param {{ uriPath: boolean }} opts
447
+ * @returns {string|undefined}
448
+ */
449
+ function normalizeGitlabProjectPath(rawProjectPath, { uriPath }) {
450
+ if (typeof rawProjectPath !== 'string') return undefined;
451
+
452
+ let projectPath = rawProjectPath;
453
+ if (uriPath) {
454
+ // URI syntax supplies one separator before the path. More than one is a
455
+ // path segment, not syntax to trim away.
456
+ if (!projectPath.startsWith('/') || projectPath.startsWith('//')) return undefined;
457
+ projectPath = projectPath.slice(1);
458
+ }
459
+
460
+ if (
461
+ projectPath === '' ||
462
+ projectPath.startsWith('/') ||
463
+ projectPath.endsWith('/') ||
464
+ UNSAFE_PROJECT_PATH_CHARS_RE.test(projectPath)
465
+ ) {
466
+ return undefined;
467
+ }
468
+
469
+ let decodedProjectPath;
470
+ try {
471
+ decodedProjectPath = decodeURIComponent(projectPath);
472
+ } catch {
473
+ return undefined;
474
+ }
475
+
476
+ if (
477
+ decodedProjectPath.includes('%') ||
478
+ UNSAFE_PROJECT_PATH_CHARS_RE.test(decodedProjectPath) ||
479
+ isUnsafeForArgv(decodedProjectPath)
480
+ ) {
481
+ return undefined;
482
+ }
483
+
484
+ const withoutGitSuffix = decodedProjectPath.replace(/\.git$/i, '');
485
+ const segments = withoutGitSuffix.split('/');
486
+ if (
487
+ withoutGitSuffix === '' ||
488
+ withoutGitSuffix.startsWith('/') ||
489
+ withoutGitSuffix.endsWith('/') ||
490
+ segments.length < 2 ||
491
+ segments.some((segment) => segment === '' || segment === '.' || segment === '..')
492
+ ) {
493
+ return undefined;
494
+ }
495
+
496
+ return withoutGitSuffix;
497
+ }
498
+
499
+ /**
500
+ * Extract an operational GitLab host and the raw (not URL-normalized) project
501
+ * path. URI parsing owns authority/port validation, while the raw path keeps
502
+ * dot and percent-encoded traversal visible to {@link normalizeGitlabProjectPath}
503
+ * before WHATWG URL normalization could erase it.
504
+ *
505
+ * @param {string} url
506
+ * @returns {{ host: string, rawProjectPath: string, uriPath: boolean }|undefined}
507
+ */
508
+ function extractGitlabProjectTargetParts(url) {
509
+ const scpMatch = /^[^@/\s]+@([^:/\s]+):(.+)$/.exec(url);
510
+ if (scpMatch) {
511
+ return { host: scpMatch[1], rawProjectPath: scpMatch[2], uriPath: false };
512
+ }
513
+
514
+ const parsed = parseRemoteUri(url);
515
+ if (
516
+ parsed === null ||
517
+ parsed.search !== '' ||
518
+ parsed.hash !== '' ||
519
+ ((parsed.protocol === 'http:' || parsed.protocol === 'https:') &&
520
+ (parsed.username !== '' || parsed.password !== ''))
521
+ ) {
522
+ return undefined;
523
+ }
524
+
525
+ // `parsed.pathname` is intentionally NOT used: the URL parser resolves
526
+ // `.`/`..` before this boundary can reject them. The regex reads only the
527
+ // path from an already-parsed, supported URI.
528
+ const rawPathMatch = /^[a-z][a-z0-9+.-]*:\/\/[^/?#]*(\/[^?#]*)?(?:[?#].*)?$/i.exec(url);
529
+ if (rawPathMatch === null) return undefined;
530
+ return { host: parsed.host, rawProjectPath: rawPathMatch[1] ?? '', uriPath: true };
531
+ }
532
+
533
+ /**
534
+ * Derive the API target for a GitLab project from the same sanitized,
535
+ * preference-selected remote that powers {@link resolveRepoSpec}. GitLab's REST
536
+ * API takes a URL-encoded `namespace/project` path rather than a remote URL or
537
+ * numeric project ID, so this projection removes the ambient project-metadata
538
+ * lookup from callers that need to pin both project and host.
539
+ *
540
+ * Supports HTTPS, scp-style SSH, and `ssh://` remotes. Credential stripping
541
+ * happens upstream in {@link listRemotes}; this helper returns only an
542
+ * operational host (including a non-default self-hosted port) and a once-only
543
+ * encoded project path, never a remote URL or HTTP userinfo. An SSH login such
544
+ * as `git@host` is transport identity, not project-path userinfo.
545
+ *
546
+ * @param {{ repoRoot?: string, gitRun?: GitRun }} [opts]
547
+ * @returns {{ host: string, encodedProjectPath: string }|undefined}
548
+ */
549
+ export function resolveGitlabProjectTarget({ repoRoot, gitRun = defaultGitRun } = {}) {
550
+ const url = resolveRawRemoteUrl({ repoRoot, vcs: 'gitlab', gitRun });
551
+ if (!url) return undefined;
552
+
553
+ const parts = extractGitlabProjectTargetParts(url);
554
+ if (!parts || isUnsafeForArgv(parts.host)) return undefined;
555
+
556
+ const projectPath = normalizeGitlabProjectPath(parts.rawProjectPath, { uriPath: parts.uriPath });
557
+ if (!projectPath) return undefined;
558
+
559
+ return {
560
+ host: parts.host.toLowerCase(),
561
+ encodedProjectPath: encodeURIComponent(projectPath),
562
+ };
563
+ }
564
+
351
565
  /**
352
566
  * @deprecated Back-compat alias for `archive-closed-prds.mjs`'s original
353
567
  * `defaultGlabRepo(repoRoot, gitRunFn)` positional signature (gitlab-only).
@@ -360,3 +574,439 @@ export function resolveRepoHost({ repoRoot, vcs, gitRun } = {}) {
360
574
  export function defaultGlabRepo(repoRoot, gitRunFn) {
361
575
  return resolveRepoSpec({ repoRoot, vcs: 'gitlab', gitRun: gitRunFn });
362
576
  }
577
+
578
+ /* ------------------------------------------------------------------------ *
579
+ * #1039 — remote resolution core: one primitive, three projections.
580
+ *
581
+ * Four probes across the repo resolved their git remote as the hard-coded
582
+ * literal `origin` and were therefore BLIND in every repo whose remotes are
583
+ * named `gitlab`/`github` (this repo's own shape: `github` + `origin`). The
584
+ * shared core below replaces that literal.
585
+ *
586
+ * The contract's load-bearing decision is that a resolution result is NEVER
587
+ * `T | null`. `null` folds "no remote is configured" (a legitimate, benign
588
+ * repo state) onto "the query failed" (a broken tool or a non-repo), and that
589
+ * fold is LIVE in this codebase: `scripts/lib/harness-audit/categories/
590
+ * category6.mjs:145-155` awards 2 of 2 points with the message "no github
591
+ * mirror remote configured — skipped" when its `git remote` call returns
592
+ * `null` — which it also does outside a git repo, and when git is not on PATH.
593
+ * A fail-open scoring 100%.
594
+ *
595
+ * So every projection returns a DISCRIMINATED result carrying a reason from
596
+ * {@link REMOTE_RESOLUTION_REASONS}, and {@link isQueryFailure} is the single
597
+ * predicate that separates "I could not ask" from "I asked, the answer is no".
598
+ * ------------------------------------------------------------------------ */
599
+
600
+ /**
601
+ * Every reason a remote resolution can fail, frozen so consumers can switch on
602
+ * it exhaustively. The list splits into two classes — the split, not the
603
+ * individual strings, is the point:
604
+ *
605
+ * QUERY FAILURE (the question could not be asked; see {@link isQueryFailure})
606
+ * - `not-a-git-repo` git exited 128 — the path is not inside a work tree.
607
+ * - `git-unavailable` the spawn failed with ENOENT — git is not on PATH.
608
+ * - `git-error` any other non-zero exit, or an injected `gitRun`
609
+ * stub that reported failure without an exit code.
610
+ *
611
+ * ABSENCE (the question was answered; the answer is "no remote for you")
612
+ * - `no-remotes` git exited 0 with an empty remote list — a fresh
613
+ * `git init`, or a clone-less work tree. BENIGN.
614
+ * - `no-matching-remote` >= 2 remotes exist and none matches the requested
615
+ * preference order. Deliberately NOT a guess: picking
616
+ * arbitrarily here means querying the WRONG project
617
+ * successfully, which is worse than not querying.
618
+ * - `unsafe-value` the chosen remote's name or URL carries whitespace /
619
+ * a C0 control character and must not reach an argv
620
+ * position ({@link isUnsafeForArgv}).
621
+ *
622
+ * @type {readonly RemoteResolutionReason[]}
623
+ */
624
+ export const REMOTE_RESOLUTION_REASONS = Object.freeze([
625
+ 'not-a-git-repo',
626
+ 'git-unavailable',
627
+ 'git-error',
628
+ 'no-remotes',
629
+ 'no-matching-remote',
630
+ 'unsafe-value',
631
+ ]);
632
+
633
+ /** @type {ReadonlySet<string>} */
634
+ const QUERY_FAILURE_REASONS = new Set(['not-a-git-repo', 'git-unavailable', 'git-error']);
635
+
636
+ /**
637
+ * `true` when `reason` means the question could not be ASKED (broken tool, no
638
+ * repo), `false` when it means the question was answered in the negative (no
639
+ * remote configured, no match, unsafe value).
640
+ *
641
+ * Consumers MUST branch on this rather than on truthiness: a query failure is a
642
+ * degraded measurement and should be surfaced (WARN / skip-with-reason), while
643
+ * an absence is a real, reportable repo state. Treating them alike is the
644
+ * category6.mjs fail-open documented above.
645
+ *
646
+ * An unknown / absent reason returns `false` — fail-safe toward "this is a real
647
+ * answer", so a future reason added to {@link REMOTE_RESOLUTION_REASONS}
648
+ * without updating this predicate never silently masks a genuine finding as a
649
+ * tooling glitch.
650
+ *
651
+ * @param {RemoteResolutionReason|string|undefined} reason
652
+ * @returns {boolean}
653
+ */
654
+ export function isQueryFailure(reason) {
655
+ return typeof reason === 'string' && QUERY_FAILURE_REASONS.has(reason);
656
+ }
657
+
658
+ /**
659
+ * Classify a failed {@link GitRunResult} into a query-failure reason.
660
+ *
661
+ * Named ceiling (BV-004): exit 128 is mapped to `not-a-git-repo` because that
662
+ * is what `git remote -v` returns for "not a git repository", and this module
663
+ * only ever runs read-only remote/ref plumbing where 128 has no other common
664
+ * cause. It is NOT a general git-exit-code taxonomy — revisit if a caller
665
+ * starts routing write commands (`git push`, `git fetch`) through `gitRun`,
666
+ * where 128 also covers auth and network fatals.
667
+ *
668
+ * @param {GitRunResult} res
669
+ * @returns {RemoteResolutionReason}
670
+ */
671
+ function classifyGitFailure(res) {
672
+ if (res && res.code === 'ENOENT') return 'git-unavailable';
673
+ if (res && res.status === 128) return 'not-a-git-repo';
674
+ return 'git-error';
675
+ }
676
+
677
+ /**
678
+ * One `git remote -v` output line: `<name>\t<url> (fetch|push)`.
679
+ *
680
+ * `(.*?)` is lazy with an anchored tail, so a URL containing a space (a
681
+ * corrupted `.git/config`, the argv-boundary guard's realistic source) is
682
+ * captured whole rather than truncated at the space. A line that does not match
683
+ * this shape at all is DROPPED — see {@link listRemotes}.
684
+ */
685
+ const REMOTE_V_LINE_RE = /^(\S+)\s+(.*?)\s+\((fetch|push)\)$/;
686
+
687
+ /**
688
+ * THE PRIMITIVE. Enumerate the repo's git remotes in ONE `git remote -v` spawn.
689
+ *
690
+ * Contract:
691
+ * - `{ ok: true, remotes: [{ name, url }] }` — the list, in git's own output
692
+ * order (alphabetical by remote name). **`remotes: []` is a VALID `ok:true`
693
+ * result** and means "this repo has no remotes", never "the query failed".
694
+ * Conflating the two is the defect this whole module section exists for.
695
+ * - `{ ok: false, reason, stderr }` — the query itself failed; `reason`
696
+ * always satisfies {@link isQueryFailure}.
697
+ *
698
+ * Only FETCH URLs are reported, one entry per remote name (first fetch line
699
+ * wins). Push URLs are a separate `remote.<name>.pushurl` concept that no
700
+ * `-R`/`--repo`/baseline-range consumer in this repo wants.
701
+ *
702
+ * Credential safety (#907, CWE-214): every URL passes through
703
+ * {@link stripUrlCredentials} HERE, at the single point remote URLs enter this
704
+ * module. Every other function in the file — including `resolveRepoSpec` and
705
+ * `resolveRepoHost` — reads its URLs from this function's output, so there is
706
+ * exactly ONE strip source and no path around it.
707
+ *
708
+ * Unparseable lines are dropped silently rather than failing the call: git
709
+ * cannot emit them, so their only source is a corrupted config or an embedded
710
+ * newline, and in both cases the remaining well-formed remotes are still the
711
+ * best available answer. A repo whose EVERY line is unparseable therefore
712
+ * reports `no-remotes` (absence), which is correct — nothing usable was found,
713
+ * and git did answer.
714
+ *
715
+ * @param {{ repoRoot?: string, gitRun?: GitRun }} [opts]
716
+ * @returns {{ ok: true, remotes: GitRemote[] }
717
+ * | { ok: false, reason: RemoteResolutionReason, stderr: string }}
718
+ */
719
+ export function listRemotes({ repoRoot, gitRun = defaultGitRun } = {}) {
720
+ const root = repoRoot ?? process.cwd();
721
+ const res = gitRun(['-C', root, 'remote', '-v']) ?? { ok: false, stdout: '', stderr: '' };
722
+
723
+ if (!res.ok) {
724
+ return { ok: false, reason: classifyGitFailure(res), stderr: String(res.stderr ?? '') };
725
+ }
726
+
727
+ /** @type {GitRemote[]} */
728
+ const remotes = [];
729
+ const seen = new Set();
730
+
731
+ for (const rawLine of String(res.stdout ?? '').split('\n')) {
732
+ const line = rawLine.replace(/\r$/, '');
733
+ const match = REMOTE_V_LINE_RE.exec(line);
734
+ if (match === null) continue;
735
+ const [, name, rawUrl, direction] = match;
736
+ if (direction !== 'fetch') continue;
737
+ if (seen.has(name)) continue;
738
+ const url = stripUrlCredentials(rawUrl).trim();
739
+ if (url === '') continue;
740
+ seen.add(name);
741
+ remotes.push({ name, url });
742
+ }
743
+
744
+ return { ok: true, remotes };
745
+ }
746
+
747
+ /**
748
+ * Remote-name preference order when NO `vcs` is supplied.
749
+ *
750
+ * **Operator decision (#1039) — deliberately DIFFERENT from
751
+ * {@link REMOTE_PREFERENCE}, do not "fix" the divergence.** The vcs-pinned
752
+ * lists put the platform-named remote first because the caller has already
753
+ * declared which platform it is talking to. The vcs-less order puts `origin`
754
+ * first because its callers (baseline ranges, family detection, vault-note
755
+ * namespacing) derive an IDENTITY from the answer, and identity must not move.
756
+ *
757
+ * Concretely, in THIS repo (`origin` → `…/infrastructure/session-orchestrator`,
758
+ * `github` → `…/Kanevry/session-orchestrator`), a `gitlab`-first order would
759
+ * re-namespace every existing vault note from `infrastructure/…` to
760
+ * `Kanevry/…`. Silent mass-rename of historical notes is not an improvement.
761
+ */
762
+ const VCS_LESS_PREFERENCE = Object.freeze(['origin', 'gitlab', 'github']);
763
+
764
+ /**
765
+ * Pick the one remote a repo-scoped command should target.
766
+ *
767
+ * Resolution, in order:
768
+ * 1. **Preference.** With `vcs` set: `REMOTE_PREFERENCE[vcs]`
769
+ * (`['<vcs>', 'origin']`, byte-identical to the pre-#1039 list, because
770
+ * 13 production importers of `resolveRepoSpec` inherit their `-R` target
771
+ * from it — a reordering would silently switch every one of them in a repo
772
+ * that has both remotes). With `vcs` omitted: {@link VCS_LESS_PREFERENCE}.
773
+ * 2. **Cross-family guard** (only when `vcs` is set): a candidate whose host
774
+ * is the OTHER platform's well-known public host is SKIPPED and resolution
775
+ * continues with the next preference entry. Passing `github.com` to
776
+ * `glab -R` is a guaranteed hard failure — strictly worse than resolving
777
+ * nothing. Unchanged from #839.
778
+ * 3. **Sole-remote fallback.** Exactly one remote configured and no
779
+ * preference hit → use it, `via:'sole-remote'`. This is what makes a fork
780
+ * (`upstream`) or a hand-named clone (`gl`) resolvable at all. The
781
+ * cross-family guard still applies to this candidate.
782
+ * 4. Otherwise `{ ok:false }` with `no-remotes` (nothing configured) or
783
+ * `no-matching-remote` (>= 2 remotes, none matched). With two or more
784
+ * candidates and no preference signal there is no non-arbitrary pick, and
785
+ * guessing means successfully querying the WRONG project — the failure
786
+ * mode is a silent wrong answer, not an error. The full `remotes` list
787
+ * rides along so the caller can surface the ambiguity to the operator.
788
+ *
789
+ * The argv-boundary guard runs AFTER the candidate is chosen, and an unsafe
790
+ * candidate ENDS resolution with `unsafe-value` rather than falling through to
791
+ * the next preference entry — preserving pre-#1039 behaviour, where an unsafe
792
+ * value likewise produced `undefined` and no retry.
793
+ *
794
+ * @param {{ repoRoot?: string, vcs?: 'gitlab'|'github', gitRun?: GitRun }} [opts]
795
+ * @returns {{ ok: true, name: string, url: string, via: 'preference'|'sole-remote' }
796
+ * | { ok: false, reason: RemoteResolutionReason, remotes?: GitRemote[], stderr?: string }}
797
+ */
798
+ export function resolvePreferredRemote({ repoRoot, vcs, gitRun = defaultGitRun } = {}) {
799
+ const listed = listRemotes({ repoRoot, gitRun });
800
+ if (!listed.ok) return { ok: false, reason: listed.reason, stderr: listed.stderr };
801
+
802
+ const { remotes } = listed;
803
+ if (remotes.length === 0) return { ok: false, reason: 'no-remotes', remotes };
804
+
805
+ const vcsPinned = vcs === 'github' || vcs === 'gitlab' ? vcs : null;
806
+ const order = vcsPinned ? REMOTE_PREFERENCE[vcsPinned] : VCS_LESS_PREFERENCE;
807
+ const wrongFamilyHost = vcsPinned ? WRONG_FAMILY_HOST[vcsPinned] : null;
808
+ const isWrongFamily = (url) => wrongFamilyHost !== null && extractHostname(url) === wrongFamilyHost;
809
+
810
+ /** @param {GitRemote} remote @param {'preference'|'sole-remote'} via */
811
+ const accept = (remote, via) =>
812
+ isUnsafeForArgv(remote.url) || isUnsafeForArgv(remote.name)
813
+ ? { ok: false, reason: /** @type {RemoteResolutionReason} */ ('unsafe-value'), remotes }
814
+ : { ok: true, name: remote.name, url: remote.url, via };
815
+
816
+ for (const name of order) {
817
+ const candidate = remotes.find((remote) => remote.name === name);
818
+ if (candidate === undefined) continue;
819
+ if (isWrongFamily(candidate.url)) continue;
820
+ return accept(candidate, 'preference');
821
+ }
822
+
823
+ if (remotes.length === 1 && !isWrongFamily(remotes[0].url)) {
824
+ return accept(remotes[0], 'sole-remote');
825
+ }
826
+
827
+ return { ok: false, reason: 'no-matching-remote', remotes };
828
+ }
829
+
830
+ /**
831
+ * Classify a single remote into a VCS family from its URL host, then its name.
832
+ *
833
+ * Host rule: `github.com` or any `github.*` host → github; `gitlab.com` or any
834
+ * `gitlab.*` host → gitlab. The `github.*` half is what keeps GitHub Enterprise
835
+ * (`github.example.com`) out of the gitlab bucket — a `url.includes('github.com')`
836
+ * test classifies it as gitlab and points `glab` at a GitHub instance.
837
+ *
838
+ * @param {GitRemote} remote
839
+ * @returns {{ family: 'gitlab'|'github', via: 'host-match'|'remote-name' }|null}
840
+ */
841
+ function classifyRemoteFamily(remote) {
842
+ const host = extractHostname(remote.url);
843
+ if (host !== null) {
844
+ if (host === 'github.com' || host.startsWith('github.')) return { family: 'github', via: 'host-match' };
845
+ if (host === 'gitlab.com' || host.startsWith('gitlab.')) return { family: 'gitlab', via: 'host-match' };
846
+ }
847
+ if (remote.name === 'github') return { family: 'github', via: 'remote-name' };
848
+ if (remote.name === 'gitlab') return { family: 'gitlab', via: 'remote-name' };
849
+ return null;
850
+ }
851
+
852
+ /**
853
+ * Decide which VCS family a repo belongs to, from its remotes — the projection
854
+ * that replaces "assume gitlab because the config says so".
855
+ *
856
+ * Precedence per remote: URL host, then remote name (see
857
+ * {@link classifyRemoteFamily}). Among classified remotes the representative is
858
+ * picked by {@link VCS_LESS_PREFERENCE}, then by git's own listing order — so
859
+ * in this repo (`github` → github.com, `origin` → gitlab.…) the answer is
860
+ * `gitlab` via `origin`, not `github` via the alphabetically-first remote.
861
+ *
862
+ * When NO remote classifies, `via:'default'` + `vcs:'gitlab'` preserves today's
863
+ * behaviour (every `resolveRepoSpec` caller already defaults to gitlab) —
864
+ * provided a representative remote can be named at all. When it cannot (>= 2
865
+ * unclassifiable remotes), the call fails with `no-matching-remote` rather than
866
+ * inventing one: naming the wrong remote is what #1039 is about.
867
+ *
868
+ * `ambiguous` is `true` when two or more remotes classify into DIFFERENT
869
+ * families — the ordinary GitLab-primary / GitHub-mirror shape. It is a signal
870
+ * for the caller to disclose the choice, not an error: `vcs` is still the
871
+ * preference-ordered answer, and `alternatives` names the remotes that would
872
+ * have said otherwise.
873
+ *
874
+ * @param {{ repoRoot?: string, gitRun?: GitRun }} [opts]
875
+ * @returns {{ ok: true, vcs: 'gitlab'|'github', name: string, url: string,
876
+ * via: 'host-match'|'remote-name'|'default', ambiguous: boolean,
877
+ * alternatives: string[] }
878
+ * | { ok: false, reason: RemoteResolutionReason, remotes?: GitRemote[], stderr?: string }}
879
+ */
880
+ export function detectVcsFamily({ repoRoot, gitRun = defaultGitRun } = {}) {
881
+ const listed = listRemotes({ repoRoot, gitRun });
882
+ if (!listed.ok) return { ok: false, reason: listed.reason, stderr: listed.stderr };
883
+
884
+ const { remotes } = listed;
885
+ if (remotes.length === 0) return { ok: false, reason: 'no-remotes', remotes };
886
+
887
+ const classified = remotes
888
+ .map((remote) => ({ remote, verdict: classifyRemoteFamily(remote) }))
889
+ .filter((entry) => entry.verdict !== null);
890
+
891
+ if (classified.length === 0) {
892
+ // No family signal anywhere. Fall back to today's implicit default
893
+ // (gitlab), but only if a representative remote can be NAMED — the
894
+ // preferred-remote resolution below refuses to guess among >= 2.
895
+ const preferred = resolvePreferredRemote({ repoRoot, gitRun });
896
+ if (!preferred.ok) return preferred;
897
+ return {
898
+ ok: true,
899
+ vcs: 'gitlab',
900
+ name: preferred.name,
901
+ url: preferred.url,
902
+ via: 'default',
903
+ ambiguous: false,
904
+ alternatives: [],
905
+ };
906
+ }
907
+
908
+ const chosen =
909
+ VCS_LESS_PREFERENCE.map((name) => classified.find((entry) => entry.remote.name === name)).find(
910
+ (entry) => entry !== undefined,
911
+ ) ?? classified[0];
912
+
913
+ const alternatives = classified
914
+ .filter((entry) => entry.verdict.family !== chosen.verdict.family)
915
+ .map((entry) => entry.remote.name);
916
+
917
+ return {
918
+ ok: true,
919
+ vcs: chosen.verdict.family,
920
+ name: chosen.remote.name,
921
+ url: chosen.remote.url,
922
+ via: chosen.verdict.via,
923
+ ambiguous: alternatives.length > 0,
924
+ alternatives,
925
+ };
926
+ }
927
+
928
+ /**
929
+ * Resolve the three-dot diff range a session-drift / scope measurement should
930
+ * run against — the projection that replaces the hard-coded literal
931
+ * `'origin/main...HEAD'` (live at `scripts/lib/scope-baseline.mjs:519`, which is
932
+ * silently inert in any repo whose remote is not named `origin` or whose default
933
+ * branch is not `main`).
934
+ *
935
+ * Chain, first hit wins:
936
+ * 1. {@link resolvePreferredRemote} (vcs-less) → the remote `R`.
937
+ * 2. `git symbolic-ref --short refs/remotes/<R>/HEAD` → `via:'remote-head'`.
938
+ * Only populated by an explicit `git remote set-head -a`, so it is the
939
+ * most authoritative and the least often present.
940
+ * 3. `git rev-parse --verify --quiet refs/remotes/<R>/main`, then `…/master`
941
+ * → `via:'remote-default-branch'`. Covers the freshly-pushed repo where
942
+ * nobody ever ran `set-head`.
943
+ * 4. `refs/heads/main`, then `refs/heads/master` → `via:'local-default-branch'`,
944
+ * gated on `allowLocalFallback` (default `true`). A local branch is a
945
+ * weaker baseline than a tracking ref — it does not know what the remote
946
+ * has — so a caller that needs a remote-anchored measurement passes
947
+ * `allowLocalFallback:false` and gets `no-tracking-ref` instead.
948
+ * 5. `{ ok:false, reason:'no-tracking-ref' }`, or `'unborn-head'` when the
949
+ * repo has no commit at all (probed only on this path, so the happy path
950
+ * costs nothing).
951
+ *
952
+ * **No root-commit fallback, by operator decision.** Diffing against the first
953
+ * commit of the repository yields a ratio over the ENTIRE history, which is not
954
+ * a session-drift measurement — it is a number that looks like one. An honest
955
+ * `no-tracking-ref` lets the caller skip with a reason.
956
+ *
957
+ * The range is always three-dot (`<base>...HEAD`, merge-base relative),
958
+ * identical to the semantics of the literal it replaces.
959
+ *
960
+ * @param {{ repoRoot?: string, gitRun?: GitRun, allowLocalFallback?: boolean }} [opts]
961
+ * @returns {{ ok: true, range: string, base: string, remote: string,
962
+ * via: 'remote-head'|'remote-default-branch'|'local-default-branch' }
963
+ * | { ok: false, reason: RemoteResolutionReason|'no-tracking-ref'|'unborn-head',
964
+ * remotes?: GitRemote[], stderr?: string }}
965
+ */
966
+ export function resolveBaselineRange({ repoRoot, gitRun = defaultGitRun, allowLocalFallback = true } = {}) {
967
+ const preferred = resolvePreferredRemote({ repoRoot, gitRun });
968
+ if (!preferred.ok) return preferred;
969
+
970
+ const root = repoRoot ?? process.cwd();
971
+ const remote = preferred.name;
972
+ const run = (args) => gitRun(['-C', root, ...args]) ?? { ok: false, stdout: '', stderr: '' };
973
+ const done = (base, via) => ({ ok: /** @type {true} */ (true), range: `${base}...HEAD`, base, remote, via });
974
+
975
+ const head = run(['symbolic-ref', '--short', `refs/remotes/${remote}/HEAD`]);
976
+ const headRef = head.ok ? head.stdout.trim() : '';
977
+ if (headRef !== '' && !isUnsafeForArgv(headRef)) return done(headRef, 'remote-head');
978
+
979
+ for (const branch of ['main', 'master']) {
980
+ const verified = run(['rev-parse', '--verify', '--quiet', `refs/remotes/${remote}/${branch}`]);
981
+ if (verified.ok && verified.stdout.trim() !== '') return done(`${remote}/${branch}`, 'remote-default-branch');
982
+ }
983
+
984
+ if (allowLocalFallback) {
985
+ for (const branch of ['main', 'master']) {
986
+ const verified = run(['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`]);
987
+ if (verified.ok && verified.stdout.trim() !== '') return done(branch, 'local-default-branch');
988
+ }
989
+ }
990
+
991
+ const headCommit = run(['rev-parse', '--verify', '--quiet', 'HEAD']);
992
+ const unborn = !headCommit.ok || headCommit.stdout.trim() === '';
993
+ return { ok: false, reason: unborn ? 'unborn-head' : 'no-tracking-ref' };
994
+ }
995
+
996
+ /**
997
+ * @typedef {'not-a-git-repo'|'git-unavailable'|'git-error'|'no-remotes'|'no-matching-remote'|'unsafe-value'} RemoteResolutionReason
998
+ */
999
+
1000
+ /**
1001
+ * @typedef {{ name: string, url: string }} GitRemote
1002
+ */
1003
+
1004
+ /**
1005
+ * @typedef {{ ok: boolean, stdout: string, stderr: string, status?: number, code?: string }} GitRunResult
1006
+ */
1007
+
1008
+ /**
1009
+ * Injectable git runner. `status`/`code` are OPTIONAL — a stub that omits them
1010
+ * still works; its failures classify as the generic `git-error`.
1011
+ * @typedef {(args: string[]) => GitRunResult} GitRun
1012
+ */