session-orchestrator 3.22.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 (268) 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 +8 -0
  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/050-plan.mdc +2 -2
  38. package/.cursor/rules/070-gitlab-ops.mdc +73 -57
  39. package/.cursor/rules/080-ecosystem-health.mdc +7 -7
  40. package/.cursor/skills/architecture/SKILL.md +13 -0
  41. package/.cursor/skills/autopilot/SKILL.md +12 -0
  42. package/.cursor/skills/bootstrap/SKILL.md +12 -0
  43. package/.cursor/skills/brainstorm/SKILL.md +13 -0
  44. package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
  45. package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
  46. package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
  47. package/.cursor/skills/daily/SKILL.md +12 -0
  48. package/.cursor/skills/debug/SKILL.md +13 -0
  49. package/.cursor/skills/discovery/SKILL.md +13 -0
  50. package/.cursor/skills/dispatcher/SKILL.md +13 -0
  51. package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
  52. package/.cursor/skills/domain-model/SKILL.md +13 -0
  53. package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
  54. package/.cursor/skills/eli5/SKILL.md +13 -0
  55. package/.cursor/skills/eval/SKILL.md +12 -0
  56. package/.cursor/skills/evolve/SKILL.md +13 -0
  57. package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
  58. package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
  59. package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
  60. package/.cursor/skills/grill/SKILL.md +13 -0
  61. package/.cursor/skills/hook-development/SKILL.md +13 -0
  62. package/.cursor/skills/journey-audit/SKILL.md +13 -0
  63. package/.cursor/skills/mcp-builder/SKILL.md +13 -0
  64. package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
  65. package/.cursor/skills/mode-selector/SKILL.md +13 -0
  66. package/.cursor/skills/npm-publish/SKILL.md +12 -0
  67. package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
  68. package/.cursor/skills/persona-panel/SKILL.md +12 -0
  69. package/.cursor/skills/plan/SKILL.md +13 -0
  70. package/.cursor/skills/playwright-driver/SKILL.md +13 -0
  71. package/.cursor/skills/quality-gates/SKILL.md +13 -0
  72. package/.cursor/skills/reconcile/SKILL.md +12 -0
  73. package/.cursor/skills/repo-audit/SKILL.md +13 -0
  74. package/.cursor/skills/session-end/SKILL.md +13 -0
  75. package/.cursor/skills/session-plan/SKILL.md +13 -0
  76. package/.cursor/skills/session-start/SKILL.md +13 -0
  77. package/.cursor/skills/skill-creator/SKILL.md +13 -0
  78. package/.cursor/skills/spinout/SKILL.md +12 -0
  79. package/.cursor/skills/sunset-review/SKILL.md +13 -0
  80. package/.cursor/skills/test-runner/SKILL.md +13 -0
  81. package/.cursor/skills/tmux-layout/SKILL.md +13 -0
  82. package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
  83. package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
  84. package/.cursor/skills/vault-mirror/SKILL.md +13 -0
  85. package/.cursor/skills/vault-sync/SKILL.md +13 -0
  86. package/.cursor/skills/wave-executor/SKILL.md +13 -0
  87. package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
  88. package/.mcp.json +4 -1
  89. package/CHANGELOG.md +168 -0
  90. package/README.md +18 -15
  91. package/agents/AGENTS.md +23 -4
  92. package/agents/code-implementer.md +2 -1
  93. package/agents/db-specialist.md +2 -1
  94. package/agents/docs-writer.md +3 -1
  95. package/agents/eval-judge.md +1 -1
  96. package/agents/session-reviewer.md +7 -1
  97. package/agents/test-writer.md +2 -1
  98. package/agents/ui-developer.md +2 -1
  99. package/commands/bootstrap.md +2 -2
  100. package/commands/close.md +3 -1
  101. package/commands/go.md +1 -1
  102. package/commands/journey-audit.md +43 -0
  103. package/docs/USER-GUIDE.md +2 -2
  104. package/docs/ci-setup.md +14 -0
  105. package/docs/codex-setup.md +64 -0
  106. package/docs/components.md +6 -6
  107. package/docs/cursor-setup.md +26 -47
  108. package/docs/events-schema.md +76 -4
  109. package/docs/github-mirror-protection.md +197 -0
  110. package/docs/pi-setup.md +2 -0
  111. package/docs/rule-authoring.md +3 -1
  112. package/docs/scope-collision-guard.md +49 -2
  113. package/docs/session-config-reference.md +26 -4
  114. package/docs/session-config-template.md +4 -3
  115. package/docs/telemetry.md +22 -0
  116. package/hooks/_lib/lock-bootstrap.mjs +8 -4
  117. package/hooks/_lib/vcs-create-matcher.mjs +397 -38
  118. package/hooks/enforce-scope.mjs +64 -0
  119. package/hooks/hooks-codex.json +1 -1
  120. package/hooks/hooks-cursor.json +201 -20
  121. package/hooks/hooks-pi.json +1 -1
  122. package/hooks/hooks.json +2 -2
  123. package/hooks/on-session-end.mjs +211 -10
  124. package/hooks/on-session-start.mjs +214 -11
  125. package/hooks/on-stop.mjs +48 -9
  126. package/hooks/post-subagent-discovery-validator.mjs +34 -3
  127. package/hooks/post-tool-batch-wave-signal.mjs +11 -2
  128. package/hooks/pre-bash-issue-budget.mjs +117 -4
  129. package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
  130. package/hooks/pre-bash-staging-fence.mjs +4 -0
  131. package/hooks/pre-task-scope-disjoint.mjs +368 -35
  132. package/hooks/skill-invocation-telemetry.mjs +21 -10
  133. package/monitors/monitors.json +6 -0
  134. package/package.json +1 -1
  135. package/pi/prompts/journey-audit.md +12 -0
  136. package/rules/_index.md +9 -1
  137. package/rules/always-on/ask-via-tool.md +62 -0
  138. package/rules/always-on/bash-harness-pitfalls.md +168 -0
  139. package/rules/always-on/build-value.md +47 -0
  140. package/rules/always-on/cross-session-messaging.md +59 -0
  141. package/rules/always-on/loop-and-monitor.md +221 -0
  142. package/rules/always-on/parallel-sessions.md +142 -12
  143. package/rules/always-on/receiving-review.md +108 -0
  144. package/rules/always-on/test-value.md +40 -0
  145. package/rules/always-on/verification-before-completion.md +77 -0
  146. package/scripts/archive-closed-prds.mjs +258 -18
  147. package/scripts/autopilot.mjs +5 -0
  148. package/scripts/backfill-evidence-digest.mjs +376 -0
  149. package/scripts/cursor-install.mjs +89 -48
  150. package/scripts/export-hw-learnings.mjs +143 -2
  151. package/scripts/express-path.mjs +299 -0
  152. package/scripts/generate-cursor-adapter.mjs +253 -0
  153. package/scripts/github-protection-audit.mjs +358 -0
  154. package/scripts/lib/autopilot/worktree-pipeline.mjs +240 -16
  155. package/scripts/lib/build-live-signals.mjs +24 -5
  156. package/scripts/lib/ci-status-banner.mjs +158 -11
  157. package/scripts/lib/command-blocker.mjs +70 -0
  158. package/scripts/lib/config/reconcile.mjs +79 -4
  159. package/scripts/lib/config/section-extractor.mjs +235 -36
  160. package/scripts/lib/config-schema.mjs +9 -1
  161. package/scripts/lib/config.mjs +57 -6
  162. package/scripts/lib/convergence-monitor.mjs +13 -2
  163. package/scripts/lib/cursor-hook-bridge.mjs +443 -0
  164. package/scripts/lib/dispatcher/cli.mjs +2 -2
  165. package/scripts/lib/express-path.mjs +327 -0
  166. package/scripts/lib/file-lock.mjs +22 -4
  167. package/scripts/lib/gates/gate-full.mjs +81 -8
  168. package/scripts/lib/gates/gate-helpers.mjs +76 -15
  169. package/scripts/lib/git-config-drift.mjs +134 -5
  170. package/scripts/lib/host-identity.mjs +247 -2
  171. package/scripts/lib/instruction-budget-guard.mjs +31 -1
  172. package/scripts/lib/issue-budget.mjs +229 -30
  173. package/scripts/lib/learnings/io.mjs +55 -10
  174. package/scripts/lib/learnings/schema.mjs +95 -28
  175. package/scripts/lib/lock-reaper.mjs +7 -1
  176. package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
  177. package/scripts/lib/locks/state-md-lock.mjs +8 -1
  178. package/scripts/lib/memory-banner.mjs +5 -2
  179. package/scripts/lib/memory-paths.mjs +15 -6
  180. package/scripts/lib/mode-selector/scoring.mjs +53 -6
  181. package/scripts/lib/platform.mjs +72 -9
  182. package/scripts/lib/plugin-root.mjs +143 -19
  183. package/scripts/lib/project-hygiene.mjs +43 -3
  184. package/scripts/lib/quality-gate.mjs +271 -13
  185. package/scripts/lib/reconcile/emitter.mjs +87 -19
  186. package/scripts/lib/reconcile/engine.mjs +281 -13
  187. package/scripts/lib/reconcile/idempotency.mjs +102 -1
  188. package/scripts/lib/reconcile/renderer.mjs +148 -3
  189. package/scripts/lib/reconcile/sanitize.mjs +40 -17
  190. package/scripts/lib/reconcile/writer.mjs +415 -84
  191. package/scripts/lib/rule-loader.mjs +37 -2
  192. package/scripts/lib/rules-sync.mjs +51 -8
  193. package/scripts/lib/scope-gate.mjs +90 -0
  194. package/scripts/lib/session-close-backfill.mjs +369 -28
  195. package/scripts/lib/session-discovery.mjs +13 -3
  196. package/scripts/lib/session-end/phase-skip.mjs +37 -4
  197. package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
  198. package/scripts/lib/session-id.mjs +30 -14
  199. package/scripts/lib/session-identity/own-session.mjs +159 -0
  200. package/scripts/lib/session-lock.mjs +85 -30
  201. package/scripts/lib/session-schema/normalizer.mjs +70 -3
  202. package/scripts/lib/session-schema/validator.mjs +40 -0
  203. package/scripts/lib/session-start-probes.mjs +608 -0
  204. package/scripts/lib/session-transition.mjs +277 -0
  205. package/scripts/lib/sessions-staleness-banner.mjs +124 -57
  206. package/scripts/lib/spiral-carryover.mjs +90 -9
  207. package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
  208. package/scripts/lib/state-md/mission-status.mjs +350 -52
  209. package/scripts/lib/state-md/yaml-parser.mjs +145 -16
  210. package/scripts/lib/state-md.mjs +12 -2
  211. package/scripts/lib/telemetry/sync.mjs +46 -8
  212. package/scripts/lib/validate/check-agents.mjs +66 -0
  213. package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
  214. package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
  215. package/scripts/lib/validate/check-doc-cli-commands.mjs +16 -32
  216. package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
  217. package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
  218. package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
  219. package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
  220. package/scripts/lib/validate/check-unwired-features.mjs +340 -32
  221. package/scripts/lib/validate/repo-files.mjs +275 -0
  222. package/scripts/lib/validate-vendored-rules.mjs +229 -7
  223. package/scripts/lib/vault-mirror/process.mjs +99 -43
  224. package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
  225. package/scripts/lib/vault-staleness-banner.mjs +76 -6
  226. package/scripts/lib/vault-status/board-writer.mjs +211 -10
  227. package/scripts/lib/vault-status/narrative-mirror.mjs +188 -8
  228. package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
  229. package/scripts/lib/wave-transcript-tail.mjs +869 -0
  230. package/scripts/materialize-wave-scope.mjs +209 -12
  231. package/scripts/mcp-server.sh +11 -2
  232. package/scripts/parse-config.mjs +65 -0
  233. package/scripts/token-audit.sh +9 -2
  234. package/scripts/validate-plugin.mjs +3 -0
  235. package/scripts/validate-wave-scope.mjs +67 -0
  236. package/scripts/vault-mirror.mjs +203 -34
  237. package/skills/_shared/monitor-patterns.md +31 -5
  238. package/skills/_shared/parallel-aware-auq.md +1 -1
  239. package/skills/_shared/parallel-aware-preamble.md +4 -2
  240. package/skills/_shared/platform-tools.md +11 -5
  241. package/skills/_shared/state-ownership.md +29 -2
  242. package/skills/autopilot/SKILL.md +5 -1
  243. package/skills/bootstrap/SKILL.md +3 -3
  244. package/skills/bootstrap/_shared-template.md +18 -10
  245. package/skills/bootstrap/deep-template.md +10 -6
  246. package/skills/bootstrap/fast-template.md +15 -8
  247. package/skills/bootstrap/standard-template.md +10 -6
  248. package/skills/claude-md-drift-check/checker.mjs +39 -11
  249. package/skills/dispatcher/SKILL.md +1 -1
  250. package/skills/journey-audit/SKILL.md +269 -0
  251. package/skills/peekaboo-driver/SKILL.md +15 -3
  252. package/skills/persona-panel/SKILL.md +1 -1
  253. package/skills/reconcile/SKILL.md +41 -1
  254. package/skills/session-end/SKILL.md +17 -4
  255. package/skills/session-end/metrics-collection.md +7 -4
  256. package/skills/session-end/phase-3-6-tail.md +11 -3
  257. package/skills/session-end/phase-3-7a-recommendations.md +16 -2
  258. package/skills/session-plan/SKILL.md +6 -1
  259. package/skills/session-plan/wave-template.md +1 -0
  260. package/skills/session-start/SKILL.md +30 -16
  261. package/skills/session-start/phase-7-5-mode-selector.md +15 -3
  262. package/skills/session-start/phase-8-5-express-path.md +77 -12
  263. package/skills/vault-sync/validator.mjs +31 -0
  264. package/skills/wave-executor/SKILL.md +4 -2
  265. package/skills/wave-executor/circuit-breaker.md +34 -9
  266. package/skills/wave-executor/wave-loop.md +102 -19
  267. package/templates/_shared/journey-manifest.md +110 -0
  268. package/templates/_shared/rules/parallel-sessions.md +0 -77
@@ -36,6 +36,11 @@ import { acquire, release, buildLockOwnerProof } from '../session-lock.mjs';
36
36
  import { emitEvent } from '../events.mjs';
37
37
  import { main as gcMain } from '../../gc-stale-worktrees.mjs';
38
38
  import { SEMANTIC_ID_RE } from '../session-id.mjs';
39
+ import { repoPathHash } from '../session-registry.mjs';
40
+ // Marker location is owned by its READER (session-end Phase 4a) — importing the
41
+ // constant from there keeps writer and reader on one string. The dependency runs
42
+ // heavy→lean (this pipeline → the dependency-free cleanup helper), never back.
43
+ import { PROMOTION_MARKER_RELPATH } from '../session-end/worktree-cleanup.mjs';
39
44
 
40
45
  // ---------------------------------------------------------------------------
41
46
  // Type definitions
@@ -547,6 +552,134 @@ export async function runStoryPipeline(context, opts = {}) {
547
552
  /** Valid git branch character set — mirrors isValidBranch from session-id.mjs. */
548
553
  const ENTER_WORKTREE_BRANCH_RE = /^[a-zA-Z0-9._/-]+$/;
549
554
 
555
+ /**
556
+ * Raised when the promotion branch `so/<sessionId>` is already checked out by
557
+ * another worktree. Refusing is the point: adopting a foreign target worktree
558
+ * would put two sessions on one branch (PSA-002 territory), so the caller gets
559
+ * the conflicting path by name instead of a silent takeover.
560
+ */
561
+ export class WorktreePromotionBranchError extends Error {
562
+ /**
563
+ * @param {string} message
564
+ * @param {{branch: string, checkedOutAt: string}} detail
565
+ */
566
+ constructor(message, { branch, checkedOutAt } = {}) {
567
+ super(message);
568
+ this.name = 'WorktreePromotionBranchError';
569
+ this.branch = branch;
570
+ this.checkedOutAt = checkedOutAt;
571
+ }
572
+ }
573
+
574
+ /**
575
+ * Map of branch name → worktree path for every branch currently checked out by
576
+ * ANY worktree of `repoRoot` (#1067).
577
+ *
578
+ * Deliberately NOT `listWorktrees()` from `../worktree/listing.mjs`: that helper
579
+ * runs `git worktree list --porcelain` in `process.cwd()` (it has no repo
580
+ * parameter), and `enterWorktree` takes `repoRoot` explicitly precisely to avoid
581
+ * CWD drift (#219) — so reusing it would query whichever repo the process
582
+ * happens to sit in. Its `$` seam also has a different shape (`$({cwd})` returns
583
+ * the tag) than `enterWorktree`'s (`exec` IS the tag), so the two cannot share
584
+ * one injected executor. Only the `branch` lines are parsed here; `HEAD`,
585
+ * `detached`, `bare` and `locked` records carry nothing this decision needs.
586
+ *
587
+ * @param {Function} exec zx-like template-tag executor (the DI seam).
588
+ * @param {string} repoRoot Absolute path to the source git repository.
589
+ * @returns {Promise<Map<string, string>>} Branch names (without the
590
+ * `refs/heads/` prefix) → the worktree path that has them checked out.
591
+ * Empty on git failure — the caller then behaves exactly as it did before this
592
+ * check existed, so a broken listing degrades to git's own loud
593
+ * `already used by worktree` error rather than to a silent wrong branch.
594
+ */
595
+ async function listCheckedOutBranches(exec, repoRoot) {
596
+ let stdout;
597
+ try {
598
+ const result = await exec`git -C ${repoRoot} worktree list --porcelain`;
599
+ stdout = String(result?.stdout ?? '');
600
+ } catch {
601
+ return new Map();
602
+ }
603
+
604
+ const branches = new Map();
605
+ let currentPath = null;
606
+ for (const rawLine of stdout.split('\n')) {
607
+ const line = rawLine.trimEnd();
608
+ const wtMatch = /^worktree (.+)$/.exec(line);
609
+ if (wtMatch) {
610
+ currentPath = wtMatch[1];
611
+ continue;
612
+ }
613
+ const branchMatch = /^branch refs\/heads\/(.+)$/.exec(line);
614
+ if (branchMatch) branches.set(branchMatch[1], currentPath ?? '');
615
+ }
616
+ return branches;
617
+ }
618
+
619
+ /**
620
+ * Does `<ref>` resolve in `repoRoot`? Wraps `git rev-parse --verify --quiet`.
621
+ *
622
+ * @param {Function} exec
623
+ * @param {string} repoRoot
624
+ * @param {string} ref
625
+ * @returns {Promise<boolean>} false on any git failure (the ref is absent, or
626
+ * git could not be asked — both lead to the create-new path, whose failure
627
+ * mode is git's own loud error rather than a silent wrong checkout).
628
+ */
629
+ async function refExists(exec, repoRoot, ref) {
630
+ try {
631
+ await exec`git -C ${repoRoot} rev-parse --verify --quiet ${ref}`;
632
+ return true;
633
+ } catch {
634
+ return false;
635
+ }
636
+ }
637
+
638
+ /**
639
+ * Record, inside the freshly created worktree, that it was auto-promoted —
640
+ * and from where. Best-effort: a failure to write the marker degrades Phase 4a
641
+ * detection to the legacy basename key, it never fails the promotion itself.
642
+ *
643
+ * No absolute source path is stored: `source_root_hash` is `repoPathHash()`, the
644
+ * same stable SHA-256 the session registry uses to correlate repos without
645
+ * exposing the operator's filesystem layout.
646
+ *
647
+ * @param {object} params
648
+ * @param {string} params.wtPath Freshly created worktree.
649
+ * @param {string} params.sourceRoot Resolved source checkout.
650
+ * @param {string} params.sessionId Session label the worktree was created for.
651
+ * @param {string} params.branch Branch the worktree actually landed on.
652
+ * @returns {boolean} true when the marker was written.
653
+ */
654
+ function writePromotionMarker({ wtPath, sourceRoot, sessionId, branch }) {
655
+ try {
656
+ const markerPath = path.join(wtPath, PROMOTION_MARKER_RELPATH);
657
+ fs.mkdirSync(path.dirname(markerPath), { recursive: true });
658
+ fs.writeFileSync(
659
+ markerPath,
660
+ JSON.stringify(
661
+ {
662
+ source_root_hash: repoPathHash(sourceRoot),
663
+ source_root_basename: path.basename(sourceRoot),
664
+ source_session_id: sessionId,
665
+ branch,
666
+ promoted_at: new Date().toISOString(),
667
+ },
668
+ null,
669
+ 2,
670
+ ) + '\n',
671
+ 'utf8',
672
+ );
673
+ return true;
674
+ } catch (err) {
675
+ console.warn(
676
+ `enterWorktree: could not write promotion marker in ${wtPath} (${err.message}) — ` +
677
+ 'session-end Phase 4a falls back to basename detection',
678
+ );
679
+ return false;
680
+ }
681
+ }
682
+
550
683
  /**
551
684
  * Create a sibling git worktree for Worktree-Auto-Promotion (#574, Epic #568 P3.1).
552
685
  *
@@ -573,10 +706,25 @@ const ENTER_WORKTREE_BRANCH_RE = /^[a-zA-Z0-9._/-]+$/;
573
706
  * check as `setupWorktree` (CWE-23 / SEC-013 defence-in-depth). Throws
574
707
  * `WorktreeBoundaryError` if the computed path escapes `basePath`.
575
708
  *
576
- * Branch handling: if the branch already exists (verified via
577
- * `git rev-parse --verify <branch>`), use `git worktree add <wtPath> <branch>`
578
- * (reuse). Otherwise use `git worktree add -b <branch> <wtPath>` (create new).
579
- * This differs from `setupWorktree`, which always passes `-b`.
709
+ * Branch handling three cases, decided in this order (#1067):
710
+ * 1. `<branch>` is already checked out by ANY worktree of `repoRoot` (the
711
+ * normal session-start Phase 0.5 case, where `branch` is the CURRENT HEAD):
712
+ * git refuses a second checkout of the same branch
713
+ * (`fatal: '<branch>' is already used by worktree at '<repoRoot>'`). Treat
714
+ * `<branch>` as a START POINT only and create a fresh promotion branch
715
+ * `so/<sessionId>` at it: `git worktree add -b so/<sessionId> <wtPath> <branch>`.
716
+ * The source branch and the source worktree are left untouched.
717
+ * If `so/<sessionId>` ALREADY exists (a worktree directory was removed but
718
+ * its branch survived), it is REUSED — `git worktree add <wtPath>
719
+ * so/<sessionId>`, `reusedBranch: true` in the return — because `-b` would
720
+ * abort with `fatal: a branch named 'so/<sessionId>' already exists`.
721
+ * If it exists AND is checked out by some worktree, this throws
722
+ * `WorktreePromotionBranchError` naming that path: adopting a foreign
723
+ * target worktree is refused, never silently taken over.
724
+ * 2. `<branch>` exists but is not checked out (verified via
725
+ * `git rev-parse --verify <branch>`): `git worktree add <wtPath> <branch>`.
726
+ * 3. `<branch>` does not exist: `git worktree add -b <branch> <wtPath>`.
727
+ * Cases 2/3 differ from `setupWorktree`, which always passes `-b`.
580
728
  *
581
729
  * @param {object} params
582
730
  * @param {string} params.basePath - Parent directory where the new worktree goes (absolute).
@@ -585,9 +733,22 @@ const ENTER_WORKTREE_BRANCH_RE = /^[a-zA-Z0-9._/-]+$/;
585
733
  * @param {string} params.repoRoot - Path to the source git repository (passed explicitly to avoid CWD drift per #219).
586
734
  * @param {object} [opts]
587
735
  * @param {Function} [opts.$] - zx-like template-tag executor (DI seam); falls back to lazy `await import('zx')`.
588
- * @returns {Promise<{ wtPath: string, reused: boolean }>}
736
+ * Every freshly created worktree also gets a `.orchestrator/promoted-from.json`
737
+ * marker (see {@link PROMOTION_MARKER_RELPATH}) so session-end Phase 4a can
738
+ * still recognise it after the #1069 process boundary hands the worktree to a
739
+ * session with a different id. Marker writing is best-effort and never fails
740
+ * the promotion.
741
+ *
742
+ * @returns {Promise<{ wtPath: string, reused: boolean, branch?: string, promotedFrom?: string, reusedBranch?: true }>}
743
+ * `branch` is the branch the new worktree actually landed on — equal to the
744
+ * `branch` param except in case 1 above, where it is `so/<sessionId>` and
745
+ * `promotedFrom` carries the requested source branch. `reusedBranch` is
746
+ * present (and `true`) only when an existing `so/<sessionId>` was checked out
747
+ * rather than created. All three are absent on the `reused: true` path (no
748
+ * branch was chosen — the worktree pre-existed).
589
749
  * @throws {TypeError} when any required param is missing or fails validation.
590
750
  * @throws {WorktreeBoundaryError} when the computed worktree path escapes `basePath`.
751
+ * @throws {WorktreePromotionBranchError} when `so/<sessionId>` is checked out elsewhere.
591
752
  */
592
753
  export async function enterWorktree({ basePath, sessionId, branch, repoRoot } = {}, opts = {}) {
593
754
  // -------------------------------------------------------------------------
@@ -686,28 +847,91 @@ export async function enterWorktree({ basePath, sessionId, branch, repoRoot } =
686
847
  }
687
848
 
688
849
  // -------------------------------------------------------------------------
689
- // Step 5: Detect whether branch already exists, then `git worktree add`.
850
+ // Step 5: Is `branch` already checked out elsewhere? (#1067)
851
+ //
852
+ // Phase 0.5 passes the CURRENT HEAD as `branch`, so in the normal promotion
853
+ // case it is checked out by `repoRoot` itself and git would refuse a second
854
+ // checkout. Then `branch` is only a start point and the worktree lands on a
855
+ // fresh `so/<sessionId>`; the source branch/worktree stay untouched.
690
856
  // -------------------------------------------------------------------------
691
- let branchExists = false;
692
- try {
693
- await exec`git -C ${repoRoot} rev-parse --verify ${branch}`;
694
- branchExists = true;
695
- } catch {
696
- // Branch does not exist fall through to create-new path with `-b`.
857
+ const checkedOutBranches = await listCheckedOutBranches(exec, repoRoot);
858
+ const promoted = checkedOutBranches.has(branch);
859
+ const targetBranch = promoted ? `so/${sessionId}` : branch;
860
+
861
+ // -------------------------------------------------------------------------
862
+ // Step 6: Decide the `git worktree add` argv.
863
+ //
864
+ // On the promotion path the target is `so/<sessionId>`, which can be in any
865
+ // of THREE states — and only the first was handled before:
866
+ // (a) absent → create it at `<branch>`: `add -b so/<id> <wt> <branch>`
867
+ // (b) exists, free → REUSE it: `add <wt> so/<id>`. This is the survivor
868
+ // of a worktree whose directory was removed while its branch stayed
869
+ // behind (`git worktree remove` does not delete the branch); `-b` would
870
+ // abort Phase 0.5 with `fatal: a branch named 'so/<id>' already exists`.
871
+ // (c) exists, checked out elsewhere → REFUSE. Adopting a foreign target
872
+ // worktree is exactly what Phase 0.5 must not do.
873
+ // -------------------------------------------------------------------------
874
+ let reusedBranch = false;
875
+ if (promoted) {
876
+ const checkedOutAt = checkedOutBranches.get(targetBranch);
877
+ if (checkedOutAt !== undefined) {
878
+ throw new WorktreePromotionBranchError(
879
+ `enterWorktree: promotion branch '${targetBranch}' is already checked out at '${checkedOutAt}' — refusing to adopt a foreign worktree`,
880
+ { branch: targetBranch, checkedOutAt },
881
+ );
882
+ }
883
+ reusedBranch = await refExists(exec, repoRoot, `refs/heads/${targetBranch}`);
697
884
  }
698
885
 
699
- if (branchExists) {
886
+ // A branch that a worktree has checked out exists by construction, so the
887
+ // source-branch rev-parse probe is skipped on the promotion path.
888
+ let branchExists = promoted;
889
+ if (!promoted) {
890
+ try {
891
+ await exec`git -C ${repoRoot} rev-parse --verify ${branch}`;
892
+ branchExists = true;
893
+ } catch {
894
+ // Branch does not exist — fall through to create-new path with `-b`.
895
+ }
896
+ }
897
+
898
+ if (promoted && reusedBranch) {
899
+ await exec`git -C ${repoRoot} worktree add ${wtPath} ${targetBranch}`;
900
+ } else if (promoted) {
901
+ await exec`git -C ${repoRoot} worktree add -b ${targetBranch} ${wtPath} ${branch}`;
902
+ } else if (branchExists) {
700
903
  await exec`git -C ${repoRoot} worktree add ${wtPath} ${branch}`;
701
904
  } else {
702
905
  await exec`git -C ${repoRoot} worktree add -b ${branch} ${wtPath}`;
703
906
  }
704
907
 
705
908
  // -------------------------------------------------------------------------
706
- // Step 6: WARN to stderr (PRD §3 P3 Gherkin row-1 + #574 DoD).
909
+ // Step 6b: Record the promotion FACT inside the new worktree (#1069 boundary).
910
+ // Session-end Phase 4a runs in a session whose id is NOT this one, so nothing
911
+ // in the path or the branch identifies the worktree to it — the marker does.
912
+ // -------------------------------------------------------------------------
913
+ writePromotionMarker({
914
+ wtPath,
915
+ sourceRoot: resolvedRepoRoot,
916
+ sessionId,
917
+ branch: targetBranch,
918
+ });
919
+
920
+ // -------------------------------------------------------------------------
921
+ // Step 7: WARN to stderr (PRD §3 P3 Gherkin row-1 + #574 DoD).
707
922
  // -------------------------------------------------------------------------
708
923
  console.warn(
709
- `enterWorktree: created sibling worktree at ${wtPath} (branch=${branch}, sessionId=${sessionId})`,
924
+ promoted
925
+ ? `enterWorktree: created sibling worktree at ${wtPath} (branch=${targetBranch}, promoted from ${branch}, sessionId=${sessionId})`
926
+ : `enterWorktree: created sibling worktree at ${wtPath} (branch=${branch}, sessionId=${sessionId})`,
710
927
  );
711
928
 
712
- return { wtPath, reused: false };
929
+ if (promoted) {
930
+ const result = { wtPath, reused: false, branch: targetBranch, promotedFrom: branch };
931
+ // Present only when it happened, so the common shape stays byte-identical
932
+ // for every existing consumer and strict-equality pin.
933
+ if (reusedBranch) result.reusedBranch = true;
934
+ return result;
935
+ }
936
+ return { wtPath, reused: false, branch };
713
937
  }
@@ -10,7 +10,8 @@
10
10
  * - Synchronous file I/O for STATE.md / sessions.jsonl / bootstrap.lock.
11
11
  * The async wrapper exists only because `scanBacklog` is async.
12
12
  * - Logging is NOT this helper's job — silent graceful-null on every error.
13
- * - Paths are resolved absolutely from process.cwd() if relative.
13
+ * - Relative paths resolve against `opts.repoRoot` (which itself defaults to
14
+ * process.cwd()); an explicit ABSOLUTE path always wins over the root.
14
15
  */
15
16
 
16
17
  import { existsSync, readFileSync } from 'node:fs';
@@ -32,9 +33,16 @@ import { scanBacklog, DEFAULT_BACKLOG_LIMIT } from './backlog-scan.mjs';
32
33
  * null/[] value to the Signals object.
33
34
  *
34
35
  * @param {object} [opts]
35
- * @param {string} [opts.statePath] defaults to '.claude/STATE.md'
36
- * @param {string} [opts.sessionsPath] — defaults to '.orchestrator/metrics/sessions.jsonl'
37
- * @param {string} [opts.lockPath] — defaults to '.orchestrator/bootstrap.lock'
36
+ * @param {string} [opts.repoRoot] absolute project root every relative
37
+ * path below resolves against, and the root forwarded to `scanBacklog`.
38
+ * Defaults to `process.cwd()`. Without it (#1071) a caller running from a
39
+ * worktree or a subdirectory silently read a DIFFERENT repo's STATE.md and
40
+ * sessions.jsonl than the one it was reporting on — measured as
41
+ * `recentSessions: []` against a checkout holding 245 session records.
42
+ * An explicit absolute `statePath`/`sessionsPath`/`lockPath` still wins.
43
+ * @param {string} [opts.statePath] — defaults to '<repoRoot>/.claude/STATE.md'
44
+ * @param {string} [opts.sessionsPath] — defaults to '<repoRoot>/.orchestrator/metrics/sessions.jsonl'
45
+ * @param {string} [opts.lockPath] — defaults to '<repoRoot>/.orchestrator/bootstrap.lock'
38
46
  * @param {Array} [opts.learnings] — pre-surfaced top-N learnings; defaults to []
39
47
  * @param {number} [opts.backlogLimit] — passed to scanBacklog; defaults to
40
48
  * `DEFAULT_BACKLOG_LIMIT` from backlog-scan.mjs (never a local copy of that
@@ -46,15 +54,24 @@ import { scanBacklog, DEFAULT_BACKLOG_LIMIT } from './backlog-scan.mjs';
46
54
  * @returns {Promise<import('./mode-selector.mjs').Signals>}
47
55
  */
48
56
  export async function buildLiveSignals(opts = {}) {
57
+ const repoRoot =
58
+ typeof opts.repoRoot === 'string' && opts.repoRoot.length > 0
59
+ ? opts.repoRoot
60
+ : process.cwd();
61
+ // `resolve(root, p)` returns `p` unchanged when `p` is absolute — explicit
62
+ // per-file overrides therefore keep precedence over repoRoot.
49
63
  const statePath = resolve(
64
+ repoRoot,
50
65
  typeof opts.statePath === 'string' ? opts.statePath : '.claude/STATE.md'
51
66
  );
52
67
  const sessionsPath = resolve(
68
+ repoRoot,
53
69
  typeof opts.sessionsPath === 'string'
54
70
  ? opts.sessionsPath
55
71
  : '.orchestrator/metrics/sessions.jsonl'
56
72
  );
57
73
  const lockPath = resolve(
74
+ repoRoot,
58
75
  typeof opts.lockPath === 'string' ? opts.lockPath : '.orchestrator/bootstrap.lock'
59
76
  );
60
77
  const learnings = Array.isArray(opts.learnings) ? opts.learnings : [];
@@ -137,7 +154,9 @@ export async function buildLiveSignals(opts = {}) {
137
154
  let backlog = null;
138
155
 
139
156
  try {
140
- backlog = await _scan({ limit: backlogLimit });
157
+ // `repoRoot` is forwarded so VCS detection and the `-R` host-pinning spec
158
+ // inside scanBacklog answer about the SAME repo the signals describe.
159
+ backlog = await _scan({ limit: backlogLimit, repoRoot });
141
160
  } catch {
142
161
  // Branch 6: scanBacklog threw — backlog stays null
143
162
  }
@@ -47,6 +47,128 @@ async function execWithTimeout(cmd, args, opts = {}) {
47
47
  ]);
48
48
  }
49
49
 
50
+ /** C0 controls plus DEL — matching them is the POINT, hence the disable. */
51
+ // eslint-disable-next-line no-control-regex
52
+ const CONTROL_BYTE_RE = /[\u0000-\u001f\u007f]/g;
53
+
54
+ /**
55
+ * Replace every C0/DEL control byte with its printable `\uXXXX` escape.
56
+ *
57
+ * `JSON.stringify` covers the payload preview, but NOT `SyntaxError.message` —
58
+ * V8 quotes the offending input INTO that message verbatim, so an ANSI/CR
59
+ * payload reached the operator's terminal through the error text even after
60
+ * the preview was escaped. Both halves go through here.
61
+ *
62
+ * @param {unknown} text
63
+ * @returns {string}
64
+ */
65
+ function escapeControlBytes(text) {
66
+ return String(text).replace(
67
+ CONTROL_BYTE_RE,
68
+ (ch) => `\\u${ch.charCodeAt(0).toString(16).padStart(4, '0')}`,
69
+ );
70
+ }
71
+
72
+ /**
73
+ * Name a parsed JSON value's type WITHOUT quoting any of its content.
74
+ *
75
+ * @param {unknown} value
76
+ * @returns {'null'|'array'|'object'|'string'|'number'|'boolean'|'undefined'}
77
+ */
78
+ function jsonTypeOf(value) {
79
+ if (value === null) return 'null';
80
+ if (Array.isArray(value)) return 'array';
81
+ return /** @type {any} */ (typeof value);
82
+ }
83
+
84
+ /**
85
+ * Parse CLI stdout as JSON, degrading an unparseable payload onto this module's
86
+ * documented failure channel instead of a bare `SyntaxError` (CWE-502).
87
+ *
88
+ * The throw IS that channel, not an escape from it: every call site runs under
89
+ * `checkCiStatus`'s outer catch, whose comment already names "unparseable
90
+ * output" as a case it converts to `console.warn` + `null`. Measured
91
+ * 2026-08-28 at 30940cb, BEFORE this helper existed: an HTML login page from
92
+ * `gh repo view`, a literal `null`, and an empty `glab` stdout ALL already
93
+ * returned `null` with a warn — nothing crashed, and
94
+ * `tests/lib/ci-status-banner.test.mjs` § "error containment" pinned it. So
95
+ * this does not fix a crash — the outer catch already turned non-JSON output
96
+ * into a warn + `null`. The rule this helper closes is lexical
97
+ * (`json-parse-untrusted-input`, see below), not a crash it prevents.
98
+ *
99
+ * What it does fix is the message. The raw parse error (`Unexpected token
100
+ * '<'`) named neither the CLI nor the request, and this banner spawns four
101
+ * different subprocesses — an operator reading that line at session-start
102
+ * could not tell which one returned garbage, which is the same
103
+ * "could not read looks like nothing to report" class #1022/#1039 attacked.
104
+ * Guarding at the parse also satisfies the `json-parse-untrusted-input` rule,
105
+ * which keys on a LEXICALLY enclosing try/catch and cannot see the outer one.
106
+ *
107
+ * Ceiling (BV-004): the payload preview is clamped to 120 characters. An
108
+ * unbounded one would bury the session-start banner it is printed beside — a
109
+ * paginated HTML error page is the realistic worst case. Revisit if a CLI
110
+ * starts emitting a diagnostic that needs more than one line to identify.
111
+ *
112
+ * The preview is emitted through `JSON.stringify`, not raw. It comes from a
113
+ * subprocess whose stdout this module does not control and lands in a
114
+ * `console.warn` beside the session-start banner: a payload carrying ANSI
115
+ * escapes, a `\r`, or a bare newline could otherwise repaint or overwrite the
116
+ * lines around it. `JSON.stringify` escapes every control byte and quotes the
117
+ * result, so the preview stays exactly one line of printable text.
118
+ *
119
+ * `expect` closes the second half of the same gap: valid JSON of the WRONG
120
+ * SHAPE parsed fine and escaped this named channel entirely. Measured
121
+ * 2026-08-28 at 7daa3d2 — a `gh repo view` that printed `null` reached
122
+ * `const { nameWithOwner } = …` and threw a bare
123
+ * `TypeError: Cannot destructure property 'nameWithOwner' of 'null'`, which
124
+ * names neither the CLI nor the request; a `glab api …/pipelines` that printed
125
+ * `null` was swallowed by `!Array.isArray(pipelines) → return null`, silent.
126
+ * Both now fail as the SAME named error the parse failure produces.
127
+ *
128
+ * @param {string} stdout Raw child-process stdout (untrusted)
129
+ * @param {string} label The command that produced it, for the failure message
130
+ * @param {'object'|'array'} [expect] Required shape; omit to accept any JSON
131
+ * @returns {unknown} The parsed value
132
+ * @throws {Error} Named parse/shape failure carrying a bounded payload preview
133
+ */
134
+ function parseCliJson(stdout, label, expect) {
135
+ const raw = String(stdout ?? '');
136
+ // Bounded (BV-004: 120 chars — a paginated HTML error page is the realistic
137
+ // worst case) AND escaped, so it can never break the line it is printed on.
138
+ const preview = raw.trim().slice(0, 120);
139
+ const shown = preview
140
+ ? `got: ${escapeControlBytes(JSON.stringify(preview))}`
141
+ : 'got: (empty stdout)';
142
+
143
+ let parsed;
144
+ try {
145
+ parsed = JSON.parse(raw);
146
+ } catch (err) {
147
+ const reason = escapeControlBytes(err instanceof Error ? err.message : String(err));
148
+ // `cause` preserves the original for a debugger; the reason is ALSO
149
+ // inlined into the message because the outer catch reads `err.message`
150
+ // only — a cause-only wrapper would lose it on the operator-facing line.
151
+ throw new Error(
152
+ `${label} returned unparseable JSON (${reason}) — ${shown}`,
153
+ { cause: err },
154
+ );
155
+ }
156
+
157
+ // A shape mismatch reports the JSON TYPE, never the payload. The parse
158
+ // succeeded, so the bytes add nothing an operator can act on — and
159
+ // `tests/lib/ci-status-banner.test.mjs` § "unexpected benign pipeline
160
+ // metadata" pins that a well-formed-but-wrong-shaped API body must not have
161
+ // its contents echoed anywhere. A type name carries no body content.
162
+ const actual = jsonTypeOf(parsed);
163
+ if (expect === 'array' && actual !== 'array') {
164
+ throw new Error(`${label} returned JSON of an unexpected shape — expected an array, got ${actual}`);
165
+ }
166
+ if (expect === 'object' && actual !== 'object') {
167
+ throw new Error(`${label} returned JSON of an unexpected shape — expected an object, got ${actual}`);
168
+ }
169
+ return parsed;
170
+ }
171
+
50
172
  /**
51
173
  * The one failure this probe can produce that the frozen
52
174
  * `REMOTE_RESOLUTION_REASONS` set has no member for: the async timeout race
@@ -201,16 +323,17 @@ async function getHeadSha(repoRoot, deps = {}) {
201
323
  * @param {string} apiPath
202
324
  * @param {string} repoRoot
203
325
  * @param {{ execFile?: Function, timeoutMs?: number, repoHost: string }} deps
326
+ * @param {'object'|'array'} [expect] Required payload shape (see `parseCliJson`)
204
327
  * @returns {Promise<unknown>}
205
328
  */
206
- async function glabApi(apiPath, repoRoot, deps = {}) {
329
+ async function glabApi(apiPath, repoRoot, deps = {}, expect = undefined) {
207
330
  const args = ['api', apiPath, '--hostname', deps.repoHost];
208
331
  const result = await execWithTimeout(
209
332
  'glab',
210
333
  args,
211
334
  { cwd: repoRoot, timeoutMs: deps.timeoutMs ?? DEFAULT_TIMEOUT_MS, execFile: deps.execFile },
212
335
  );
213
- return JSON.parse(result.stdout);
336
+ return parseCliJson(result.stdout, `glab api ${apiPath}`, expect);
214
337
  }
215
338
 
216
339
  /**
@@ -222,9 +345,10 @@ async function glabApi(apiPath, repoRoot, deps = {}) {
222
345
  * @param {string} apiPath
223
346
  * @param {string} repoRoot
224
347
  * @param {{ execFile?: Function, timeoutMs?: number, repoHost?: string }} deps
348
+ * @param {'object'|'array'} [expect] Required payload shape (see `parseCliJson`)
225
349
  * @returns {Promise<unknown>}
226
350
  */
227
- async function ghApi(apiPath, repoRoot, deps = {}) {
351
+ async function ghApi(apiPath, repoRoot, deps = {}, expect = undefined) {
228
352
  const args = ['api', apiPath];
229
353
  if (deps.repoHost) args.push('--hostname', deps.repoHost);
230
354
  const result = await execWithTimeout(
@@ -232,7 +356,7 @@ async function ghApi(apiPath, repoRoot, deps = {}) {
232
356
  args,
233
357
  { cwd: repoRoot, timeoutMs: deps.timeoutMs ?? DEFAULT_TIMEOUT_MS, execFile: deps.execFile },
234
358
  );
235
- return JSON.parse(result.stdout);
359
+ return parseCliJson(result.stdout, `gh api ${apiPath}`, expect);
236
360
  }
237
361
 
238
362
  /**
@@ -276,14 +400,18 @@ async function checkGitlab(repoRoot, now, deps = {}) {
276
400
  const currentSha = await getHeadSha(repoRoot, deps);
277
401
  const apiDeps = { ...deps, repoHost: project.host };
278
402
  const projectPath = `projects/${project.encodedProjectPath}`;
403
+ // `'array'` is load-bearing, not decoration: before it, a `glab api` that
404
+ // returned valid JSON of the wrong shape (`null`, `"ok"`, an object) fell
405
+ // into `!Array.isArray(pipelines) → return null` — a SILENT no-op an operator
406
+ // reads as "nothing to report". It now raises the same named error an
407
+ // unparseable payload does, so the outer catch warns.
279
408
  const pipelines = await glabApi(
280
409
  `${projectPath}/pipelines?order_by=updated_at&sort=desc&per_page=15`,
281
410
  repoRoot,
282
411
  apiDeps,
412
+ 'array',
283
413
  );
284
414
 
285
- if (!Array.isArray(pipelines)) return null;
286
-
287
415
  const currentPipeline = pipelines.find((p) => p.sha === currentSha);
288
416
 
289
417
  if (!currentPipeline) {
@@ -312,6 +440,7 @@ async function checkGitlab(repoRoot, now, deps = {}) {
312
440
  `${projectPath}/pipelines/${currentPipeline.id}/jobs`,
313
441
  repoRoot,
314
442
  apiDeps,
443
+ 'array',
315
444
  );
316
445
  if (Array.isArray(jobs)) {
317
446
  const softFailed = jobs
@@ -379,6 +508,7 @@ async function checkGitlab(repoRoot, now, deps = {}) {
379
508
  `${projectPath}/pipelines/${currentPipeline.id}/jobs`,
380
509
  repoRoot,
381
510
  apiDeps,
511
+ 'array',
382
512
  );
383
513
  if (Array.isArray(jobs)) {
384
514
  const failedJob = jobs.find((j) => j.status === 'failed');
@@ -450,12 +580,22 @@ async function checkGithub(repoRoot, deps = {}) {
450
580
  repoViewArgs,
451
581
  { cwd: repoRoot, timeoutMs: deps.timeoutMs ?? DEFAULT_TIMEOUT_MS, execFile: deps.execFile },
452
582
  );
453
- const { nameWithOwner } = JSON.parse(repoViewResult.stdout);
583
+ // `'object'` before the destructuring: a `gh repo view` that printed `null`
584
+ // (or a bare string, or an array) used to throw a bare
585
+ // `TypeError: Cannot destructure property 'nameWithOwner' of 'null'`, whose
586
+ // message names neither the CLI nor the request — the exact identification
587
+ // failure this helper exists to fix.
588
+ const { nameWithOwner } = parseCliJson(
589
+ repoViewResult.stdout,
590
+ `gh ${repoViewArgs.join(' ')}`,
591
+ 'object',
592
+ );
454
593
 
455
594
  const data = await ghApi(
456
595
  `repos/${nameWithOwner}/commits/HEAD/check-runs`,
457
596
  repoRoot,
458
597
  deps,
598
+ 'object',
459
599
  );
460
600
 
461
601
  const checkRuns = data.check_runs;
@@ -516,12 +656,19 @@ async function checkGithub(repoRoot, deps = {}) {
516
656
  * or >= 2 remotes with no preference match) — a benign, measured absence
517
657
  * - Required CLI (glab / gh) not in PATH
518
658
  * - Any CLI invocation times out
519
- * - JSON parse failure on CLI output
520
659
  *
521
660
  * Also returns `null`, but with a `console.warn` trace, when the VCS-detection
522
- * QUERY ITSELF failed (`git` not on PATH, `git remote -v` erroring) or when a
523
- * present CLI rejected its invocation. `null` alone cannot express "could not
524
- * read" see the outer catch and Step 1 for why the warn channel carries it.
661
+ * QUERY ITSELF failed (`git` not on PATH, `git remote -v` erroring), when a
662
+ * present CLI rejected its invocation, or when a CLI returned output this
663
+ * module could not parse (see {@link parseCliJson}). `null` alone cannot
664
+ * express "could not read" — see the outer catch and Step 1 for why the warn
665
+ * channel carries it.
666
+ *
667
+ * The unparseable-output case was listed above as SILENT until 2026-08-28.
668
+ * That was never the behaviour — it has always fallen through to the outer
669
+ * catch's warn branch (measured at 30940cb). The drift survived because the
670
+ * one test covering it asserted only the `null` and let the file-wide
671
+ * `console.warn` spy swallow the rest.
525
672
  *
526
673
  * @param {{
527
674
  * repoRoot?: string,