session-orchestrator 5.2.0 → 5.3.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 (295) hide show
  1. package/.agents/skills/architecture/SKILL.md +3 -1
  2. package/.agents/skills/autopilot/SKILL.md +5 -1
  3. package/.agents/skills/autopilot/agents/openai.yaml +5 -0
  4. package/.agents/skills/bootstrap/SKILL.md +5 -1
  5. package/.agents/skills/bootstrap/agents/openai.yaml +5 -0
  6. package/.agents/skills/brainstorm/SKILL.md +5 -1
  7. package/.agents/skills/brainstorm/agents/openai.yaml +5 -0
  8. package/.agents/skills/claude-md-drift-check/SKILL.md +3 -1
  9. package/.agents/skills/close/SKILL.md +5 -1
  10. package/.agents/skills/close/agents/openai.yaml +5 -0
  11. package/.agents/skills/convergence-monitoring/SKILL.md +4 -2
  12. package/.agents/skills/debug/SKILL.md +5 -1
  13. package/.agents/skills/debug/agents/openai.yaml +5 -0
  14. package/.agents/skills/discovery/SKILL.md +5 -1
  15. package/.agents/skills/discovery/agents/openai.yaml +5 -0
  16. package/.agents/skills/dispatcher/SKILL.md +5 -1
  17. package/.agents/skills/dispatcher/agents/openai.yaml +5 -0
  18. package/.agents/skills/docs-orchestrator/SKILL.md +3 -1
  19. package/.agents/skills/ecosystem-health/SKILL.md +3 -1
  20. package/.agents/skills/eli5/SKILL.md +5 -1
  21. package/.agents/skills/eli5/agents/openai.yaml +5 -0
  22. package/.agents/skills/eval/SKILL.md +6 -2
  23. package/.agents/skills/eval/agents/openai.yaml +5 -0
  24. package/.agents/skills/evolve/SKILL.md +6 -2
  25. package/.agents/skills/evolve/agents/openai.yaml +5 -0
  26. package/.agents/skills/frontmatter-guard/SKILL.md +3 -1
  27. package/.agents/skills/gitlab-ops/SKILL.md +3 -1
  28. package/.agents/skills/gitlab-portfolio/SKILL.md +3 -1
  29. package/.agents/skills/go/SKILL.md +5 -1
  30. package/.agents/skills/go/agents/openai.yaml +5 -0
  31. package/.agents/skills/grill/SKILL.md +5 -1
  32. package/.agents/skills/grill/agents/openai.yaml +5 -0
  33. package/.agents/skills/harness-audit/SKILL.md +5 -1
  34. package/.agents/skills/harness-audit/agents/openai.yaml +5 -0
  35. package/.agents/skills/hook-development/SKILL.md +3 -1
  36. package/.agents/skills/mcp-builder/SKILL.md +3 -1
  37. package/.agents/skills/memory-cleanup/SKILL.md +5 -1
  38. package/.agents/skills/memory-cleanup/agents/openai.yaml +5 -0
  39. package/.agents/skills/mode-selector/SKILL.md +3 -1
  40. package/.agents/skills/npm-publish/SKILL.md +4 -2
  41. package/.agents/skills/peekaboo-driver/SKILL.md +3 -1
  42. package/.agents/skills/persona-panel/SKILL.md +5 -1
  43. package/.agents/skills/persona-panel/agents/openai.yaml +5 -0
  44. package/.agents/skills/plan/SKILL.md +5 -1
  45. package/.agents/skills/plan/agents/openai.yaml +5 -0
  46. package/.agents/skills/playwright-driver/SKILL.md +3 -1
  47. package/.agents/skills/portfolio/SKILL.md +5 -1
  48. package/.agents/skills/portfolio/agents/openai.yaml +5 -0
  49. package/.agents/skills/quality-gates/SKILL.md +3 -1
  50. package/.agents/skills/reconcile/SKILL.md +5 -1
  51. package/.agents/skills/reconcile/agents/openai.yaml +5 -0
  52. package/.agents/skills/release/SKILL.md +5 -1
  53. package/.agents/skills/release/agents/openai.yaml +5 -0
  54. package/.agents/skills/remote-offload/SKILL.md +3 -1
  55. package/.agents/skills/repo-audit/SKILL.md +5 -1
  56. package/.agents/skills/repo-audit/agents/openai.yaml +5 -0
  57. package/.agents/skills/session/SKILL.md +21 -0
  58. package/.agents/skills/session/agents/openai.yaml +5 -0
  59. package/.agents/skills/session-end/SKILL.md +3 -1
  60. package/.agents/skills/session-plan/SKILL.md +3 -1
  61. package/.agents/skills/session-start/SKILL.md +3 -1
  62. package/.agents/skills/spinout/SKILL.md +5 -1
  63. package/.agents/skills/spinout/agents/openai.yaml +5 -0
  64. package/.agents/skills/sunset-review/SKILL.md +5 -1
  65. package/.agents/skills/sunset-review/agents/openai.yaml +5 -0
  66. package/.agents/skills/templates-ack/SKILL.md +21 -0
  67. package/.agents/skills/templates-ack/agents/openai.yaml +5 -0
  68. package/.agents/skills/test/SKILL.md +5 -1
  69. package/.agents/skills/test/agents/openai.yaml +5 -0
  70. package/.agents/skills/test-runner/SKILL.md +3 -1
  71. package/.agents/skills/tmux-layout/SKILL.md +3 -1
  72. package/.agents/skills/using-orchestrator/SKILL.md +3 -1
  73. package/.agents/skills/ux-grill/SKILL.md +5 -1
  74. package/.agents/skills/ux-grill/agents/openai.yaml +5 -0
  75. package/.agents/skills/vault-mirror/SKILL.md +3 -1
  76. package/.agents/skills/vault-sync/SKILL.md +3 -1
  77. package/.agents/skills/wave-executor/SKILL.md +3 -1
  78. package/.agents/skills/write-executable-plan/SKILL.md +3 -1
  79. package/.claude-plugin/marketplace.json +1 -1
  80. package/.claude-plugin/plugin.json +1 -1
  81. package/.codex-plugin/plugin.json +4 -4
  82. package/.codex-plugin/skills/convergence-monitoring/SKILL.md +1 -3
  83. package/.codex-plugin/skills/eval/SKILL.md +1 -1
  84. package/.codex-plugin/skills/evolve/SKILL.md +1 -1
  85. package/.codex-plugin/skills/npm-publish/SKILL.md +1 -3
  86. package/.codex-plugin/skills/session/SKILL.md +1 -1
  87. package/.cursor/commands/eval.md +1 -1
  88. package/.cursor/commands/session.md +1 -1
  89. package/.cursor/rules/000-session-orchestrator.mdc +0 -2
  90. package/.cursor/rules/050-plan.mdc +1 -1
  91. package/.cursor/skills/convergence-monitoring/SKILL.md +1 -0
  92. package/.cursor/skills/eval/SKILL.md +1 -1
  93. package/.cursor/skills/npm-publish/SKILL.md +1 -0
  94. package/.cursor-plugin/plugin.json +1 -1
  95. package/.orchestrator/policy/blocked-commands.json +12 -3
  96. package/AGENTS.md +3 -2
  97. package/CHANGELOG.md +136 -0
  98. package/README.md +9 -9
  99. package/SECURITY.md +12 -0
  100. package/agents/dialectic-deriver.md +13 -10
  101. package/agents/eval-judge.md +67 -45
  102. package/agents/skill-applied-judge.md +34 -19
  103. package/commands/session.md +7 -3
  104. package/docs/baseline.md +12 -6
  105. package/docs/codex-setup.md +14 -2
  106. package/docs/components.md +7 -5
  107. package/docs/events-schema.md +56 -9
  108. package/docs/rule-authoring.md +58 -6
  109. package/docs/session-config-reference.md +100 -7
  110. package/docs/session-config-template.md +31 -2
  111. package/docs/telemetry.md +2 -0
  112. package/hooks/_lib/hook-import-set.json +85 -8
  113. package/hooks/_lib/subagent-transcript.mjs +582 -31
  114. package/hooks/config-protection.mjs +11 -3
  115. package/hooks/cwd-change-restore.mjs +11 -3
  116. package/hooks/enforce-commands.mjs +70 -23
  117. package/hooks/enforce-scope.mjs +143 -33
  118. package/hooks/hooks-codex.json +1 -1
  119. package/hooks/hooks.json +1 -1
  120. package/hooks/loop-guard.mjs +11 -3
  121. package/hooks/on-session-end.mjs +58 -23
  122. package/hooks/on-session-start.mjs +48 -11
  123. package/hooks/on-stop.mjs +168 -22
  124. package/hooks/operator-steer.mjs +11 -3
  125. package/hooks/post-bash-issue-budget-refund.mjs +18 -8
  126. package/hooks/post-bash-write-verify.mjs +3 -2
  127. package/hooks/post-edit-import-probe.mjs +17 -9
  128. package/hooks/post-edit-validate.mjs +13 -5
  129. package/hooks/post-subagent-discovery-validator.mjs +98 -13
  130. package/hooks/post-tool-batch-wave-signal.mjs +200 -38
  131. package/hooks/post-tool-failure-corrective-context.mjs +11 -5
  132. package/hooks/post-tooluse-frontend-slop.mjs +10 -4
  133. package/hooks/pre-auq-clarity.mjs +15 -2
  134. package/hooks/pre-bash-destructive-guard.mjs +80 -9
  135. package/hooks/pre-bash-issue-budget.mjs +16 -11
  136. package/hooks/pre-bash-memory-propose-audit.mjs +86 -54
  137. package/hooks/pre-bash-sessions-ledger-guard.mjs +391 -20
  138. package/hooks/pre-bash-staging-fence.mjs +335 -31
  139. package/hooks/pre-bash-templates-first.mjs +19 -14
  140. package/hooks/pre-task-scope-disjoint.mjs +233 -2
  141. package/hooks/subagent-telemetry.mjs +15 -19
  142. package/hooks/wave-scope-commit-guard.mjs +197 -100
  143. package/monitors/monitors.json +1 -1
  144. package/output-styles/wave-summary.md +1 -1
  145. package/package.json +1 -1
  146. package/pi/prompts/eval.md +1 -1
  147. package/pi/prompts/session.md +1 -1
  148. package/rules/README.md +1 -1
  149. package/rules/opt-in-domain/prompt-caching.md +1 -1
  150. package/rules/opt-in-stack/backend-data.md +1 -1
  151. package/rules/opt-in-stack/backend.md +3 -3
  152. package/rules/opt-in-stack/frontend.md +1 -1
  153. package/rules/opt-in-stack/security-web.md +3 -3
  154. package/rules/opt-in-stack/swift.md +1 -1
  155. package/scripts/autopilot.mjs +23 -2
  156. package/scripts/backfill-abandoned-sessions.mjs +117 -15
  157. package/scripts/check-sessions-integrity.mjs +300 -0
  158. package/scripts/dialectic-deriver.mjs +50 -13
  159. package/scripts/emit-session.mjs +75 -29
  160. package/scripts/eval-session.mjs +65 -3
  161. package/scripts/generate-agents-skills.mjs +102 -29
  162. package/scripts/generate-cursor-adapter.mjs +61 -16
  163. package/scripts/lib/agent-status.mjs +2 -31
  164. package/scripts/lib/auq/clarity.mjs +10 -2
  165. package/scripts/lib/auq/parse.mjs +12 -31
  166. package/scripts/lib/auq/schema.mjs +56 -41
  167. package/scripts/lib/auto-dialectic.mjs +304 -15
  168. package/scripts/lib/autopilot/flags.mjs +12 -1
  169. package/scripts/lib/autopilot/kill-switches.mjs +6 -3
  170. package/scripts/lib/autopilot/loop.mjs +14 -1
  171. package/scripts/lib/autopilot/stall-sampler.mjs +80 -23
  172. package/scripts/lib/ci-status-banner.mjs +376 -16
  173. package/scripts/lib/command-blocker.mjs +275 -28
  174. package/scripts/lib/config/dialectic.mjs +12 -3
  175. package/scripts/lib/config/gate.mjs +74 -0
  176. package/scripts/lib/config/reaper.mjs +162 -0
  177. package/scripts/lib/config.mjs +14 -0
  178. package/scripts/lib/convergence-monitor.mjs +74 -11
  179. package/scripts/lib/ecosystem-health.mjs +11 -0
  180. package/scripts/lib/eval/engine.mjs +421 -53
  181. package/scripts/lib/eval/judge.mjs +463 -40
  182. package/scripts/lib/eval/schema.mjs +10 -1
  183. package/scripts/lib/events-rotation.mjs +221 -25
  184. package/scripts/lib/events-schema.mjs +114 -0
  185. package/scripts/lib/events.mjs +524 -5
  186. package/scripts/lib/frontmatter-guard.mjs +21 -10
  187. package/scripts/lib/gates/gate-baseline.mjs +27 -2
  188. package/scripts/lib/gates/gate-full.mjs +28 -3
  189. package/scripts/lib/gates/gate-helpers.mjs +243 -21
  190. package/scripts/lib/gates/gate-incremental.mjs +28 -3
  191. package/scripts/lib/gates/gate-per-file.mjs +27 -2
  192. package/scripts/lib/gitlab-portfolio/markdown-writer.mjs +6 -1
  193. package/scripts/lib/instruction-budget-guard.mjs +146 -4
  194. package/scripts/lib/io.mjs +42 -8
  195. package/scripts/lib/issue-close-strip-labels.mjs +207 -49
  196. package/scripts/lib/js-mask.mjs +197 -0
  197. package/scripts/lib/learnings/evolve-telemetry.mjs +11 -7
  198. package/scripts/lib/maintenance-due-banner.mjs +53 -88
  199. package/scripts/lib/orphan-reaper.mjs +1588 -0
  200. package/scripts/lib/peer-cards/merger.mjs +48 -10
  201. package/scripts/lib/peer-cards/reader.mjs +78 -2
  202. package/scripts/lib/process-group.mjs +899 -0
  203. package/scripts/lib/quality-gate.mjs +107 -28
  204. package/scripts/lib/reconcile/backlog.mjs +368 -0
  205. package/scripts/lib/reconcile/engine.mjs +55 -188
  206. package/scripts/lib/reconcile/rule-expiry-sweep.mjs +302 -60
  207. package/scripts/lib/reconcile/sanitize.mjs +69 -3
  208. package/scripts/lib/reconcile-nudge-banner.mjs +138 -45
  209. package/scripts/lib/resource-probe/parsers.mjs +31 -0
  210. package/scripts/lib/rule-loader.mjs +41 -12
  211. package/scripts/lib/scope-echo.mjs +39 -2
  212. package/scripts/lib/scope-gate.mjs +605 -1
  213. package/scripts/lib/session-close-backfill.mjs +33 -6
  214. package/scripts/lib/session-id.mjs +9 -20
  215. package/scripts/lib/session-invocation.mjs +20 -0
  216. package/scripts/lib/session-schema/constants.mjs +30 -2
  217. package/scripts/lib/session-schema/normalizer.mjs +56 -4
  218. package/scripts/lib/session-schema.mjs +8 -3
  219. package/scripts/lib/session-start-probes.mjs +95 -10
  220. package/scripts/lib/sessions-canonical.mjs +23 -0
  221. package/scripts/lib/sessions-integrity-banner.mjs +7 -1
  222. package/scripts/lib/sessions-staleness-banner.mjs +193 -51
  223. package/scripts/lib/skill-evidence-window.mjs +891 -0
  224. package/scripts/lib/skill-evolution/candidate-intake.mjs +133 -12
  225. package/scripts/lib/skill-evolution/engine.mjs +18 -9
  226. package/scripts/lib/skill-judge.mjs +45 -3
  227. package/scripts/lib/tail-window.mjs +56 -0
  228. package/scripts/lib/telemetry/schema.mjs +30 -0
  229. package/scripts/lib/telemetry/sync.mjs +61 -6
  230. package/scripts/lib/telemetry-flush-health-banner.mjs +4 -22
  231. package/scripts/lib/test-runner/issue-reconcile.mjs +48 -16
  232. package/scripts/lib/tmux-layout/telemetry-stats.mjs +72 -13
  233. package/scripts/lib/user-invocable-skills.mjs +23 -3
  234. package/scripts/lib/ux-grill/reconcile.mjs +48 -22
  235. package/scripts/lib/validate/check-agents-skills.mjs +26 -15
  236. package/scripts/lib/validate/check-cursor-adapter.mjs +1 -0
  237. package/scripts/lib/validate/check-entry-guard.mjs +13 -50
  238. package/scripts/lib/validate/check-hook-entry-guards.mjs +636 -0
  239. package/scripts/lib/validate/check-pi-prompts.mjs +1 -0
  240. package/scripts/lib/validate/check-rules.mjs +7 -5
  241. package/scripts/lib/validate/check-skill-links.mjs +9 -1
  242. package/scripts/lib/validate/check-skill-script-paths.mjs +239 -27
  243. package/scripts/lib/validate/check-test-git-config-target.mjs +24 -34
  244. package/scripts/lib/validate/check-untracked-test-deps.mjs +7 -102
  245. package/scripts/lib/validate/check-unwired-features.mjs +130 -27
  246. package/scripts/lib/validate/check-validator-registration.mjs +34 -10
  247. package/scripts/lib/validate/confidential-names.mjs +10 -0
  248. package/scripts/lib/validate-vendored-rules.mjs +4 -3
  249. package/scripts/lib/vault-mirror/namespace.mjs +46 -8
  250. package/scripts/lib/vault-mirror/process.mjs +10 -3
  251. package/scripts/lib/vault-mirror/render-sessions.mjs +12 -2
  252. package/scripts/lib/vault-status/narrative-mirror.mjs +31 -7
  253. package/scripts/lib/vault-yaml.mjs +118 -0
  254. package/scripts/lib/worktree/lifecycle.mjs +153 -1
  255. package/scripts/release-session-lock.mjs +305 -0
  256. package/scripts/release.mjs +30 -5
  257. package/scripts/resolve-session-invocation.mjs +59 -0
  258. package/scripts/run-quality-gate.mjs +156 -17
  259. package/scripts/sweep-expired-rules.mjs +14 -3
  260. package/scripts/validate-plugin.mjs +12 -0
  261. package/scripts/validate-wave-scope.mjs +32 -105
  262. package/scripts/vault-mirror.mjs +9 -1
  263. package/skills/_shared/platform-tools.md +23 -11
  264. package/skills/autopilot/SKILL.md +22 -7
  265. package/skills/claude-md-drift-check/SKILL.md +1 -1
  266. package/skills/convergence-monitoring/README.md +8 -1
  267. package/skills/convergence-monitoring/SIGNALS.md +50 -6
  268. package/skills/convergence-monitoring/SKILL.md +15 -6
  269. package/skills/eval/SKILL.md +39 -24
  270. package/skills/eval/rubric-v1.md +1 -0
  271. package/skills/eval/rubric-v2.md +457 -0
  272. package/skills/evolve/SKILL.md +1 -1
  273. package/skills/evolve/references/evolve-dialectic-mode.md +42 -25
  274. package/skills/gitlab-ops/SKILL.md +3 -2
  275. package/skills/npm-publish/SKILL.md +1 -1
  276. package/skills/reconcile/SKILL.md +11 -0
  277. package/skills/session-end/SKILL.md +13 -16
  278. package/skills/session-end/discovery-scan.md +1 -1
  279. package/skills/session-end/phase-3-6-tail.md +55 -9
  280. package/skills/session-end/references/phase-5-issue-cleanup.md +9 -14
  281. package/skills/session-end/session-metrics-write.md +10 -0
  282. package/skills/session-plan/SKILL.md +17 -5
  283. package/skills/session-plan/references/session-plan-task-classification.md +2 -2
  284. package/skills/session-start/references/phase-4-ssot-environment-check.md +2 -1
  285. package/skills/ux-grill/SKILL.md +1 -1
  286. package/skills/wave-executor/SKILL.md +8 -4
  287. package/skills/wave-executor/circuit-breaker.md +2 -0
  288. package/skills/wave-executor/references/wave-executor-state-init.md +5 -3
  289. package/skills/wave-executor/references/wave-loop-dispatch.md +2 -1
  290. package/.codex-plugin/skills/convergence-monitoring/agents/openai.yaml +0 -5
  291. package/.codex-plugin/skills/npm-publish/agents/openai.yaml +0 -5
  292. package/.cursor/commands/convergence-monitoring.md +0 -13
  293. package/.cursor/commands/npm-publish.md +0 -13
  294. package/pi/prompts/convergence-monitoring.md +0 -11
  295. package/pi/prompts/npm-publish.md +0 -11
@@ -2,21 +2,39 @@
2
2
  * autopilot/stall-sampler.mjs — Stateless progress sampler for STALL_TIMEOUT
3
3
  * kill-switch (ADR-364, issue #371).
4
4
  *
5
- * The sampler reads `autopilot.jsonl` mtime as the progress marker: the
6
- * autopilot loop writes one record per session atomically (tmp+rename), so the
7
- * file mtime advances exactly when the loop makes progress. No false-positive
8
- * churn from in-place writes, no need to parse JSON, microsecond precision.
5
+ * PROGRESS MARKER PRECEDENCE (HR-102: a better signal REPLACES a worse one).
9
6
  *
10
- * Stateless single statSync call per invocation, no side effects, no async.
11
- * Wire-up to the kill-switch + loop is deferred (see #371 follow-up); this
12
- * module ships in isolation under the ADR-364 thin-slice MVP.
7
+ * 1. `session.lock` `last_heartbeat` (schema v2) advances DURING a run:
8
+ * `hooks/on-stop.mjs` refreshes it at every turn-end and
9
+ * `hooks/post-tool-batch-wave-signal.mjs` at every tool batch.
10
+ * 2. `autopilot.jsonl` mtime — the legacy marker, used only when no lock path
11
+ * is supplied or the lock is absent/corrupt/timestamp-less.
12
+ *
13
+ * The header of this module used to claim `autopilot.jsonl` carries "one record
14
+ * per session". It does not: `telemetry.mjs` writes ONE record per /autopilot
15
+ * INVOCATION, and `loop.mjs` calls it exactly twice — once for the dry-run
16
+ * preview and once AFTER the `for(;;)` loop. So during a real run the mtime the
17
+ * sampler read belonged to the PREVIOUS autopilot run, typically hours or days
18
+ * old, and the post-session check fired STALL_TIMEOUT after iteration 1 every
19
+ * time. The append-once contract is correct and stays; the marker was wrong.
20
+ *
21
+ * CEILING (BV-004). The heartbeat only advances while some live session owns the
22
+ * lock: `updateHeartbeat()` refuses on a session_id mismatch, and a child
23
+ * `claude -p` spawned while a parent session already holds the lock never
24
+ * acquires one (`hooks/_lib/lock-bootstrap.mjs` bails on `reason: 'active'`).
25
+ * In that one invocation shape — the headless driver started FROM a chat
26
+ * session rather than from a bare shell — the heartbeat freezes and the check
27
+ * degrades to today's behaviour. Revisit if STALL_TIMEOUT is ever observed
28
+ * firing on a run whose child session did real work.
29
+ *
30
+ * Stateless — at most two sync reads per invocation, no side effects, no async.
13
31
  *
14
32
  * Exports:
15
33
  * SAMPLE_CADENCE_MS — shared constant (30_000) defining the "fresh" window
16
34
  * sampleProgress(opts) — returns progress descriptor; never throws
17
35
  */
18
36
 
19
- import { statSync } from 'node:fs';
37
+ import { readFileSync, statSync } from 'node:fs';
20
38
 
21
39
  // ---------------------------------------------------------------------------
22
40
  // Constants
@@ -32,9 +50,40 @@ export const SAMPLE_CADENCE_MS = 30_000;
32
50
  const DEFAULT_AUTOPILOT_JSONL = '.orchestrator/metrics/autopilot.jsonl';
33
51
  const DEFAULT_STALL_TIMEOUT_SECONDS = 600;
34
52
  const MARKER_SUCCESS = 'autopilot.jsonl:mtime';
53
+ const MARKER_HEARTBEAT = 'session.lock:last_heartbeat';
35
54
  const MARKER_MISSING = 'missing';
36
55
  const MARKER_INVALID = 'invalid';
37
56
 
57
+ /**
58
+ * Read the live heartbeat timestamp (ms) out of `session.lock`.
59
+ *
60
+ * Returns `null` — meaning "fall back to the mtime marker" — for every failure
61
+ * mode: no path supplied, file absent, unreadable, not JSON, or carrying no
62
+ * parsable timestamp. A missing measurement is NOT a zero here; conflating the
63
+ * two would report a stall of `now` seconds the moment the lock is unreadable.
64
+ *
65
+ * @param {string} [sessionLockPath]
66
+ * @returns {number | null}
67
+ */
68
+ function readHeartbeatMs(sessionLockPath) {
69
+ if (typeof sessionLockPath !== 'string' || sessionLockPath.length === 0) return null;
70
+ let lock;
71
+ try {
72
+ lock = JSON.parse(readFileSync(sessionLockPath, 'utf8'));
73
+ } catch {
74
+ return null;
75
+ }
76
+ if (typeof lock !== 'object' || lock === null) return null;
77
+ // Same precedence `session-lock.mjs` uses on read: a v1 lock has no
78
+ // `last_heartbeat`, and its `started_at` is the best available stand-in.
79
+ const raw = typeof lock.last_heartbeat === 'string' && lock.last_heartbeat.length > 0
80
+ ? lock.last_heartbeat
81
+ : lock.started_at;
82
+ if (typeof raw !== 'string' || raw.length === 0) return null;
83
+ const ms = Date.parse(raw);
84
+ return Number.isFinite(ms) ? ms : null;
85
+ }
86
+
38
87
  // ---------------------------------------------------------------------------
39
88
  // Progress sampler
40
89
  // ---------------------------------------------------------------------------
@@ -45,6 +94,10 @@ const MARKER_INVALID = 'invalid';
45
94
  * @param {object} [opts]
46
95
  * @param {string} [opts.autopilotJsonlPath] — path to autopilot.jsonl (default
47
96
  * '.orchestrator/metrics/autopilot.jsonl').
97
+ * @param {string} [opts.sessionLockPath] — path to `session.lock`. When supplied
98
+ * and readable, its `last_heartbeat` (falling back to `started_at`, the schema
99
+ * v1 normalisation `session-lock.mjs` applies on read) REPLACES the mtime
100
+ * marker. Omitted → mtime only, which is what every pre-existing caller gets.
48
101
  * @param {number} [opts.stallTimeoutSeconds] — kill-switch threshold (default
49
102
  * 600). NOT range-clamped here; caller is responsible for bounds.
50
103
  * @param {() => number} [opts.nowMs] — wall-clock supplier (default Date.now).
@@ -66,22 +119,26 @@ export function sampleProgress(opts = {}) {
66
119
  void _stallTimeoutSeconds;
67
120
  const nowMs = typeof opts.nowMs === 'function' ? opts.nowMs : Date.now;
68
121
 
69
- let mtimeMs;
70
- try {
71
- const st = statSync(autopilotJsonlPath);
72
- mtimeMs = st.mtimeMs;
73
- } catch (err) {
74
- const marker = err && err.code === 'ENOENT' ? MARKER_MISSING : MARKER_INVALID;
75
- return {
76
- progressed: false,
77
- lastMarker: null,
78
- stallSeconds: 0,
79
- marker,
80
- };
122
+ let markerMs = readHeartbeatMs(opts.sessionLockPath);
123
+ let markerName = MARKER_HEARTBEAT;
124
+
125
+ if (markerMs === null) {
126
+ markerName = MARKER_SUCCESS;
127
+ try {
128
+ markerMs = statSync(autopilotJsonlPath).mtimeMs;
129
+ } catch (err) {
130
+ const marker = err && err.code === 'ENOENT' ? MARKER_MISSING : MARKER_INVALID;
131
+ return {
132
+ progressed: false,
133
+ lastMarker: null,
134
+ stallSeconds: 0,
135
+ marker,
136
+ };
137
+ }
81
138
  }
82
139
 
83
140
  const now = nowMs();
84
- const deltaMs = now - mtimeMs;
141
+ const deltaMs = now - markerMs;
85
142
  // Clamp negative deltas (clock skew, file mtime in the future) to 0.
86
143
  const stallSeconds = Math.max(0, Math.round(deltaMs / 1000));
87
144
  // Fresh if within the sample cadence window (also covers the clock-skew
@@ -90,8 +147,8 @@ export function sampleProgress(opts = {}) {
90
147
 
91
148
  return {
92
149
  progressed,
93
- lastMarker: mtimeMs,
150
+ lastMarker: markerMs,
94
151
  stallSeconds,
95
- marker: MARKER_SUCCESS,
152
+ marker: markerName,
96
153
  };
97
154
  }
@@ -104,7 +104,8 @@ function degradedResult(reason, detail) {
104
104
  // Escape here so BOTH routes are covered at their common exit. Slice first,
105
105
  // escape after: the budget is 200 chars of DETAIL, and escaping cannot then
106
106
  // leave a cut mid-`\uXXXX`.
107
- const tail = detail ? ` — ${escapeControlBytes(String(detail).trim().slice(0, 200))}` : '';
107
+ const escaped = detail ? escapeControlBytes(String(detail).trim().slice(0, 200)) : '';
108
+ const tail = escaped ? ` — ${escaped}` : '';
108
109
  return {
109
110
  severity: 'warn',
110
111
  ok: false,
@@ -112,6 +113,14 @@ function degradedResult(reason, detail) {
112
113
  `⚠ ci-status: CI status for HEAD could not be determined (${reason}) — ` +
113
114
  `state UNKNOWN, not "green".${tail}`,
114
115
  degraded: reason,
116
+ // The SAME already-clamped, already-escaped tail, carried as a field rather
117
+ // than only inside the banner sentence. Consumers that render their own row
118
+ // (the release preflight's `ci-green-on-head`) otherwise had to discard the
119
+ // only distinguishing part: a short sha, an unknown commit and a missing
120
+ // pipeline all printed the identical `CI status unknown (query-failed)`.
121
+ // Absent when there is no detail, so `ci.detail` stays falsy exactly where
122
+ // there is nothing to add.
123
+ ...(escaped ? { detail: escaped } : {}),
115
124
  };
116
125
  }
117
126
 
@@ -155,6 +164,40 @@ function escapeControlBytes(text) {
155
164
  );
156
165
  }
157
166
 
167
+ /**
168
+ * Ceiling (BV-004) for one API-supplied fragment embedded in a reading's
169
+ * operator-facing fields. 60 characters fits every real GitLab pipeline status
170
+ * and every CI job name this repo has ever produced (`test (macos-latest)` is
171
+ * 20), and keeps the release-preflight row on one line. Revisit if a platform
172
+ * starts emitting job names that need more.
173
+ */
174
+ const API_TEXT_MAX = 60;
175
+
176
+ /**
177
+ * Clamp AND control-byte-escape a string that came from a CI platform's API
178
+ * before it is interpolated into a reading (`details.reason`,
179
+ * `failingJobName`, `allowFailureJobs`).
180
+ *
181
+ * The degraded branch has done this since #1031 ({@link degradedResult}); the
182
+ * READ branch had not, so a pipeline `status` or job `name` reached the
183
+ * operator's terminal verbatim — including
184
+ * `scripts/release.mjs`'s `ci-green-on-head` row, the one line that decides
185
+ * whether a release proceeds. A status shaped `x\r\u001b[32m✓ ... PASS`
186
+ * repainted that row green while the row's own verdict stayed `ok:false`.
187
+ *
188
+ * Escaped at GENERATION, not at each render: there are four renderers
189
+ * (session-start banner, release preflight × 2, session-start probes) and only
190
+ * one producer, so the invariant "a reading carries no raw control bytes"
191
+ * belongs here. Slice first, escape after — the budget is 60 chars of API
192
+ * TEXT, and escaping afterwards cannot leave a cut mid-`\uXXXX`.
193
+ *
194
+ * @param {unknown} value
195
+ * @returns {string}
196
+ */
197
+ function sanitizeApiText(value) {
198
+ return escapeControlBytes(String(value ?? '').trim().slice(0, API_TEXT_MAX));
199
+ }
200
+
158
201
  /**
159
202
  * Name a parsed JSON value's type WITHOUT quoting any of its content.
160
203
  *
@@ -493,6 +536,246 @@ async function ghApi(apiPath, repoRoot, deps = {}, expect = undefined) {
493
536
  return parseCliJson(result.stdout, `gh api ${apiPath}`, expect);
494
537
  }
495
538
 
539
+ /**
540
+ * Look up the branch HEAD currently sits on, for pipeline-REF preference.
541
+ *
542
+ * **Never throws and never degrades the reading.** A missing branch is not a
543
+ * failure to report — it only costs the ref preference below, which then falls
544
+ * back to "every same-sha pipeline is an acceptable candidate", i.e. exactly the
545
+ * pre-#857 behaviour. Degrading here instead would turn a detached HEAD, a
546
+ * worktree oddity or a `git` hiccup into a dark banner on a repo whose CI state
547
+ * is perfectly readable.
548
+ *
549
+ * `HEAD` (detached) is reported as "unknown", not as a branch named HEAD.
550
+ *
551
+ * @param {string} repoRoot
552
+ * @param {{ execFile?: Function, timeoutMs?: number }} deps
553
+ * @returns {Promise<string|undefined>}
554
+ */
555
+ async function getCurrentBranch(repoRoot, deps = {}) {
556
+ const gitTimeout = Math.min(2000, deps.timeoutMs ?? 2000);
557
+ try {
558
+ const result = await execWithTimeout(
559
+ 'git',
560
+ ['rev-parse', '--abbrev-ref', 'HEAD'],
561
+ { cwd: repoRoot, timeoutMs: gitTimeout, execFile: deps.execFile },
562
+ );
563
+ const branch = String(result?.stdout ?? '').trim();
564
+ if (branch === '' || branch === 'HEAD') return undefined;
565
+ return branch;
566
+ } catch {
567
+ return undefined;
568
+ }
569
+ }
570
+
571
+ /**
572
+ * Does a pipeline's `ref` name the branch HEAD is on?
573
+ *
574
+ * GitLab reports a branch pipeline's ref as the bare branch name (`main`);
575
+ * `refs/heads/main` is accepted too because nothing in the API contract forbids
576
+ * the long form. An MR pipeline's ref is `refs/merge-requests/<iid>/head`,
577
+ * which matches no branch and is therefore never a confident match.
578
+ *
579
+ * @param {unknown} ref
580
+ * @param {string|undefined} branch
581
+ * @returns {boolean}
582
+ */
583
+ function refMatchesBranch(ref, branch) {
584
+ if (!branch || typeof ref !== 'string' || ref === '') return false;
585
+ return ref === branch || ref === `refs/heads/${branch}`;
586
+ }
587
+
588
+ /**
589
+ * Does a pipeline's `ref` name a merge-request HEAD (`detached`) pipeline?
590
+ *
591
+ * GitLab spells it `refs/merge-requests/<iid>/head` — the DETACHED pipeline,
592
+ * which runs the MR source commit itself. The merged-results counterpart
593
+ * (`refs/merge-requests/<iid>/merge`) runs a synthetic merge commit whose sha is
594
+ * NOT the local HEAD, so it can never reach a same-sha candidate tier and is
595
+ * deliberately not matched here.
596
+ *
597
+ * @param {unknown} ref
598
+ * @returns {boolean}
599
+ */
600
+ function isMergeRequestHeadRef(ref) {
601
+ return typeof ref === 'string' && /^refs\/merge-requests\/[^/]+\/head$/.test(ref);
602
+ }
603
+
604
+ /**
605
+ * Rank a GitLab pipeline status by how ALARMING it is (#857).
606
+ *
607
+ * Used only to pick ONE reading out of several pipelines for the SAME commit on
608
+ * the SAME ref, whose statuses contradict each other — measured on this project
609
+ * 2026-09-18: 14 of ~86 distinct shas in the last 100 pipelines carry 2-4
610
+ * pipelines, 7 of them with contradicting statuses (`canceled`+`success`,
611
+ * `failed`+`success`, …), produced by different `source`s (`push`, `api`,
612
+ * `merge_request_event`).
613
+ *
614
+ * Worst-status-wins is this module's stated fail-toward-visible posture (see
615
+ * {@link SILENT_QUERY_FAILURES}) applied to disagreement: a `success` beside a
616
+ * `failed` or a `canceled` cannot establish that the commit is green — the two
617
+ * runs may have executed different job sets, and which one is "the real one" is
618
+ * not derivable from the pipeline list. The disagreement itself is published as
619
+ * evidence (`details.ambiguous` + `details.candidateStatuses`), so the operator
620
+ * is never left guessing WHY the verdict reads the way it does.
621
+ *
622
+ * `failed` outranks `canceled` so a real failure can never be masked by a
623
+ * cancellation; everything UNSETTLED outranks `success`, because an in-flight
624
+ * sibling run means the commit's verdict is not settled.
625
+ *
626
+ * "Unsettled" is a category, not a leftover bucket. `skipped` and `manual` are
627
+ * TERMINAL non-failing states — the ordinary shape of a `rules:`-gated or
628
+ * manual-trigger pipeline — and lumping them in with `running`/`pending` turned
629
+ * the everyday `success` + `skipped` pair into `unknown /
630
+ * unrecognised-status-skipped`, where the pre-#857 reading was `green`. They
631
+ * therefore rank BELOW `success`: they never beat a real reading in either
632
+ * direction (`success` beside them → green, `failed` beside them → red), while
633
+ * a LONE `skipped`/`manual` still falls through `checkGitlab`'s tail to
634
+ * `unknown`, unchanged. Category separation, not a threshold tweak.
635
+ *
636
+ * @param {unknown} status
637
+ * @returns {number} Higher = more alarming
638
+ */
639
+ function statusSeverity(status) {
640
+ if (status === 'failed') return 4;
641
+ if (status === 'canceled') return 3;
642
+ if (status === 'success') return 1;
643
+ // Terminal, non-failing, carries no verdict of its own.
644
+ if (status === 'skipped' || status === 'manual') return 0;
645
+ // running / pending / created / preparing / waiting_for_resource / scheduled
646
+ // — and anything unrecognised, which cannot be assumed settled.
647
+ return 2;
648
+ }
649
+
650
+ /**
651
+ * Split every pipeline carrying the queried sha into the candidate set this
652
+ * reading may speak for, and the foreign-ref remainder it must not (#857).
653
+ *
654
+ * Before this, `pipelines.find(p => p.sha === currentSha)` took the most
655
+ * recently updated row in a 15-row window that MIXES refs (`main`,
656
+ * `refs/merge-requests/39/head`, a `codex/…` branch — measured 2026-09-18), so
657
+ * a foreign branch's or an MR's pipeline could silently become the local HEAD's
658
+ * verdict.
659
+ *
660
+ * Preference, first non-empty tier wins:
661
+ * 1. pipelines whose `ref` names the current branch (the confident match);
662
+ * 2. pipelines whose ref cannot be judged — no `ref` field, or no branch
663
+ * resolved (detached HEAD, `git` unavailable). Judging is impossible, so
664
+ * this is the pre-#857 CANDIDATE SET, now resolved worst-status-wins like
665
+ * every other tier (a ref-less `success` + `canceled` pair read green
666
+ * before #857 and reads red now — the change is deliberate, see
667
+ * {@link statusSeverity});
668
+ * 3. pipelines on an MR HEAD ref ({@link isMergeRequestHeadRef}) carrying the
669
+ * queried sha. A repo whose `workflow: rules:` only admit
670
+ * `merge_request_event` produces NO branch pipeline at all, so tiers 1-2
671
+ * are empty and the pre-tier-3 code reported `pipeline-unmatched-ref` for a
672
+ * commit whose verdict was sitting right there. SHA EQUALITY is the
673
+ * correctness argument — the ref is only a tie-breaker, and a
674
+ * merged-results pipeline runs a DIFFERENT sha, so it cannot reach this
675
+ * tier at all. `matchedRef` is reported, so the operator sees which ref
676
+ * spoke;
677
+ * 4. otherwise NOTHING is selected: every same-sha pipeline belongs to a
678
+ * genuinely foreign ref (another branch), which the caller reports as
679
+ * `unknown` with its own reason rather than adopting.
680
+ *
681
+ * `foreign` is EVERY same-sha row outside the selected tier — on every branch,
682
+ * not only on tier 4 (#1390). It used to be `[]` whenever a tier matched, so a
683
+ * same-sha `failed` on another ref vanished with no trace behind a `green`
684
+ * reading. The ref preference still decides the verdict; the rows it set aside
685
+ * are handed back so the caller can publish them as evidence
686
+ * ({@link candidateEvidence} `droppedStatuses`). Linear `includes` scan over
687
+ * at most the 15-row API window — revisit if `per_page` grows past ~200.
688
+ *
689
+ * @param {Array<any>} pipelines
690
+ * @param {string} sha
691
+ * @param {string|undefined} branch
692
+ * @returns {{ selected: Array<any>, foreign: Array<any> }}
693
+ */
694
+ function selectShaPipelines(pipelines, sha, branch) {
695
+ const sameSha = pipelines.filter((p) => p && p.sha === sha);
696
+ const split = (selected) => ({
697
+ selected,
698
+ foreign: sameSha.filter((p) => !selected.includes(p)),
699
+ });
700
+
701
+ const onBranch = sameSha.filter((p) => refMatchesBranch(p.ref, branch));
702
+ if (onBranch.length > 0) return split(onBranch);
703
+
704
+ const unjudgeable = sameSha.filter(
705
+ (p) => !branch || typeof p.ref !== 'string' || p.ref === '',
706
+ );
707
+ if (unjudgeable.length > 0) return split(unjudgeable);
708
+
709
+ const mrHead = sameSha.filter((p) => isMergeRequestHeadRef(p.ref));
710
+ if (mrHead.length > 0) return split(mrHead);
711
+
712
+ return split([]);
713
+ }
714
+
715
+ /**
716
+ * Pick the pipeline a reading speaks for: the most alarming of the candidates,
717
+ * ties resolved by the API's own `updated_at desc` ordering (first wins).
718
+ *
719
+ * @param {Array<any>} candidates Non-empty
720
+ * @returns {any}
721
+ */
722
+ function worstPipeline(candidates) {
723
+ let worst = candidates[0];
724
+ for (const p of candidates) {
725
+ if (statusSeverity(p.status) > statusSeverity(worst.status)) worst = p;
726
+ }
727
+ return worst;
728
+ }
729
+
730
+ /**
731
+ * ADDITIVE evidence fields (#857) describing how a reading was chosen.
732
+ *
733
+ * Additive by hard constraint: the `status` vocabulary stays
734
+ * `green | red | unknown`, because `dispatcher/rank.mjs` and
735
+ * `autonomy/suitability.mjs` treat only the literal `'red'` as bad and the
736
+ * session-start renderer prints nothing for a status string it does not know.
737
+ * So ambiguity is published BESIDE the verdict, never as a new verdict.
738
+ *
739
+ * - `matchedRef` the chosen pipeline's ref (present whenever the API
740
+ * supplied one), so the operator can see WHICH ref the
741
+ * verdict speaks for.
742
+ * - `candidateCount` number of same-sha candidates — only when > 1, so an
743
+ * ordinary one-pipeline reading is byte-identical to
744
+ * before.
745
+ * - `candidateStatuses` their statuses, in API order, same gate.
746
+ * - `ambiguous` `true` only when those statuses DISAGREE.
747
+ * - `droppedCount` number of same-sha pipelines the ref preference SET
748
+ * ASIDE (another branch, an MR HEAD beside a branch
749
+ * match, a ref-less row beside a judged one) — only when
750
+ * > 0 (#1390). Without it a `failed` run of the very
751
+ * commit on another ref disappeared behind a `green`.
752
+ * - `droppedStatuses` their statuses, in API order, same gate. Evidence
753
+ * only: they never enter the verdict or `ambiguous`,
754
+ * which stay the #857 ref-preference result.
755
+ *
756
+ * @param {Array<any>} selected
757
+ * @param {any} chosen
758
+ * @param {Array<any>} dropped Same-sha rows outside the selected tier
759
+ * @returns {{ matchedRef?: string, candidateCount?: number, candidateStatuses?: string[], ambiguous?: true, droppedCount?: number, droppedStatuses?: string[] }}
760
+ */
761
+ function candidateEvidence(selected, chosen, dropped) {
762
+ /** @type {any} */
763
+ const out = {};
764
+ if (typeof chosen?.ref === 'string' && chosen.ref !== '') {
765
+ out.matchedRef = sanitizeApiText(chosen.ref);
766
+ }
767
+ if (selected.length > 1) {
768
+ out.candidateCount = selected.length;
769
+ out.candidateStatuses = selected.map((p) => sanitizeApiText(p.status));
770
+ if (new Set(out.candidateStatuses).size > 1) out.ambiguous = true;
771
+ }
772
+ if (dropped.length > 0) {
773
+ out.droppedCount = dropped.length;
774
+ out.droppedStatuses = dropped.map((p) => sanitizeApiText(p.status));
775
+ }
776
+ return out;
777
+ }
778
+
496
779
  /**
497
780
  * Compute age in whole days between an ISO date string and `now`.
498
781
  *
@@ -534,6 +817,10 @@ async function checkGitlab(repoRoot, now, deps = {}) {
534
817
  // #1332: an explicit `deps.sha` (validated full hex SHA, see checkCiStatus)
535
818
  // replaces the local HEAD lookup — the caller asks about a NAMED commit.
536
819
  const currentSha = deps.sha ?? (await getHeadSha(repoRoot, deps));
820
+ // #857 ref preference — resolved alongside the sha, and best-effort: an
821
+ // unresolvable branch costs the preference, never the reading. See
822
+ // {@link getCurrentBranch}.
823
+ const branch = await getCurrentBranch(repoRoot, deps);
537
824
  const apiDeps = { ...deps, repoHost: project.host };
538
825
  const projectPath = `projects/${project.encodedProjectPath}`;
539
826
  // `'array'` is load-bearing, not decoration: before it, a `glab api` that
@@ -548,9 +835,32 @@ async function checkGitlab(repoRoot, now, deps = {}) {
548
835
  'array',
549
836
  );
550
837
 
551
- const currentPipeline = pipelines.find((p) => p.sha === currentSha);
838
+ // #857: the window mixes refs and carries duplicate pipelines per commit, so
839
+ // the reading is CHOSEN (ref preference, then worst-status-wins), never
840
+ // `.find()`-ed off the most recently updated row.
841
+ const { selected, foreign } = selectShaPipelines(pipelines, currentSha, branch);
842
+
843
+ if (selected.length === 0 && foreign.length > 0) {
844
+ // Pipelines EXIST for this commit, but every one of them belongs to a
845
+ // foreign ref (another branch, or `refs/merge-requests/<iid>/head`).
846
+ // Adopting one would let an MR's or a foreign branch's verdict stand in for
847
+ // the local HEAD's; claiming "no pipeline" would hide that runs exist. So:
848
+ // `unknown`, with a reason that names the actual situation. The status
849
+ // vocabulary is unchanged — the finding is delivered as a REASON.
850
+ return {
851
+ status: 'unknown',
852
+ ok: false,
853
+ details: {
854
+ currentPipelineId: null,
855
+ cliUsed: 'glab',
856
+ reason: 'pipeline-unmatched-ref',
857
+ candidateCount: foreign.length,
858
+ candidateStatuses: foreign.map((p) => sanitizeApiText(p.status)),
859
+ },
860
+ };
861
+ }
552
862
 
553
- if (!currentPipeline) {
863
+ if (selected.length === 0) {
554
864
  return {
555
865
  status: 'unknown',
556
866
  ok: false,
@@ -562,6 +872,8 @@ async function checkGitlab(repoRoot, now, deps = {}) {
562
872
  };
563
873
  }
564
874
 
875
+ const currentPipeline = worstPipeline(selected);
876
+ const evidence = candidateEvidence(selected, currentPipeline, foreign);
565
877
  const pipelineStatus = currentPipeline.status;
566
878
 
567
879
  if (pipelineStatus === 'success') {
@@ -581,7 +893,7 @@ async function checkGitlab(repoRoot, now, deps = {}) {
581
893
  if (Array.isArray(jobs)) {
582
894
  const softFailed = jobs
583
895
  .filter((j) => j.status === 'failed' && j.allow_failure === true)
584
- .map((j) => j.name);
896
+ .map((j) => sanitizeApiText(j.name));
585
897
  if (softFailed.length > 0) allowFailureJobs = softFailed;
586
898
  }
587
899
  } catch {
@@ -595,6 +907,7 @@ async function checkGitlab(repoRoot, now, deps = {}) {
595
907
  details: {
596
908
  currentPipelineId: currentPipeline.id,
597
909
  cliUsed: 'glab',
910
+ ...evidence,
598
911
  },
599
912
  };
600
913
  }
@@ -606,33 +919,50 @@ async function checkGitlab(repoRoot, now, deps = {}) {
606
919
  details: {
607
920
  currentPipelineId: currentPipeline.id,
608
921
  cliUsed: 'glab',
609
- reason: `pipeline-${pipelineStatus}`,
922
+ reason: `pipeline-${sanitizeApiText(pipelineStatus)}`,
923
+ ...evidence,
610
924
  },
611
925
  };
612
926
  }
613
927
 
614
928
  if (pipelineStatus === 'failed' || pipelineStatus === 'canceled') {
615
929
  // Find the last green pipeline in the history.
930
+ //
931
+ // #857: the look-back slice EXCLUDES every row carrying the queried sha, not
932
+ // just the chosen one. Pipelines are NOT one-per-commit on this project —
933
+ // measured 2026-09-18, 14 of ~86 distinct shas in the last 100 pipelines
934
+ // carry 2-4 — so a `success` duplicate of the very commit being reported red
935
+ // used to be found here and named as its own `lastGreen`. Reproduced on
936
+ // `52ae12f1` (a `canceled` push pipeline + a `success` api pipeline for the
937
+ // same commit): status `red`, `lastGreen.sha` === HEAD.
616
938
  const currentIdx = pipelines.indexOf(currentPipeline);
617
- const rest = pipelines.slice(currentIdx + 1);
939
+ const rest = pipelines.slice(currentIdx + 1).filter((p) => p && p.sha !== currentSha);
618
940
  const lastGreenPipeline = rest.find((p) => p.status === 'success');
619
941
 
620
- // Count consecutive non-success pipelines from current onwards.
621
- let redCount = 1;
942
+ // The unbroken non-success run from the current pipeline back to the last
943
+ // green, as ROWS. Same meaning `redCount` always had.
944
+ const redRun = [currentPipeline];
622
945
  for (const p of rest) {
623
946
  if (p.status === 'success') break;
624
- redCount++;
947
+ redRun.push(p);
625
948
  }
949
+ const redCount = redRun.length;
626
950
 
627
951
  let lastGreen;
628
952
  if (lastGreenPipeline) {
629
953
  const ageDays = ageDaysFrom(lastGreenPipeline.created_at, now);
630
- // Approximate commit distance: redCount is the number of red pipelines
631
- // before reaching the last green (pipelines are one-per-commit on this project).
632
954
  lastGreen = {
633
955
  sha: lastGreenPipeline.sha,
634
956
  pipelineId: lastGreenPipeline.id,
635
- ageCommits: redCount,
957
+ // Commit distance, counted over DISTINCT shas in the red run. The old
958
+ // value was the ROW count under a comment asserting "pipelines are
959
+ // one-per-commit on this project" — measured false at 16%, so the
960
+ // distance was overstated whenever a commit had been re-run.
961
+ ageCommits: new Set(redRun.map((p) => p.sha)).size,
962
+ // The row count is kept under its own name rather than dropped: it is
963
+ // what `redCount` reports, and "3 red pipelines over 2 commits" is a
964
+ // different (and also useful) statement from either number alone.
965
+ agePipelines: redCount,
636
966
  ageDays,
637
967
  };
638
968
  }
@@ -648,7 +978,7 @@ async function checkGitlab(repoRoot, now, deps = {}) {
648
978
  );
649
979
  if (Array.isArray(jobs)) {
650
980
  const failedJob = jobs.find((j) => j.status === 'failed');
651
- failingJobName = failedJob ? failedJob.name : undefined;
981
+ failingJobName = failedJob ? sanitizeApiText(failedJob.name) : undefined;
652
982
  }
653
983
  } catch {
654
984
  // Non-fatal — we still report red status without job name.
@@ -663,6 +993,15 @@ async function checkGitlab(repoRoot, now, deps = {}) {
663
993
  details: {
664
994
  currentPipelineId: currentPipeline.id,
665
995
  cliUsed: 'glab',
996
+ // A user-cancelled pipeline printed `🚨 CI RED on HEAD` indistinguishably
997
+ // from a real failure. The STATUS stays `red` on purpose (no consumer
998
+ // churn — `dispatcher/rank.mjs` and `autonomy/suitability.mjs` key on the
999
+ // literal `'red'`), and the distinction is delivered through the reason
1000
+ // both the banner and the release-preflight row already interpolate.
1001
+ // Named like the `pipeline-running` / `pipeline-pending` reasons above;
1002
+ // `failed` keeps carrying no reason — its detail is `failingJobName`.
1003
+ ...(pipelineStatus === 'canceled' ? { reason: 'pipeline-canceled' } : {}),
1004
+ ...evidence,
666
1005
  },
667
1006
  };
668
1007
  }
@@ -674,7 +1013,8 @@ async function checkGitlab(repoRoot, now, deps = {}) {
674
1013
  details: {
675
1014
  currentPipelineId: currentPipeline.id,
676
1015
  cliUsed: 'glab',
677
- reason: `unrecognised-status-${pipelineStatus}`,
1016
+ reason: `unrecognised-status-${sanitizeApiText(pipelineStatus)}`,
1017
+ ...evidence,
678
1018
  },
679
1019
  };
680
1020
  }
@@ -757,7 +1097,7 @@ async function checkGithub(repoRoot, deps = {}) {
757
1097
  return {
758
1098
  status: 'red',
759
1099
  ok: false,
760
- failingJobName: failedRun.name,
1100
+ failingJobName: sanitizeApiText(failedRun.name),
761
1101
  details: {
762
1102
  cliUsed: 'gh',
763
1103
  reason: 'lastGreen-not-implemented-for-github',
@@ -835,7 +1175,13 @@ async function checkGithub(repoRoot, deps = {}) {
835
1175
  * } | {
836
1176
  * status: 'green'|'red'|'unknown',
837
1177
  * ok: boolean,
838
- * lastGreen?: { sha: string, pipelineId: number, ageCommits: number, ageDays: number|null },
1178
+ * lastGreen?: {
1179
+ * sha: string,
1180
+ * pipelineId: number,
1181
+ * ageCommits: number,
1182
+ * agePipelines?: number,
1183
+ * ageDays: number|null,
1184
+ * },
839
1185
  * redCount?: number,
840
1186
  * failingJobName?: string,
841
1187
  * details: {
@@ -843,8 +1189,22 @@ async function checkGithub(repoRoot, deps = {}) {
843
1189
  * cliUsed: 'glab'|'gh',
844
1190
  * reason?: string,
845
1191
  * error?: string,
1192
+ * matchedRef?: string,
1193
+ * candidateCount?: number,
1194
+ * candidateStatuses?: string[],
1195
+ * ambiguous?: true,
1196
+ * droppedCount?: number,
1197
+ * droppedStatuses?: string[],
846
1198
  * },
847
1199
  * }>}
1200
+ *
1201
+ * The `status` vocabulary is FROZEN at `green | red | unknown` (#857/#856): the
1202
+ * #857 "several contradicting pipelines" and "only foreign-ref pipelines"
1203
+ * findings — and the #1390 "same-sha rows the ref preference set aside"
1204
+ * (`droppedCount`/`droppedStatuses`) — are delivered through ADDITIVE `details`
1205
+ * fields and `details.reason`,
1206
+ * never as a new status value — consumers fail open on an unknown status string
1207
+ * and the session-start renderer prints nothing for one.
848
1208
  */
849
1209
  export async function checkCiStatus(opts = {}, deps = {}) {
850
1210
  const {