session-orchestrator 3.21.0 → 3.23.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 (345) 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/commands/autopilot-multi.md +14 -0
  5. package/.cursor/commands/autopilot.md +14 -0
  6. package/.cursor/commands/bootstrap.md +14 -0
  7. package/.cursor/commands/brainstorm.md +14 -0
  8. package/.cursor/commands/close.md +13 -0
  9. package/.cursor/commands/contract-version-bump.md +14 -0
  10. package/.cursor/commands/debug.md +14 -0
  11. package/.cursor/commands/discovery.md +14 -0
  12. package/.cursor/commands/dispatcher.md +14 -0
  13. package/.cursor/commands/eli5.md +14 -0
  14. package/.cursor/commands/eval.md +14 -0
  15. package/.cursor/commands/evolve.md +14 -0
  16. package/.cursor/commands/go.md +14 -0
  17. package/.cursor/commands/grill.md +14 -0
  18. package/.cursor/commands/harness-audit.md +13 -0
  19. package/.cursor/commands/journey-audit.md +14 -0
  20. package/.cursor/commands/memory-cleanup.md +14 -0
  21. package/.cursor/commands/persona-panel.md +14 -0
  22. package/.cursor/commands/plan.md +14 -0
  23. package/.cursor/commands/portfolio.md +14 -0
  24. package/.cursor/commands/reconcile.md +14 -0
  25. package/.cursor/commands/release.md +14 -0
  26. package/.cursor/commands/repo-audit.md +13 -0
  27. package/.cursor/commands/session.md +14 -0
  28. package/.cursor/commands/spinout.md +14 -0
  29. package/.cursor/commands/sunset-review.md +14 -0
  30. package/.cursor/commands/templates-ack.md +14 -0
  31. package/.cursor/commands/test.md +14 -0
  32. package/.cursor/hooks.json +60 -0
  33. package/.cursor/rules/000-session-orchestrator.mdc +11 -2
  34. package/.cursor/rules/010-session-workflow.mdc +9 -1
  35. package/.cursor/rules/020-quality-gates.mdc +1 -1
  36. package/.cursor/rules/030-wave-execution.mdc +1 -1
  37. package/.cursor/rules/040-discovery.mdc +6 -6
  38. package/.cursor/rules/050-plan.mdc +10 -10
  39. package/.cursor/rules/070-gitlab-ops.mdc +73 -57
  40. package/.cursor/rules/080-ecosystem-health.mdc +7 -7
  41. package/.cursor/skills/architecture/SKILL.md +13 -0
  42. package/.cursor/skills/autopilot/SKILL.md +12 -0
  43. package/.cursor/skills/bootstrap/SKILL.md +12 -0
  44. package/.cursor/skills/brainstorm/SKILL.md +13 -0
  45. package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
  46. package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
  47. package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
  48. package/.cursor/skills/daily/SKILL.md +12 -0
  49. package/.cursor/skills/debug/SKILL.md +13 -0
  50. package/.cursor/skills/discovery/SKILL.md +13 -0
  51. package/.cursor/skills/dispatcher/SKILL.md +13 -0
  52. package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
  53. package/.cursor/skills/domain-model/SKILL.md +13 -0
  54. package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
  55. package/.cursor/skills/eli5/SKILL.md +13 -0
  56. package/.cursor/skills/eval/SKILL.md +12 -0
  57. package/.cursor/skills/evolve/SKILL.md +13 -0
  58. package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
  59. package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
  60. package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
  61. package/.cursor/skills/grill/SKILL.md +13 -0
  62. package/.cursor/skills/hook-development/SKILL.md +13 -0
  63. package/.cursor/skills/journey-audit/SKILL.md +13 -0
  64. package/.cursor/skills/mcp-builder/SKILL.md +13 -0
  65. package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
  66. package/.cursor/skills/mode-selector/SKILL.md +13 -0
  67. package/.cursor/skills/npm-publish/SKILL.md +12 -0
  68. package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
  69. package/.cursor/skills/persona-panel/SKILL.md +12 -0
  70. package/.cursor/skills/plan/SKILL.md +13 -0
  71. package/.cursor/skills/playwright-driver/SKILL.md +13 -0
  72. package/.cursor/skills/quality-gates/SKILL.md +13 -0
  73. package/.cursor/skills/reconcile/SKILL.md +12 -0
  74. package/.cursor/skills/repo-audit/SKILL.md +13 -0
  75. package/.cursor/skills/session-end/SKILL.md +13 -0
  76. package/.cursor/skills/session-plan/SKILL.md +13 -0
  77. package/.cursor/skills/session-start/SKILL.md +13 -0
  78. package/.cursor/skills/skill-creator/SKILL.md +13 -0
  79. package/.cursor/skills/spinout/SKILL.md +12 -0
  80. package/.cursor/skills/sunset-review/SKILL.md +13 -0
  81. package/.cursor/skills/test-runner/SKILL.md +13 -0
  82. package/.cursor/skills/tmux-layout/SKILL.md +13 -0
  83. package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
  84. package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
  85. package/.cursor/skills/vault-mirror/SKILL.md +13 -0
  86. package/.cursor/skills/vault-sync/SKILL.md +13 -0
  87. package/.cursor/skills/wave-executor/SKILL.md +13 -0
  88. package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
  89. package/.mcp.json +4 -1
  90. package/CHANGELOG.md +269 -0
  91. package/README.md +18 -15
  92. package/agents/AGENTS.md +23 -4
  93. package/agents/code-implementer.md +2 -1
  94. package/agents/db-specialist.md +2 -1
  95. package/agents/docs-writer.md +3 -1
  96. package/agents/eval-judge.md +1 -1
  97. package/agents/memory-proposal-collector.md +6 -4
  98. package/agents/session-reviewer.md +7 -1
  99. package/agents/test-writer.md +2 -1
  100. package/agents/ui-developer.md +2 -1
  101. package/commands/bootstrap.md +2 -2
  102. package/commands/close.md +3 -1
  103. package/commands/eli5.md +33 -0
  104. package/commands/go.md +1 -1
  105. package/commands/journey-audit.md +43 -0
  106. package/commands/release.md +5 -3
  107. package/commands/test.md +2 -2
  108. package/docs/USER-GUIDE.md +2 -2
  109. package/docs/ci-setup.md +14 -0
  110. package/docs/codex-setup.md +64 -0
  111. package/docs/components.md +7 -6
  112. package/docs/cursor-setup.md +26 -47
  113. package/docs/events-schema.md +76 -4
  114. package/docs/github-mirror-protection.md +197 -0
  115. package/docs/pi-setup.md +2 -0
  116. package/docs/rule-authoring.md +3 -1
  117. package/docs/scope-collision-guard.md +52 -5
  118. package/docs/session-config-reference.md +57 -12
  119. package/docs/session-config-template.md +4 -3
  120. package/docs/telemetry.md +22 -0
  121. package/hooks/_lib/lock-bootstrap.mjs +26 -16
  122. package/hooks/_lib/vcs-create-matcher.mjs +397 -38
  123. package/hooks/enforce-scope.mjs +64 -0
  124. package/hooks/hooks-codex.json +1 -1
  125. package/hooks/hooks-cursor.json +201 -20
  126. package/hooks/hooks-pi.json +1 -1
  127. package/hooks/hooks.json +12 -2
  128. package/hooks/on-session-end.mjs +237 -104
  129. package/hooks/on-session-start.mjs +404 -110
  130. package/hooks/on-stop.mjs +48 -9
  131. package/hooks/post-subagent-discovery-validator.mjs +34 -3
  132. package/hooks/post-tool-batch-wave-signal.mjs +11 -2
  133. package/hooks/pre-auq-clarity.mjs +787 -0
  134. package/hooks/pre-bash-issue-budget.mjs +133 -21
  135. package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
  136. package/hooks/pre-bash-staging-fence.mjs +4 -0
  137. package/hooks/pre-task-scope-disjoint.mjs +368 -35
  138. package/hooks/skill-invocation-telemetry.mjs +21 -10
  139. package/monitors/monitors.json +6 -0
  140. package/package.json +3 -1
  141. package/pi/prompts/eli5.md +12 -0
  142. package/pi/prompts/journey-audit.md +12 -0
  143. package/rules/_index.md +9 -1
  144. package/rules/always-on/ask-via-tool.md +62 -0
  145. package/rules/always-on/bash-harness-pitfalls.md +168 -0
  146. package/rules/always-on/build-value.md +47 -0
  147. package/rules/always-on/cross-session-messaging.md +59 -0
  148. package/rules/always-on/loop-and-monitor.md +221 -0
  149. package/rules/always-on/parallel-sessions.md +142 -12
  150. package/rules/always-on/receiving-review.md +108 -0
  151. package/rules/always-on/test-value.md +40 -0
  152. package/rules/always-on/verification-before-completion.md +77 -0
  153. package/scripts/archive-closed-prds.mjs +258 -18
  154. package/scripts/auq-audit.mjs +825 -0
  155. package/scripts/autopilot.mjs +12 -8
  156. package/scripts/backfill-evidence-digest.mjs +376 -0
  157. package/scripts/cursor-install.mjs +89 -48
  158. package/scripts/export-hw-learnings.mjs +143 -2
  159. package/scripts/express-path.mjs +299 -0
  160. package/scripts/generate-cursor-adapter.mjs +253 -0
  161. package/scripts/github-protection-audit.mjs +358 -0
  162. package/scripts/lib/auq/clarity.mjs +1314 -0
  163. package/scripts/lib/auq/parse.mjs +1006 -0
  164. package/scripts/lib/auq/schema.mjs +1457 -0
  165. package/scripts/lib/autopilot/worktree-pipeline.mjs +240 -16
  166. package/scripts/lib/build-live-signals.mjs +24 -5
  167. package/scripts/lib/ci-status-banner.mjs +221 -68
  168. package/scripts/lib/command-blocker.mjs +70 -0
  169. package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
  170. package/scripts/lib/config/reconcile.mjs +79 -4
  171. package/scripts/lib/config/section-extractor.mjs +235 -36
  172. package/scripts/lib/config/vault-integration.mjs +12 -1
  173. package/scripts/lib/config-schema.mjs +9 -1
  174. package/scripts/lib/config.mjs +57 -6
  175. package/scripts/lib/convergence-monitor.mjs +13 -2
  176. package/scripts/lib/cursor-hook-bridge.mjs +443 -0
  177. package/scripts/lib/dispatcher/cli.mjs +2 -2
  178. package/scripts/lib/dispatcher/rank.mjs +4 -7
  179. package/scripts/lib/express-path.mjs +327 -0
  180. package/scripts/lib/file-lock.mjs +22 -4
  181. package/scripts/lib/gates/gate-full.mjs +84 -11
  182. package/scripts/lib/gates/gate-helpers.mjs +93 -21
  183. package/scripts/lib/git-config-drift.mjs +134 -5
  184. package/scripts/lib/host-identity.mjs +247 -2
  185. package/scripts/lib/instruction-budget-guard.mjs +31 -1
  186. package/scripts/lib/io.mjs +239 -0
  187. package/scripts/lib/issue-budget.mjs +284 -31
  188. package/scripts/lib/learnings/io.mjs +55 -10
  189. package/scripts/lib/learnings/schema.mjs +95 -28
  190. package/scripts/lib/lock-reaper.mjs +7 -1
  191. package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
  192. package/scripts/lib/locks/state-md-lock.mjs +8 -1
  193. package/scripts/lib/memory-banner.mjs +5 -2
  194. package/scripts/lib/memory-paths.mjs +15 -6
  195. package/scripts/lib/mode-selector/scoring.mjs +53 -6
  196. package/scripts/lib/owner-interview.mjs +78 -32
  197. package/scripts/lib/peer-discovery.mjs +73 -22
  198. package/scripts/lib/platform.mjs +72 -9
  199. package/scripts/lib/plugin-root.mjs +143 -19
  200. package/scripts/lib/project-hygiene.mjs +104 -4
  201. package/scripts/lib/quality-gate.mjs +271 -13
  202. package/scripts/lib/reconcile/emitter.mjs +87 -19
  203. package/scripts/lib/reconcile/engine.mjs +281 -13
  204. package/scripts/lib/reconcile/idempotency.mjs +102 -1
  205. package/scripts/lib/reconcile/renderer.mjs +165 -7
  206. package/scripts/lib/reconcile/sanitize.mjs +40 -17
  207. package/scripts/lib/reconcile/writer.mjs +415 -84
  208. package/scripts/lib/resource-probe/evaluate.mjs +330 -149
  209. package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
  210. package/scripts/lib/resource-probe.mjs +18 -2
  211. package/scripts/lib/rule-loader.mjs +37 -2
  212. package/scripts/lib/rules-sync.mjs +51 -8
  213. package/scripts/lib/scope-gate.mjs +90 -0
  214. package/scripts/lib/session-close-backfill.mjs +369 -28
  215. package/scripts/lib/session-discovery.mjs +13 -3
  216. package/scripts/lib/session-end/phase-skip.mjs +37 -4
  217. package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
  218. package/scripts/lib/session-id.mjs +30 -14
  219. package/scripts/lib/session-identity/own-session.mjs +159 -0
  220. package/scripts/lib/session-lock.mjs +85 -30
  221. package/scripts/lib/session-schema/normalizer.mjs +70 -3
  222. package/scripts/lib/session-schema/validator.mjs +40 -0
  223. package/scripts/lib/session-start-probes.mjs +608 -0
  224. package/scripts/lib/session-transition.mjs +277 -0
  225. package/scripts/lib/sessions-staleness-banner.mjs +124 -57
  226. package/scripts/lib/spiral-carryover.mjs +111 -9
  227. package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
  228. package/scripts/lib/state-md/mission-status.mjs +467 -72
  229. package/scripts/lib/state-md/yaml-parser.mjs +145 -16
  230. package/scripts/lib/state-md.mjs +12 -2
  231. package/scripts/lib/telemetry/sync.mjs +46 -8
  232. package/scripts/lib/validate/check-agents.mjs +66 -0
  233. package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
  234. package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
  235. package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
  236. package/scripts/lib/validate/check-doc-cli-commands.mjs +16 -32
  237. package/scripts/lib/validate/check-hooks-symmetry.mjs +59 -63
  238. package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
  239. package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
  240. package/scripts/lib/validate/check-rules.mjs +153 -9
  241. package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
  242. package/scripts/lib/validate/check-unwired-features.mjs +340 -32
  243. package/scripts/lib/validate/repo-files.mjs +275 -0
  244. package/scripts/lib/validate-vendored-rules.mjs +229 -7
  245. package/scripts/lib/vault-backfill/glab.mjs +91 -58
  246. package/scripts/lib/vault-backfill/manifest.mjs +28 -8
  247. package/scripts/lib/vault-mirror/process.mjs +99 -43
  248. package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
  249. package/scripts/lib/vault-staleness-banner.mjs +76 -6
  250. package/scripts/lib/vault-status/board-writer.mjs +211 -10
  251. package/scripts/lib/vault-status/narrative-mirror.mjs +188 -8
  252. package/scripts/lib/vcs-repo-spec.mjs +182 -13
  253. package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
  254. package/scripts/lib/wave-resource-gate.mjs +67 -73
  255. package/scripts/lib/wave-transcript-tail.mjs +869 -0
  256. package/scripts/materialize-wave-scope.mjs +478 -0
  257. package/scripts/mcp-server.sh +11 -2
  258. package/scripts/parse-config.mjs +65 -0
  259. package/scripts/release.mjs +443 -122
  260. package/scripts/run-quality-gate.mjs +14 -0
  261. package/scripts/token-audit.sh +9 -2
  262. package/scripts/validate-plugin.mjs +6 -0
  263. package/scripts/validate-wave-scope.mjs +73 -1
  264. package/scripts/vault-backfill.mjs +32 -5
  265. package/scripts/vault-mirror.mjs +203 -34
  266. package/skills/_shared/monitor-patterns.md +31 -5
  267. package/skills/_shared/parallel-aware-auq.md +31 -25
  268. package/skills/_shared/parallel-aware-preamble.md +35 -4
  269. package/skills/_shared/platform-tools.md +11 -5
  270. package/skills/_shared/state-ownership.md +61 -8
  271. package/skills/autopilot/SKILL.md +5 -1
  272. package/skills/bootstrap/SKILL.md +5 -4
  273. package/skills/bootstrap/_shared-template.md +18 -10
  274. package/skills/bootstrap/deep-template.md +10 -6
  275. package/skills/bootstrap/fast-template.md +15 -8
  276. package/skills/bootstrap/standard-template.md +10 -6
  277. package/skills/brainstorm/SKILL.md +18 -18
  278. package/skills/brainstorm/soul.md +12 -0
  279. package/skills/claude-md-drift-check/checker.mjs +39 -11
  280. package/skills/discovery/SKILL.md +28 -24
  281. package/skills/dispatcher/SKILL.md +1 -1
  282. package/skills/eli5/SKILL.md +43 -0
  283. package/skills/evolve/SKILL.md +8 -9
  284. package/skills/gitlab-ops/SKILL.md +30 -26
  285. package/skills/grill/SKILL.md +6 -6
  286. package/skills/grill/soul.md +16 -0
  287. package/skills/journey-audit/SKILL.md +269 -0
  288. package/skills/memory-cleanup/SKILL.md +2 -2
  289. package/skills/npm-publish/SKILL.md +4 -4
  290. package/skills/peekaboo-driver/SKILL.md +18 -6
  291. package/skills/persona-panel/SKILL.md +1 -1
  292. package/skills/plan/SKILL.md +18 -16
  293. package/skills/plan/mode-feature.md +1 -1
  294. package/skills/plan/mode-new.md +35 -23
  295. package/skills/plan/soul.md +12 -0
  296. package/skills/reconcile/SKILL.md +44 -4
  297. package/skills/session-end/SKILL.md +70 -24
  298. package/skills/session-end/metrics-collection.md +7 -4
  299. package/skills/session-end/phase-3-6-tail.md +48 -5
  300. package/skills/session-end/phase-3-7a-recommendations.md +16 -2
  301. package/skills/session-plan/SKILL.md +6 -1
  302. package/skills/session-plan/wave-template.md +1 -0
  303. package/skills/session-start/SKILL.md +96 -48
  304. package/skills/session-start/phase-2-5-docs-planning.md +8 -8
  305. package/skills/session-start/phase-4-5-resource-health.md +82 -19
  306. package/skills/session-start/phase-7-5-mode-selector.md +15 -3
  307. package/skills/session-start/phase-8-5-express-path.md +77 -12
  308. package/skills/session-start/soul.md +110 -0
  309. package/skills/test-runner/SKILL.md +2 -2
  310. package/skills/using-orchestrator/SKILL.md +1 -1
  311. package/skills/vault-sync/validator.mjs +31 -0
  312. package/skills/wave-executor/SKILL.md +4 -2
  313. package/skills/wave-executor/circuit-breaker.md +34 -9
  314. package/skills/wave-executor/wave-loop.md +129 -24
  315. package/skills/write-executable-plan/SKILL.md +6 -6
  316. package/templates/_shared/journey-manifest.md +110 -0
  317. package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
  318. package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
  319. package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
  320. package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
  321. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
  322. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
  323. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
  324. package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
  325. package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
  326. package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
  327. package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
  328. package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
  329. package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
  330. package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
  331. package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
  332. package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
  333. package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
  334. package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
  335. package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
  336. package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
  337. package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
  338. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
  339. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
  340. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
  341. package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
  342. package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
  343. package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
  344. package/skills/vault-sync/tests/schema-drift.test.mjs +0 -133
  345. package/templates/_shared/rules/parallel-sessions.md +0 -77
@@ -18,6 +18,7 @@
18
18
  *
19
19
  * Exports:
20
20
  * GENERATOR_MARKER — frontmatter sentinel identifying generator-owned files
21
+ * NARRATIVE_EVENT — canonical telemetry event name for a mirror attempt
21
22
  * extractNarrative — pure: STATE.md contents → { waveHistory, deviations, whatNotToRetry, missionStatus }
22
23
  * renderNarrative — pure: narrative + repo + now → full markdown (frontmatter + body)
23
24
  * writeNarrative — idempotent write with skip-handwritten / skip-noop / dry-run + _overview refusal
@@ -30,6 +31,7 @@ import { readFile } from 'node:fs/promises';
30
31
  import path from 'node:path';
31
32
  import os from 'node:os';
32
33
 
34
+ import { emitEvent, sessionAttribution } from '../events.mjs';
33
35
  import { parseStateMd, parseMissionStatus } from '../state-md.mjs';
34
36
  import {
35
37
  parseFrontmatter,
@@ -557,6 +559,128 @@ function resolveLooseSlug(vaultDir, candidateSlug, fsSeam = {}) {
557
559
  return matches.length === 1 ? matches[0] : candidateSlug;
558
560
  }
559
561
 
562
+ // ── Telemetry ────────────────────────────────────────────────────────────────────
563
+
564
+ /**
565
+ * Canonical event name for a narrative-mirror attempt (issue #1129).
566
+ *
567
+ * ONE event per {@link mirrorNarrative} call — the REJECTION and NO-OP paths
568
+ * included, because those are the point. Before this existed, a vault-disabled
569
+ * config, a missing STATE.md, a hand-authored target file and a healthy write
570
+ * were all indistinguishable from the ledger's side: measured 2026-08-23 over
571
+ * 28 387 records in `.orchestrator/metrics/events.jsonl`, ZERO board/mirror
572
+ * events, because this module did not import {@link emitEvent} at all. Its only
573
+ * caller is shell prose in `skills/session-end/session-metrics-write.md`, whose
574
+ * `mode: warn` degradation prints a WARNING and closes the session anyway — so
575
+ * an outage of this writer left no durable trace whatsoever.
576
+ *
577
+ * Name deliberately NOT minted here: it is the one the wave assigned, so the
578
+ * fact "a narrative mirror ran" has exactly one address in the ledger.
579
+ */
580
+ export const NARRATIVE_EVENT = 'orchestrator.vault.narrative_mirrored';
581
+
582
+ /**
583
+ * Emit the narrative-mirror telemetry record. Best-effort: never throws, never
584
+ * alters the mirror result.
585
+ *
586
+ * ABSENT IS NOT ZERO (`docs/events-schema.md`): every optional field is spread
587
+ * conditionally, so an UNMEASURED field is MISSING from the record rather than
588
+ * written as `0`. `chars: 0` would then honestly mean "the narrative rendered to
589
+ * an empty document"; an absent `chars` means "no render was reached on this
590
+ * path" — reading the missing key as `0` conflates the two in both directions.
591
+ *
592
+ * NAMED CEILING (build-value BV-004): when `repoRoot` is absent or empty this
593
+ * emits NOTHING. `emitEvent` without `opts.repoRoot` falls back to
594
+ * `SO_PROJECT_DIR` (#941), so the only destination left for a rootless call is
595
+ * whichever repo the process happens to sit in — i.e. a caller that named no
596
+ * repo would silently append to a FOREIGN ledger, and the two call shapes that
597
+ * reach this branch today are the `repoRoot: ''` / omitted-`repoRoot` unit
598
+ * tests, which would then write synthetic records into this repo's real fleet
599
+ * telemetry on every suite run. REVISIT TRIGGER: if a production caller ever
600
+ * legitimately invokes `mirrorNarrative` without a `repoRoot`, that call needs a
601
+ * destination decision of its own — not this silent skip.
602
+ *
603
+ * @param {object} opts
604
+ * @param {string} [opts.repoRoot] — pins the ledger to THIS repo's
605
+ * `.orchestrator/metrics/events.jsonl` (#941), and supplies session attribution.
606
+ * @param {string} opts.action — the `action` the mirror returned, or `'error'`
607
+ * when the writer threw. ALWAYS present.
608
+ * @param {string} [opts.path] — resolved narrative path, when one was resolved.
609
+ * @param {number} [opts.chars] — length of the rendered narrative document this
610
+ * call produced. Present whenever the render was reached (so also on
611
+ * `skipped-noop` / `dry-run`, where the document was built but not written —
612
+ * `action` is what says whether it landed); absent on the earlier no-op paths,
613
+ * which return before anything is rendered.
614
+ * @param {string} [opts.errorCode] — `err.code` on the throw path (a bounded
615
+ * token such as `EACCES`). The error MESSAGE is deliberately NOT recorded: it
616
+ * can quote a path or STATE.md content, and this module's whole reason for
617
+ * masking (#1025) is that such prose carries secrets.
618
+ * @returns {Promise<void>}
619
+ */
620
+ /**
621
+ * Reduce an absolute vault path to its LAST TWO segments for telemetry.
622
+ *
623
+ * The full path is the module's public return contract and stays untouched.
624
+ * What must not travel is the path in the EMITTED payload: on a real host it
625
+ * reads `/Users/<name>/Projects/<vault>/01-projects/<private-slug>/…`, i.e. an
626
+ * OS username plus a private project slug. Those are exactly the two shapes
627
+ * `scripts/lib/validate/check-owner-leakage.mjs` blocks as CP1 and CP6 — and
628
+ * that scanner structurally cannot see this one, because it walks `git ls-files`
629
+ * and `.orchestrator/metrics/*.jsonl` is gitignored (`.gitignore:40`).
630
+ * The record is invisible to the pre-commit guard and visible to the optional
631
+ * Clank webhook (`scripts/lib/events.mjs`, `CLANK_EVENT_URL`), which posts the
632
+ * payload verbatim with no redaction.
633
+ *
634
+ * The BASENAME is the deliberate ceiling — one segment, not two. Two segments
635
+ * would keep the parent directory, and under `01-projects/` that directory IS
636
+ * the private project slug, i.e. exactly the CP6 shape this is meant to drop.
637
+ * The diagnostic value lives in the filename alone: it says WHICH writer ran
638
+ * (`_session-narrative.md` vs `_active-sessions.md`), which is the question the
639
+ * event exists to answer. Which project it was is already answerable from the
640
+ * record's own `session_id` / repo-scoped ledger location.
641
+ * Revisit trigger: a consumer that needs more than the filename — then it
642
+ * belongs in the RETURN value, which already carries the absolute path, never
643
+ * in the event.
644
+ *
645
+ * @param {unknown} outputPath
646
+ * @returns {string|undefined} `undefined` when there is nothing measured to report.
647
+ */
648
+ function telemetrySafePath(outputPath) {
649
+ if (typeof outputPath !== 'string' || outputPath.length === 0) return undefined;
650
+ const base = path.basename(outputPath);
651
+ return base.length > 0 ? base : undefined;
652
+ }
653
+
654
+ async function emitNarrativeEvent({ repoRoot, action, path: outputPath, chars, errorCode }) {
655
+ if (typeof repoRoot !== 'string' || repoRoot.length === 0) return;
656
+ try {
657
+ await emitEvent(
658
+ NARRATIVE_EVENT,
659
+ {
660
+ action,
661
+ ...(telemetrySafePath(outputPath) !== undefined ? { path_tail: telemetrySafePath(outputPath) } : {}),
662
+ // `typeof … === 'number'` rather than `!= null`: the repo's eslint
663
+ // `eqeqeq: always` forbids the loose form, and this shape additionally
664
+ // refuses a non-numeric `chars` outright. A measured `0` still lands.
665
+ ...(typeof chars === 'number' ? { chars } : {}),
666
+ // `typeof === 'string'`, not truthiness: an `err.code` of '' is a measured
667
+ // empty code, and the sibling `chars` field four lines up already states
668
+ // why this file rejects the loose form.
669
+ ...(typeof errorCode === 'string' && errorCode.length > 0 ? { error_code: errorCode } : {}),
670
+ // session_id / semantic_session_id — omitted entirely when no session
671
+ // lock is readable (CI, ad-hoc runs). See sessionAttribution's contract:
672
+ // a fabricated id would read as a real session.
673
+ ...sessionAttribution(repoRoot),
674
+ },
675
+ { repoRoot },
676
+ );
677
+ } catch {
678
+ /* Best-effort telemetry. emitEvent does real file I/O (mkdir + append), so a
679
+ read-only or occupied ledger path WILL throw — and a broken ledger must
680
+ never fail a narrative write. The mirror result is authoritative. */
681
+ }
682
+ }
683
+
560
684
  // ── Convenience orchestration ────────────────────────────────────────────────────
561
685
 
562
686
  /**
@@ -594,10 +718,50 @@ function resolveLooseSlug(vaultDir, candidateSlug, fsSeam = {}) {
594
718
  * @returns {Promise<{ action: string, path?: string }>}
595
719
  */
596
720
  export async function mirrorNarrative(opts) {
721
+ let outcome;
722
+ try {
723
+ outcome = await runNarrativeMirror(opts);
724
+ } catch (err) {
725
+ // The harshest silent-failure case: `session-metrics-write.md` catches this
726
+ // throw, prints a WARNING under `mode: warn` and closes the session anyway.
727
+ // Record it, then re-throw — telemetry observes, it never swallows.
728
+ await emitNarrativeEvent({
729
+ repoRoot: opts?.repoRoot,
730
+ action: 'error',
731
+ errorCode: typeof err?.code === 'string' ? err.code : undefined,
732
+ });
733
+ throw err;
734
+ }
735
+
736
+ await emitNarrativeEvent({
737
+ repoRoot: opts?.repoRoot,
738
+ action: outcome.result.action,
739
+ path: outcome.result.path,
740
+ chars: outcome.chars,
741
+ });
742
+
743
+ return outcome.result;
744
+ }
745
+
746
+ /**
747
+ * The mirror itself — every early return of {@link mirrorNarrative} lives here.
748
+ *
749
+ * Split out so that exactly ONE emit site covers EVERY outcome: a future early
750
+ * return added inside this function is telemetered by construction, whereas
751
+ * hand-placing an emit beside each of the seven `return`s makes "forgot the new
752
+ * one" the default failure. The `chars` companion travels beside the result
753
+ * rather than inside it because the returned object is a PUBLIC shape that
754
+ * callers (and tests) compare with `toEqual` — adding a key there would be an
755
+ * observable contract change for a purely internal measurement.
756
+ *
757
+ * @param {Parameters<typeof mirrorNarrative>[0]} opts
758
+ * @returns {Promise<{ result: { action: string, path?: string }, chars?: number }>}
759
+ */
760
+ async function runNarrativeMirror(opts) {
597
761
  const { repoRoot, repo, now = new Date(), dryRun = false, fs: injectedFs, hostPaths } = opts;
598
762
 
599
763
  if (typeof repoRoot !== 'string' || repoRoot.length === 0) {
600
- return { action: 'skipped-vault-disabled' };
764
+ return { result: { action: 'skipped-vault-disabled' } };
601
765
  }
602
766
 
603
767
  // Read Session Config (CLAUDE.md / AGENTS.md) and resolve vault settings.
@@ -606,12 +770,12 @@ export async function mirrorNarrative(opts) {
606
770
  const configText = await readConfigFile(repoRoot);
607
771
  config = parseSessionConfig(configText, { hostPaths });
608
772
  } catch {
609
- return { action: 'skipped-vault-disabled' };
773
+ return { result: { action: 'skipped-vault-disabled' } };
610
774
  }
611
775
 
612
776
  const vaultIntegration = config?.['vault-integration'];
613
777
  if (!vaultIntegration || vaultIntegration.enabled !== true) {
614
- return { action: 'skipped-vault-disabled' };
778
+ return { result: { action: 'skipped-vault-disabled' } };
615
779
  }
616
780
 
617
781
  // Defense-in-depth: when the caller omits (or passes an empty) `repo`, derive
@@ -628,7 +792,7 @@ export async function mirrorNarrative(opts) {
628
792
 
629
793
  const rawVaultDir = vaultIntegration['vault-dir'];
630
794
  if (!rawVaultDir || typeof rawVaultDir !== 'string') {
631
- return { action: 'skipped-vault-disabled' };
795
+ return { result: { action: 'skipped-vault-disabled' } };
632
796
  }
633
797
 
634
798
  const vaultDir = path.resolve(expandHome(rawVaultDir));
@@ -640,9 +804,19 @@ export async function mirrorNarrative(opts) {
640
804
  const outputPath = resolveNarrativePath(vaultDir, repoSlug);
641
805
 
642
806
  // Defense-in-depth: ensure the resolved file stays inside the vault root.
643
- const inside = validatePathInsideProject(path.relative(vaultDir, outputPath), vaultDir);
807
+ //
808
+ // `canonicalizeRoot: true` (#1033) is load-bearing, not hygiene. The guard's
809
+ // realpath phase is skipped while the target is ABSENT (ENOENT) and fires once
810
+ // it EXISTS — so on a vault root reached through a symlink, run 1 writes and
811
+ // run 2 resolves the file to the canonical path, reads it as outside the
812
+ // LEXICAL root, and returns `skipped-invalid-path` forever after. Canonicalizing
813
+ // the root makes both sides of the comparison the same kind of path. Same call
814
+ // shape as scripts/lib/reconcile/writer.mjs and scripts/lib/memory-proposals/store.mjs.
815
+ const inside = validatePathInsideProject(path.relative(vaultDir, outputPath), vaultDir, {
816
+ canonicalizeRoot: true,
817
+ });
644
818
  if (!inside.ok) {
645
- return { action: 'skipped-invalid-path', path: outputPath };
819
+ return { result: { action: 'skipped-invalid-path', path: outputPath } };
646
820
  }
647
821
 
648
822
  // Read STATE.md (best-effort; absent STATE.md → nothing to mirror).
@@ -651,7 +825,7 @@ export async function mirrorNarrative(opts) {
651
825
  try {
652
826
  stateContents = await readFile(stateMdPath, 'utf8');
653
827
  } catch {
654
- return { action: 'skipped-no-statemd', path: outputPath };
828
+ return { result: { action: 'skipped-no-statemd', path: outputPath } };
655
829
  }
656
830
 
657
831
  // #1025: the ONE masking site for the narrative mirror — after the frontmatter
@@ -660,5 +834,11 @@ export async function mirrorNarrative(opts) {
660
834
  const narrative = maskNarrative(extractNarrative(stateContents));
661
835
  const content = renderNarrative({ repo: repoName, narrative, now });
662
836
 
663
- return writeNarrative({ outputPath, content, dryRun, fs: injectedFs });
837
+ // `chars` measures the document THIS call rendered — so it is present on
838
+ // `skipped-noop` and `dry-run` too, where the render happened but nothing
839
+ // landed. `action` is the field that says whether it landed.
840
+ return {
841
+ result: writeNarrative({ outputPath, content, dryRun, fs: injectedFs }),
842
+ chars: content.length,
843
+ };
664
844
  }
@@ -227,17 +227,56 @@ const WRONG_FAMILY_HOST = {
227
227
  github: 'gitlab.com',
228
228
  };
229
229
 
230
+ /** URI schemes that can name a supported Git remote. */
231
+ const REMOTE_URI_PROTOCOLS = new Set(['http:', 'https:', 'ssh:']);
232
+
230
233
  /**
231
- * Extract the bare hostname from a git remote URL, handling both the HTTPS
232
- * (`https://host/owner/repo.git`) and SSH (`git@host:owner/repo.git`) forms.
233
- * Returns `null` for an unrecognized shape (never throws).
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
+
251
+ /**
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).
234
257
  *
235
258
  * @param {string} url
236
259
  * @returns {string|null}
237
260
  */
238
261
  function extractHost(url) {
239
- const httpsMatch = /^https?:\/\/([^/]+)/i.exec(url);
240
- 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;
241
280
  const sshMatch = /^[^@\s]+@([^:\s]+):/i.exec(url);
242
281
  if (sshMatch) return sshMatch[1].toLowerCase();
243
282
  return null;
@@ -367,12 +406,12 @@ export function resolveRepoSpec({ repoRoot, vcs = 'gitlab', gitRun = defaultGitR
367
406
  }
368
407
 
369
408
  /**
370
- * Resolve the bare hostname of the matching remote, for use with
371
- * `glab api --hostname`/`gh api --hostname` — the `api` subcommand of both
372
- * CLIs does NOT accept `-R`/`--repo` (it has no repo concept), only a
373
- * `--hostname` flag to pin which instance the request targets. This is the
374
- * host-pinning counterpart to `resolveRepoSpec` for those api-only call
375
- * 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.
376
415
  *
377
416
  * Applies the identical remote-preference-order + cross-family-guard
378
417
  * resolution as `resolveRepoSpec`, just returning the host instead of the
@@ -393,6 +432,136 @@ export function resolveRepoHost({ repoRoot, vcs, gitRun } = {}) {
393
432
  return isUnsafeForArgv(host) ? undefined : host;
394
433
  }
395
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
+
396
565
  /**
397
566
  * @deprecated Back-compat alias for `archive-closed-prds.mjs`'s original
398
567
  * `defaultGlabRepo(repoRoot, gitRunFn)` positional signature (gitlab-only).
@@ -636,7 +805,7 @@ export function resolvePreferredRemote({ repoRoot, vcs, gitRun = defaultGitRun }
636
805
  const vcsPinned = vcs === 'github' || vcs === 'gitlab' ? vcs : null;
637
806
  const order = vcsPinned ? REMOTE_PREFERENCE[vcsPinned] : VCS_LESS_PREFERENCE;
638
807
  const wrongFamilyHost = vcsPinned ? WRONG_FAMILY_HOST[vcsPinned] : null;
639
- const isWrongFamily = (url) => wrongFamilyHost !== null && extractHost(url) === wrongFamilyHost;
808
+ const isWrongFamily = (url) => wrongFamilyHost !== null && extractHostname(url) === wrongFamilyHost;
640
809
 
641
810
  /** @param {GitRemote} remote @param {'preference'|'sole-remote'} via */
642
811
  const accept = (remote, via) =>
@@ -670,7 +839,7 @@ export function resolvePreferredRemote({ repoRoot, vcs, gitRun = defaultGitRun }
670
839
  * @returns {{ family: 'gitlab'|'github', via: 'host-match'|'remote-name' }|null}
671
840
  */
672
841
  function classifyRemoteFamily(remote) {
673
- const host = extractHost(remote.url);
842
+ const host = extractHostname(remote.url);
674
843
  if (host !== null) {
675
844
  if (host === 'github.com' || host.startsWith('github.')) return { family: 'github', via: 'host-match' };
676
845
  if (host === 'gitlab.com' || host.startsWith('gitlab.')) return { family: 'gitlab', via: 'host-match' };