session-orchestrator 5.11.0 → 5.13.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 (394) hide show
  1. package/.claude-plugin/marketplace.json +12 -1
  2. package/.claude-plugin/plugin.json +4 -1
  3. package/.codex-plugin/agents/explorer.toml +1 -1
  4. package/.codex-plugin/agents/session-reviewer.toml +2 -2
  5. package/.codex-plugin/agents/wave-worker.toml +1 -1
  6. package/.codex-plugin/plugin.json +1 -1
  7. package/.cursor-plugin/plugin.json +1 -1
  8. package/.orchestrator/policy/blocked-commands.json +8 -0
  9. package/.orchestrator/policy/public-export.json +30 -0
  10. package/CHANGELOG.md +180 -3
  11. package/README.md +126 -117
  12. package/agents/analyst.md +7 -2
  13. package/agents/architect-reviewer.md +7 -2
  14. package/agents/code-implementer.md +4 -2
  15. package/agents/db-specialist.md +9 -2
  16. package/agents/dialectic-deriver.md +9 -3
  17. package/agents/docs-writer.md +7 -2
  18. package/agents/eval-judge.md +7 -1
  19. package/agents/qa-strategist.md +7 -2
  20. package/agents/security-reviewer.md +9 -2
  21. package/agents/session-reviewer.md +18 -2
  22. package/agents/skill-applied-judge.md +7 -1
  23. package/agents/test-writer.md +9 -2
  24. package/agents/ui-developer.md +9 -2
  25. package/agents/ux-evaluator.md +6 -1
  26. package/commands/session.md +4 -2
  27. package/commands/templates-ack.md +89 -18
  28. package/docs/agent-authoring.md +30 -11
  29. package/docs/ci-setup.md +38 -2
  30. package/docs/codex-setup.md +33 -9
  31. package/docs/components.md +13 -2
  32. package/docs/cursor-setup.md +3 -3
  33. package/docs/events-schema.md +5 -4
  34. package/docs/evidence-candidates.md +63 -0
  35. package/docs/fleet-checkin-audit.md +7 -4
  36. package/docs/github-mirror-protection.md +150 -3
  37. package/docs/install.md +102 -25
  38. package/docs/instruction-delivery.md +1 -1
  39. package/docs/migration-v4.md +2 -2
  40. package/docs/mods.md +118 -0
  41. package/docs/owner-config-schema.md +1 -0
  42. package/docs/plugin-architecture-v3.md +1 -1
  43. package/docs/pm-skills-marketplace.md +4 -3
  44. package/docs/rule-authoring.md +24 -13
  45. package/docs/scope-collision-guard.md +2 -1
  46. package/docs/session-config-reference.md +55 -21
  47. package/docs/session-config-template.md +32 -24
  48. package/docs/sessions-ledger-repair.md +137 -0
  49. package/docs/vault-docs-architecture.md +1 -1
  50. package/hooks/_lib/hook-import-set.json +406 -73
  51. package/hooks/_lib/hook-root.mjs +301 -0
  52. package/hooks/_lib/lock-bootstrap.mjs +434 -4
  53. package/hooks/_lib/orchestrator-ignore.mjs +3 -0
  54. package/hooks/_lib/subagent-transcript.mjs +2 -2
  55. package/hooks/_lib/vcs-create-matcher.mjs +13 -17
  56. package/hooks/cwd-change-restore.mjs +5 -1
  57. package/hooks/enforce-commands.mjs +4 -3
  58. package/hooks/enforce-scope.mjs +11 -1
  59. package/hooks/hooks-codex.json +48 -2
  60. package/hooks/hooks.json +31 -31
  61. package/hooks/on-session-end.mjs +38 -15
  62. package/hooks/on-session-start.mjs +158 -59
  63. package/hooks/on-stop.mjs +11 -6
  64. package/hooks/post-bash-issue-budget-refund.mjs +6 -1
  65. package/hooks/post-bash-write-verify.mjs +8 -4
  66. package/hooks/post-edit-validate.mjs +45 -52
  67. package/hooks/post-subagent-discovery-validator.mjs +2 -2
  68. package/hooks/post-tool-batch-wave-signal.mjs +8 -4
  69. package/hooks/post-tool-failure-corrective-context.mjs +5 -1
  70. package/hooks/pre-bash-destructive-guard.mjs +220 -8
  71. package/hooks/pre-bash-issue-budget.mjs +6 -1
  72. package/hooks/pre-bash-sessions-ledger-guard.mjs +691 -123
  73. package/hooks/pre-bash-staging-fence.mjs +258 -14
  74. package/hooks/pre-bash-templates-first.mjs +28 -27
  75. package/hooks/run-node.sh +1 -1
  76. package/hooks/skill-invocation-telemetry.mjs +15 -8
  77. package/hooks/subagent-telemetry.mjs +77 -14
  78. package/hooks/wave-scope-commit-guard.mjs +143 -14
  79. package/mods/so-hud/.claude-plugin/plugin.json +11 -0
  80. package/mods/so-hud/README.md +57 -0
  81. package/mods/so-hud/hooks/beacon-io.mjs +101 -0
  82. package/mods/so-hud/hooks/heredoc-scan.ts +394 -0
  83. package/mods/so-hud/hooks/hooks.json +1 -0
  84. package/mods/so-hud/hooks/logic.ts +182 -0
  85. package/mods/so-hud/hooks/register.tsx +670 -0
  86. package/mods/so-hud/hooks/rules.ts +2998 -0
  87. package/mods/so-hud/tsconfig.json +8 -0
  88. package/mods/so-hud/types/index.d.ts +36 -0
  89. package/package.json +4 -3
  90. package/rules/_index.md +1 -1
  91. package/rules/always-on/ask-via-tool.md +1 -1
  92. package/rules/always-on/loop-and-monitor.md +17 -194
  93. package/rules/always-on/parallel-sessions.md +3 -1
  94. package/rules/opt-in-stack/backend-data.md +1 -1
  95. package/rules/opt-in-stack/backend.md +1 -1
  96. package/rules/opt-in-stack/security-web.md +5 -1
  97. package/scripts/agent-usage-census.mjs +79 -0
  98. package/scripts/apply-session-learnings.mjs +34 -6
  99. package/scripts/archive-closed-prds.mjs +21 -5
  100. package/scripts/autopilot.mjs +11 -5
  101. package/scripts/backfill-abandoned-sessions.mjs +13 -3
  102. package/scripts/backfill-evidence-digest.mjs +2 -1
  103. package/scripts/backfill-learnings-from-vault.mjs +61 -6
  104. package/scripts/backfill-sessions.mjs +16 -2
  105. package/scripts/check-package-manager.mjs +26 -8
  106. package/scripts/check-sessions-integrity.mjs +4 -1
  107. package/scripts/ci/assert-vitest-green.mjs +3 -0
  108. package/scripts/ci/windows-smoke.mjs +201 -0
  109. package/scripts/close-precheck.mjs +55 -0
  110. package/scripts/dialectic-deriver.mjs +20 -12
  111. package/scripts/emit-event.mjs +2 -2
  112. package/scripts/emit-session.mjs +239 -20
  113. package/scripts/eval-session.mjs +17 -3
  114. package/scripts/export-hw-learnings.mjs +29 -2
  115. package/scripts/fleet-checkin-audit.mjs +8 -4
  116. package/scripts/fleet-checkin.mjs +73 -3
  117. package/scripts/gc-stale-worktrees.mjs +6 -1
  118. package/scripts/github-export.mjs +128 -0
  119. package/scripts/github-protection-audit.mjs +3 -1
  120. package/scripts/handover-queue-check.mjs +209 -0
  121. package/scripts/harness-audit.mjs +6 -4
  122. package/scripts/lib/agent-frontmatter.mjs +57 -10
  123. package/scripts/lib/agent-status.mjs +125 -7
  124. package/scripts/lib/agent-usage-census.mjs +394 -0
  125. package/scripts/lib/auto-dialectic.mjs +17 -10
  126. package/scripts/lib/auto-dream.mjs +22 -14
  127. package/scripts/lib/autopilot/loop.mjs +3 -1
  128. package/scripts/lib/autopilot/recent-runs.mjs +4 -2
  129. package/scripts/lib/autopilot/stall-sampler.mjs +2 -1
  130. package/scripts/lib/autopilot/telemetry.mjs +5 -1
  131. package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
  132. package/scripts/lib/baseline-archetypes.mjs +10 -2
  133. package/scripts/lib/blocked-commands-policy.mjs +4 -3
  134. package/scripts/lib/build-live-signals.mjs +13 -9
  135. package/scripts/lib/ci-status-banner.mjs +329 -24
  136. package/scripts/lib/codex/plugin-contract.mjs +8 -0
  137. package/scripts/lib/cold-start-detector.mjs +5 -2
  138. package/scripts/lib/command-blocker.mjs +603 -43
  139. package/scripts/lib/common.mjs +3 -2
  140. package/scripts/lib/config/cross-repo.mjs +74 -7
  141. package/scripts/lib/config/host-paths.mjs +6 -2
  142. package/scripts/lib/config/remote-hosts.mjs +2 -2
  143. package/scripts/lib/config/section-extractor.mjs +65 -11
  144. package/scripts/lib/config/template-keys.mjs +153 -0
  145. package/scripts/lib/config/vault-integration.mjs +1 -0
  146. package/scripts/lib/config.mjs +3 -3
  147. package/scripts/lib/coordinator-snapshot.mjs +1 -1
  148. package/scripts/lib/discovery/triage-state.mjs +2 -1
  149. package/scripts/lib/dispatcher/rank.mjs +5 -7
  150. package/scripts/lib/ecosystem-health.mjs +2 -2
  151. package/scripts/lib/eval/engine.mjs +3 -2
  152. package/scripts/lib/eval/session-resolve.mjs +6 -2
  153. package/scripts/lib/eval/sink.mjs +4 -3
  154. package/scripts/lib/events-retention-banner.mjs +3 -2
  155. package/scripts/lib/events-schema.mjs +10 -0
  156. package/scripts/lib/events.mjs +23 -12
  157. package/scripts/lib/evolve/autonomy-verdict.mjs +4 -9
  158. package/scripts/lib/evolve/autopilot-effectiveness.mjs +8 -18
  159. package/scripts/lib/fleet-protocol.mjs +21 -0
  160. package/scripts/lib/gates/gate-full.mjs +172 -11
  161. package/scripts/lib/gates/gate-helpers.mjs +236 -23
  162. package/scripts/lib/git-config-drift.mjs +141 -7
  163. package/scripts/lib/github-export.mjs +447 -0
  164. package/scripts/lib/gitlab-portfolio/cli.mjs +16 -5
  165. package/scripts/lib/handover-queue-marks.mjs +159 -0
  166. package/scripts/lib/hardware-pattern-detector.mjs +103 -13
  167. package/scripts/lib/harness-audit/categories/category1.mjs +16 -4
  168. package/scripts/lib/harness-audit/categories/category4.mjs +11 -8
  169. package/scripts/lib/harness-audit/categories/category6.mjs +25 -5
  170. package/scripts/lib/harness-audit/categories/category9.mjs +10 -4
  171. package/scripts/lib/host-identity.mjs +9 -5
  172. package/scripts/lib/http-request.mjs +64 -0
  173. package/scripts/lib/instruction-budget-guard.mjs +30 -22
  174. package/scripts/lib/io.mjs +6 -3
  175. package/scripts/lib/issue-budget-reconcile.mjs +3 -2
  176. package/scripts/lib/issue-budget.mjs +36 -7
  177. package/scripts/lib/learnings/evidence-candidates.mjs +154 -0
  178. package/scripts/lib/learnings/expiry-sweep.mjs +99 -7
  179. package/scripts/lib/learnings/io.mjs +289 -15
  180. package/scripts/lib/learnings/select.mjs +53 -4
  181. package/scripts/lib/learnings/store-role.mjs +209 -0
  182. package/scripts/lib/learnings/surface.mjs +46 -7
  183. package/scripts/lib/ledger-path.mjs +113 -0
  184. package/scripts/lib/maintenance-due-banner.mjs +63 -12
  185. package/scripts/lib/memory-banner.mjs +5 -6
  186. package/scripts/lib/memory-proposals/collector.mjs +5 -2
  187. package/scripts/lib/memory-proposals/sink.mjs +7 -4
  188. package/scripts/lib/memory-proposals/store.mjs +22 -5
  189. package/scripts/lib/mode-selector-accuracy.mjs +2 -1
  190. package/scripts/lib/no-follow-open.mjs +57 -0
  191. package/scripts/lib/orphan-reaper/reaper-audit.mjs +4 -2
  192. package/scripts/lib/own-lock-root.mjs +95 -0
  193. package/scripts/lib/owner-interview.mjs +2 -1
  194. package/scripts/lib/owner-yaml.mjs +70 -7
  195. package/scripts/lib/platform.mjs +161 -9
  196. package/scripts/lib/plugin-update-banner.mjs +15 -11
  197. package/scripts/lib/pre-push-local-partial.mjs +117 -0
  198. package/scripts/lib/pre-push-remote-gate.mjs +151 -25
  199. package/scripts/lib/process-group.mjs +270 -34
  200. package/scripts/lib/project-hygiene.mjs +28 -0
  201. package/scripts/lib/quality-gate.mjs +33 -9
  202. package/scripts/lib/reconcile/backlog.mjs +29 -15
  203. package/scripts/lib/reconcile/engine.mjs +26 -2
  204. package/scripts/lib/reconcile/idempotency.mjs +65 -3
  205. package/scripts/lib/reconcile/rule-expiry-sweep.mjs +53 -12
  206. package/scripts/lib/reconcile/verdict-ledger.mjs +224 -0
  207. package/scripts/lib/reconcile-nudge-banner.mjs +40 -6
  208. package/scripts/lib/resource-probe/evaluate.mjs +32 -9
  209. package/scripts/lib/resource-probe/probe-platform.mjs +3 -1
  210. package/scripts/lib/rule-loader.mjs +20 -10
  211. package/scripts/lib/scope-baseline.mjs +123 -8
  212. package/scripts/lib/scope-echo.mjs +5 -2
  213. package/scripts/lib/scope-gate.mjs +55 -20
  214. package/scripts/lib/session-close-backfill.mjs +75 -7
  215. package/scripts/lib/session-end/close-precheck.mjs +151 -0
  216. package/scripts/lib/session-end/phase-skip.mjs +8 -7
  217. package/scripts/lib/session-end/tail-runner.mjs +7 -2
  218. package/scripts/lib/session-end/vault-orphans.mjs +205 -0
  219. package/scripts/lib/session-id.mjs +9 -1
  220. package/scripts/lib/session-identity/own-session.mjs +63 -0
  221. package/scripts/lib/session-invocation.mjs +14 -3
  222. package/scripts/lib/session-lock.mjs +114 -0
  223. package/scripts/lib/session-record-repair.mjs +245 -35
  224. package/scripts/lib/session-registry.mjs +85 -12
  225. package/scripts/lib/session-schema/constants.mjs +9 -0
  226. package/scripts/lib/session-schema/filters.mjs +56 -1
  227. package/scripts/lib/session-schema/validator.mjs +15 -5
  228. package/scripts/lib/session-start-probes.mjs +149 -22
  229. package/scripts/lib/session-token-rollup.mjs +2 -1
  230. package/scripts/lib/session-work-trees.mjs +166 -0
  231. package/scripts/lib/sessions-canonical.mjs +61 -3
  232. package/scripts/lib/sessions-integrity-banner.mjs +17 -39
  233. package/scripts/lib/sessions-staleness-banner.mjs +8 -5
  234. package/scripts/lib/sh-quote.mjs +1 -0
  235. package/scripts/lib/skill-evidence-window.mjs +5 -1
  236. package/scripts/lib/skill-health/join.mjs +10 -9
  237. package/scripts/lib/skill-invocations-schema.mjs +13 -0
  238. package/scripts/lib/skill-judgments-schema.mjs +6 -2
  239. package/scripts/lib/snapshot-probe.mjs +414 -0
  240. package/scripts/lib/so-hud/collect.mjs +174 -0
  241. package/scripts/lib/so-hud/runtime.mjs +87 -0
  242. package/scripts/lib/spiral-carryover.mjs +8 -1
  243. package/scripts/lib/state-md/frontmatter-mutators.mjs +266 -8
  244. package/scripts/lib/state-md/idle-reset.mjs +248 -0
  245. package/scripts/lib/state-md/mission-status.mjs +5 -1
  246. package/scripts/lib/state-md/recommendations.mjs +26 -4
  247. package/scripts/lib/state-md-peer-guard.mjs +2 -0
  248. package/scripts/lib/state-md.mjs +31 -8
  249. package/scripts/lib/sunset/walker.mjs +4 -3
  250. package/scripts/lib/telemetry/consent.mjs +4 -2
  251. package/scripts/lib/telemetry/queue.mjs +3 -3
  252. package/scripts/lib/telemetry/sync.mjs +45 -9
  253. package/scripts/lib/test-runner/artifact-paths.mjs +10 -2
  254. package/scripts/lib/tmux-layout/layouts.mjs +20 -3
  255. package/scripts/lib/ux-grill/paths.mjs +5 -1
  256. package/scripts/lib/validate/check-agents.mjs +37 -6
  257. package/scripts/lib/validate/check-component-paths.mjs +25 -1
  258. package/scripts/lib/validate/check-guard-requires-parity.mjs +3 -3
  259. package/scripts/lib/validate/check-hooks-symmetry.mjs +53 -32
  260. package/scripts/lib/validate/check-json-files.mjs +3 -1
  261. package/scripts/lib/validate/check-learning-provenance.mjs +3 -2
  262. package/scripts/lib/validate/check-learnings-shell-writes.mjs +10 -4
  263. package/scripts/lib/validate/check-metrics-ledger-root.mjs +234 -0
  264. package/scripts/lib/validate/check-owner-leakage.mjs +159 -11
  265. package/scripts/lib/validate/check-test-posix-shell.mjs +246 -0
  266. package/scripts/lib/validate/check-unwired-features.mjs +4 -2
  267. package/scripts/lib/validate/check-vcs-repo-flag-positional.mjs +58 -0
  268. package/scripts/lib/validate/check-vcs-repo-flag.mjs +259 -73
  269. package/scripts/lib/validate/check-workflow-continue-on-error.mjs +63 -0
  270. package/scripts/lib/validate/confidential-names.mjs +3 -1
  271. package/scripts/lib/vault-mirror/migrate-ids.mjs +321 -0
  272. package/scripts/lib/vault-mirror/namespace.mjs +22 -9
  273. package/scripts/lib/vault-mirror/process.mjs +197 -65
  274. package/scripts/lib/vault-mirror/prune.mjs +5 -2
  275. package/scripts/lib/vault-mirror/render-learnings.mjs +8 -7
  276. package/scripts/lib/vault-mirror/render-sessions.mjs +36 -53
  277. package/scripts/lib/vault-mirror/retention.mjs +2 -2
  278. package/scripts/lib/vault-mirror/utils.mjs +106 -13
  279. package/scripts/lib/vault-staleness-banner.mjs +4 -7
  280. package/scripts/lib/vault-status/board-writer.mjs +34 -5
  281. package/scripts/lib/vault-status/narrative-mirror.mjs +66 -7
  282. package/scripts/lib/wave-executor/foreign-dispatch.mjs +20 -0
  283. package/scripts/lib/win-process-tree.mjs +137 -0
  284. package/scripts/lib/workspace.mjs +1 -1
  285. package/scripts/lib/worktree/constants.mjs +1 -1
  286. package/scripts/lib/worktree/lifecycle.mjs +1 -1
  287. package/scripts/lib/worktree/listing.mjs +1 -1
  288. package/scripts/lib/zx.mjs +50 -0
  289. package/scripts/mcp-server.mjs +16 -7
  290. package/scripts/mcp-server.sh +13 -83
  291. package/scripts/measure-policy-cache-effectiveness.mjs +7 -5
  292. package/scripts/memory-propose.mjs +10 -3
  293. package/scripts/migrate-cold-start-seed.mjs +7 -0
  294. package/scripts/migrate-legacy-learnings.sh +22 -0
  295. package/scripts/migrate-sessions-jsonl.mjs +16 -2
  296. package/scripts/migrate-vault-paths.mjs +29 -13
  297. package/scripts/parse-config.mjs +71 -14
  298. package/scripts/print-learnings-index.mjs +25 -2
  299. package/scripts/release-session-lock.mjs +94 -7
  300. package/scripts/release.mjs +451 -24
  301. package/scripts/relocate-vault-corpus.mjs +11 -7
  302. package/scripts/repair-invalid-sessions.mjs +68 -3
  303. package/scripts/resolve-session-invocation.mjs +57 -8
  304. package/scripts/run-migrate-v2-cross-repo.mjs +56 -1
  305. package/scripts/run-quality-gate.mjs +33 -7
  306. package/scripts/self-update.mjs +56 -7
  307. package/scripts/session-commit-files.mjs +133 -0
  308. package/scripts/site-numbers.mjs +52 -9
  309. package/scripts/snapshot-probe.mjs +156 -0
  310. package/scripts/so-hud.mjs +68 -0
  311. package/scripts/state-md-idle-reset.mjs +82 -0
  312. package/scripts/sweep-expired-learnings.mjs +54 -6
  313. package/scripts/sweep-expired-rules.mjs +4 -1
  314. package/scripts/validate-plugin.mjs +22 -0
  315. package/scripts/vault-integration-watcher.mjs +54 -6
  316. package/scripts/vault-mirror.mjs +9 -0
  317. package/scripts/vault-orphans.mjs +171 -0
  318. package/skills/_shared/fleet-protocol.md +166 -3
  319. package/skills/_shared/instruction-file-resolution.md +1 -1
  320. package/skills/_shared/monitor-patterns.md +44 -25
  321. package/skills/_shared/platform-tools.md +7 -0
  322. package/skills/_shared/state-ownership.md +5 -4
  323. package/skills/architecture/references/domain-model.md +1 -1
  324. package/skills/autopilot/SKILL.md +3 -2
  325. package/skills/bootstrap/SKILL.md +3 -1
  326. package/skills/bootstrap/_shared-template.md +4 -4
  327. package/skills/bootstrap/fast-template.md +1 -1
  328. package/skills/bootstrap/private-contract.md +1 -1
  329. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +17 -9
  330. package/skills/bootstrap/templates/agents/project-code-review.md +5 -1
  331. package/skills/bootstrap/templates/agents/project-discovery.md +5 -1
  332. package/skills/bootstrap/templates/agents/project-quality-gate.md +5 -1
  333. package/skills/claude-md-drift-check/SKILL.md +3 -3
  334. package/skills/claude-md-drift-check/checker.mjs +61 -101
  335. package/skills/close/SKILL.md +20 -7
  336. package/skills/discovery/probes/docs-staleness.mjs +7 -5
  337. package/skills/discovery/probes/frontend-slop.mjs +7 -3
  338. package/skills/discovery/probes/ssot-code-diff.mjs +5 -3
  339. package/skills/discovery/probes/supply-chain-slopcheck.mjs +10 -8
  340. package/skills/discovery/probes/vault-narrative-staleness.mjs +30 -10
  341. package/skills/discovery/probes/vault-staleness.mjs +5 -3
  342. package/skills/discovery/probes-session.md +3 -0
  343. package/skills/discovery/probes-vault.md +2 -0
  344. package/skills/dispatcher/SKILL.md +1 -1
  345. package/skills/eval/SKILL.md +3 -1
  346. package/skills/evolve/SKILL.md +2 -0
  347. package/skills/evolve/references/evolve-analyze-mode.md +3 -3
  348. package/skills/evolve/references/evolve-dialectic-mode.md +1 -1
  349. package/skills/gitlab-ops/SKILL.md +7 -6
  350. package/skills/gitlab-portfolio/SKILL.md +2 -2
  351. package/skills/go/SKILL.md +13 -1
  352. package/skills/npm-publish/SKILL.md +5 -2
  353. package/skills/plan/SKILL.md +23 -10
  354. package/skills/plan/mode-feature.md +13 -8
  355. package/skills/plan/mode-new.md +6 -14
  356. package/skills/plan/prd-feature-template.md +19 -0
  357. package/skills/plan/prd-full-template.md +12 -5
  358. package/skills/plan/prd-premortem-panel.md +48 -0
  359. package/skills/plan/prd-reviewer-prompt.md +11 -3
  360. package/skills/plan/soul.md +1 -1
  361. package/skills/portfolio/SKILL.md +5 -6
  362. package/skills/quality-gates/SKILL.md +2 -4
  363. package/skills/reconcile/SKILL.md +27 -4
  364. package/skills/release/SKILL.md +16 -8
  365. package/skills/session-end/SKILL.md +97 -13
  366. package/skills/session-end/phase-3-2-docs-verification.md +1 -0
  367. package/skills/session-end/phase-3-6-tail.md +11 -7
  368. package/skills/session-end/phase-3-7a-recommendations.md +9 -2
  369. package/skills/session-end/plan-verification.md +16 -1
  370. package/skills/session-end/references/phase-3-7e-vault-orphans.md +63 -0
  371. package/skills/session-end/references/phase-3-documentation-updates.md +25 -13
  372. package/skills/session-end/references/phase-5-issue-cleanup.md +1 -1
  373. package/skills/session-end/references/session-summary-template.md +8 -0
  374. package/skills/session-end/session-metrics-write.md +7 -4
  375. package/skills/session-plan/SKILL.md +5 -3
  376. package/skills/session-start/SKILL.md +10 -4
  377. package/skills/session-start/phase-4-5-resource-health.md +11 -5
  378. package/skills/session-start/phase-7-5-mode-selector.md +3 -1
  379. package/skills/session-start/phase-8-5-express-path.md +4 -3
  380. package/skills/session-start/references/phase-1-2-session-lock.md +7 -5
  381. package/skills/session-start/references/phase-1-5-session-continuity.md +13 -5
  382. package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
  383. package/skills/session-start/references/phase-4-ssot-environment-check.md +3 -2
  384. package/skills/tmux-layout/SKILL.md +2 -2
  385. package/skills/vault-sync/validator.mjs +3 -2
  386. package/skills/wave-executor/SKILL.md +5 -4
  387. package/skills/wave-executor/references/wave-executor-state-init.md +3 -3
  388. package/skills/wave-executor/references/wave-loop-dispatch.md +11 -5
  389. package/skills/wave-executor/references/wave-loop-review.md +13 -5
  390. package/skills/wave-executor/references/wave-loop-scope-manifest.md +1 -1
  391. package/AGENTS.md +0 -207
  392. package/assets/og-card.png +0 -0
  393. package/assets/og-card.svg +0 -47
  394. package/assets/wave-lifecycle.svg +0 -98
@@ -19,9 +19,18 @@
19
19
  * started_at, last_heartbeat, status ('active'|'wave'|'idle'),
20
20
  * current_wave, host_class, mode (Epic #583 W2-I3),
21
21
  * role ('navigator'|null, optional, #1462 — display only, never ownership;
22
- * set by heartbeat(patch.role), NOT by registerSelf)
22
+ * set by heartbeat(patch.role), NOT by registerSelf),
23
+ * session_root (absolute realpath|null, optional, #1605 — where THIS
24
+ * session's `.orchestrator/session.lock` and `current-session.json`
25
+ * live; set by registerSelf),
26
+ * work_root (absolute realpath|null, optional, #1605 — the working copy the
27
+ * session works in; set by registerSelf, refreshed by
28
+ * heartbeat(patch.workRoot) at every turn end)
23
29
  * }
24
30
  *
31
+ * `session_root` / `work_root` are additive: readers that predate #1605 ignore
32
+ * them, and `_validEntry` does not require them.
33
+ *
25
34
  * Schema v2 (Epic #583, W2-I3): `mode` field added so the exclusivity-matrix
26
35
  * can classify cross-repo registry entries correctly. Without it, every
27
36
  * registry-sourced peer was bucketed as `mode='session'` → classifyMode threw
@@ -38,7 +47,7 @@ import crypto from 'node:crypto';
38
47
  import { promises as fs } from 'node:fs';
39
48
  import { digestSha256 } from './crypto-digest-utils.mjs';
40
49
 
41
- import { appendFileSync, mkdirSync } from 'node:fs';
50
+ import { appendFileSync, chmodSync, mkdirSync, realpathSync } from 'node:fs';
42
51
 
43
52
  import { utcTimestamp, appendJsonl } from './common.mjs';
44
53
  import { resolvePrivateConfigDir } from './config/private-config-dir.mjs';
@@ -89,14 +98,41 @@ function entryPath(sessionId) {
89
98
  // ---------------------------------------------------------------------------
90
99
 
91
100
  /**
92
- * Stable SHA-256 hex of an absolute path. Used so registry entries can be
101
+ * Stable SHA-256 hex of a canonical repo path. Used so registry entries can be
93
102
  * correlated by repo without exposing local filesystem layout.
94
103
  */
95
104
  export function repoPathHash(absPath) {
96
105
  if (typeof absPath !== 'string' || absPath.length === 0) {
97
106
  throw new TypeError('repoPathHash: absPath must be a non-empty string');
98
107
  }
99
- return digestSha256(path.resolve(absPath));
108
+ return digestSha256(resolveRepoPath(absPath));
109
+ }
110
+
111
+ /** Resolve symlinks and on-disk casing; unavailable paths keep their old identity. */
112
+ function resolveRepoPath(repoPath) {
113
+ const resolved = path.resolve(repoPath);
114
+ try {
115
+ return realpathSync.native(resolved);
116
+ } catch {
117
+ return resolved;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Canonical spelling of an optional absolute root for `session_root` /
123
+ * `work_root` (#1605): `null` when absent, the realpath otherwise.
124
+ *
125
+ * @param {unknown} value
126
+ * @param {string} name option name for the error message
127
+ * @returns {string|null}
128
+ * @throws {TypeError} when `value` is neither nullish nor an absolute path string
129
+ */
130
+ function _optionalRoot(value, name) {
131
+ if (value === undefined || value === null) return null;
132
+ if (typeof value !== 'string' || !path.isAbsolute(value)) {
133
+ throw new TypeError(`${name} must be an absolute path when provided`);
134
+ }
135
+ return resolveRepoPath(value);
100
136
  }
101
137
 
102
138
  function _assertSessionId(sessionId) {
@@ -110,9 +146,11 @@ function _assertSessionId(sessionId) {
110
146
  }
111
147
 
112
148
  async function _writeJsonAtomic(filePath, data, { mode = 0o600 } = {}) {
113
- await fs.mkdir(path.dirname(filePath), { recursive: true });
149
+ // 0o700: the registry sits in the private config dir; a bare mkdir leaves it
150
+ // 0777 under umask 000 and breaks the owner-policy check beside it (#1629).
151
+ await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
114
152
  const tmp = `${filePath}.tmp-${process.pid}-${crypto.randomBytes(4).toString('hex')}`;
115
- await fs.writeFile(tmp, JSON.stringify(data, null, 2) + '\n', 'utf8');
153
+ await fs.writeFile(tmp, JSON.stringify(data, null, 2) + '\n', { encoding: 'utf8', mode }); // no 0666 window (#1632)
116
154
  await fs.rename(tmp, filePath);
117
155
  try { await fs.chmod(filePath, mode); } catch { /* best effort */ }
118
156
  }
@@ -208,8 +246,10 @@ export function logSweepEvent({ event, session_id, error }) {
208
246
  }) + '\n';
209
247
  try {
210
248
  const logPath = sweepLogPath();
211
- try { mkdirSync(path.dirname(logPath), { recursive: true }); } catch { /* best effort */ }
212
- appendFileSync(logPath, line, 'utf8');
249
+ try { mkdirSync(path.dirname(logPath), { recursive: true, mode: 0o700 }); } catch { /* best effort */ } // #1629
250
+ appendFileSync(logPath, line, { encoding: 'utf8', mode: 0o600 }); // #1632
251
+ // `mode` applies on creation only: tighten a loose pre-existing log too (#1636).
252
+ try { chmodSync(logPath, 0o600); } catch { /* best effort */ }
213
253
  } catch { /* last-resort silent no-op — never let log-write cascade */ }
214
254
  }
215
255
 
@@ -225,6 +265,10 @@ export function logSweepEvent({ event, session_id, error }) {
225
265
  * @param {number} [opts.pid] — defaults to process.pid
226
266
  * @param {string} [opts.status] — 'active' | 'wave' | 'idle' (default 'active')
227
267
  * @param {number} [opts.currentWave] — default 0
268
+ * @param {string} [opts.sessionRoot] — absolute root holding this session's
269
+ * `session.lock` / `current-session.json` (#1605); stored as `session_root`.
270
+ * @param {string} [opts.workRoot] — absolute working copy the session works in
271
+ * (#1605); stored as `work_root`.
228
272
  * @param {string} [opts.mode] — session mode (e.g., 'deep', 'feature', 'housekeeping').
229
273
  * Schema v2 (Epic #583, W2-I3): when present, propagated into the entry so
230
274
  * discovery + exclusivity-matrix classification work for cross-repo peers.
@@ -248,6 +292,8 @@ export async function registerSelf(opts) {
248
292
  if (opts.semanticSessionId !== undefined && opts.semanticSessionId !== null && typeof opts.semanticSessionId !== 'string') {
249
293
  throw new TypeError('registerSelf: semanticSessionId must be a string when provided');
250
294
  }
295
+ const sessionRoot = _optionalRoot(opts.sessionRoot, 'registerSelf: sessionRoot');
296
+ const workRoot = _optionalRoot(opts.workRoot, 'registerSelf: workRoot');
251
297
  const now = utcTimestamp();
252
298
  const entry = {
253
299
  session_id: sessionId,
@@ -255,7 +301,7 @@ export async function registerSelf(opts) {
255
301
  pid: opts.pid ?? process.pid,
256
302
  platform: opts.platform ?? null,
257
303
  repo_path_hash: repoPathHash(projectRoot),
258
- repo_name: path.basename(path.resolve(projectRoot)),
304
+ repo_name: path.basename(resolveRepoPath(projectRoot)),
259
305
  branch: opts.branch ?? null,
260
306
  started_at: now,
261
307
  last_heartbeat: now,
@@ -263,6 +309,8 @@ export async function registerSelf(opts) {
263
309
  current_wave: opts.currentWave ?? 0,
264
310
  host_class: opts.hostClass ?? null,
265
311
  mode: opts.mode ?? null,
312
+ session_root: sessionRoot,
313
+ work_root: workRoot,
266
314
  };
267
315
  await _writeJsonAtomic(entryPath(sessionId), entry);
268
316
  return entry;
@@ -277,16 +325,20 @@ export const REGISTRY_ROLES = Object.freeze(['navigator']);
277
325
  *
278
326
  * `patch.role`: `undefined` keeps the stored role, `null` clears it, a member
279
327
  * of `REGISTRY_ROLES` sets it. Anything else throws before the file is touched.
328
+ * `patch.workRoot` (#1605): `undefined` keeps the stored `work_root`, an
329
+ * absolute path replaces it (stored as its realpath).
280
330
  *
281
331
  * @param {string} sessionId
282
- * @param {{ status?: string, currentWave?: number, role?: string|null }} [patch]
332
+ * @param {{ status?: string, currentWave?: number, role?: string|null, workRoot?: string }} [patch]
283
333
  * @returns {Promise<object|null>} the updated entry, or null if none exists.
284
- * @throws {TypeError} when `patch.role` is neither undefined, null, nor a REGISTRY_ROLES member.
334
+ * @throws {TypeError} when `patch.role` is neither undefined, null, nor a REGISTRY_ROLES
335
+ * member, or `patch.workRoot` is neither undefined nor an absolute path.
285
336
  */
286
337
  export async function heartbeat(sessionId, patch = {}) {
287
338
  if (patch.role !== undefined && patch.role !== null && !REGISTRY_ROLES.includes(patch.role)) {
288
339
  throw new TypeError('heartbeat: role must be "navigator" or null');
289
340
  }
341
+ const workRoot = patch.workRoot === undefined ? undefined : _optionalRoot(patch.workRoot, 'heartbeat: workRoot');
290
342
  const file = entryPath(sessionId);
291
343
  const existing = await _readJsonSafe(file);
292
344
  if (!_validEntry(existing)) return null;
@@ -296,6 +348,7 @@ export async function heartbeat(sessionId, patch = {}) {
296
348
  ...(patch.status !== undefined ? { status: patch.status } : {}),
297
349
  ...(patch.currentWave !== undefined ? { current_wave: patch.currentWave } : {}),
298
350
  ...(patch.role !== undefined ? { role: patch.role } : {}),
351
+ ...(workRoot !== undefined && workRoot !== null ? { work_root: workRoot } : {}),
299
352
  };
300
353
  await _writeJsonAtomic(file, updated);
301
354
  return updated;
@@ -353,6 +406,26 @@ export async function detectPeers({ sessionId, freshnessMin = 15 } = {}) {
353
406
  });
354
407
  }
355
408
 
409
+ /**
410
+ * Count registry entries whose `started_at` lies within the last `windowMin`
411
+ * minutes — the start-wave signal (#1650). Pure: pass the entries you already
412
+ * hold. Passing detectPeers() output is complete as long as `windowMin` is at
413
+ * most detectPeers' `freshnessMin` (15): registerSelf() writes `started_at`
414
+ * and `last_heartbeat` with the same stamp, so every entry started inside the
415
+ * window is still heartbeat-fresh. Self must already be excluded by the caller.
416
+ * Unparseable or future `started_at` never counts (`_ageMinutes` → Infinity).
417
+ *
418
+ * @param {object[]} entries Registry entries (e.g. detectPeers() result)
419
+ * @param {object} [opts]
420
+ * @param {number} [opts.windowMin=5]
421
+ * @param {number} [opts.now] ms-since-epoch (test seam)
422
+ * @returns {number}
423
+ */
424
+ export function countRecentStarts(entries, { windowMin = 5, now = Date.now() } = {}) {
425
+ if (!Array.isArray(entries)) return 0;
426
+ return entries.filter((e) => e && _ageMinutes(e.started_at, now) <= windowMin).length;
427
+ }
428
+
356
429
  /**
357
430
  * Remove heartbeat entries older than `thresholdMin` minutes. Appends one
358
431
  * JSONL line per removal to sweepLogPath() for observability.
@@ -402,7 +475,7 @@ export async function sweepZombies({ thresholdMin = 60, now = Date.now() } = {})
402
475
  file: name,
403
476
  age_minutes: Number.isFinite(age) ? Math.round(age) : null,
404
477
  reason: valid ? 'stale-heartbeat' : 'malformed-entry',
405
- });
478
+ }, { mode: 0o600 }); // #1632
406
479
  logged += 1;
407
480
  } catch { /* log best-effort */ }
408
481
  } catch { /* file might have been removed by another sweep */ }
@@ -45,6 +45,8 @@ export const SESSION_KEY_ALIASES = Object.freeze({
45
45
  issues_filed: 'issues_created',
46
46
  issues_planned: 'planned_issues',
47
47
  files_changed: 'total_files_changed',
48
+ // No `agents_total` alias (#1699): session-record-repair.mjs lets the agent_summary sum win over it
49
+ // and flags a disagreement; an alias here would make normalize/migrate prefer agents_total instead.
48
50
  snapshots: 'snapshots_created',
49
51
  learnings: 'learnings_added',
50
52
  waves_total: 'total_waves',
@@ -252,6 +254,13 @@ export const OPTIONAL_FIELDS = Object.freeze([
252
254
  // 2026-10-01), so the counters say which of the two it is.
253
255
  'cost_records_priced',
254
256
  'cost_records_total',
257
+ // #1659 — `[{ tree, start_ref }]`: the work trees this session committed in,
258
+ // from its own `orchestrator.session.work_tree` events. Written by
259
+ // scripts/emit-session.mjs only when it adds something beyond
260
+ // `session_start_ref` (≥2 trees, or one tree that is not the own checkout).
261
+ // Shape (absolute `tree`, full hex `start_ref`) is enforced at that writer —
262
+ // malformed entries are dropped with a WARN — not in validateSession().
263
+ 'session_work_trees',
255
264
  // #1470 Pkt 4/6 — what `total_cost_usd` is and which price table produced
256
265
  // the cost fields. Merged by scripts/emit-session.mjs from
257
266
  // scripts/lib/telemetry/pricing.mjs (`COST_BASIS`, `PRICING_TABLE_DATE`):
@@ -34,7 +34,8 @@
34
34
  * these helpers only where the window is meant to represent REAL WORK.
35
35
  *
36
36
  * Exports: isRealSession, filterRealSessions, tailRealSessions,
37
- * isCoordinatorDirectHousekeeping
37
+ * isCoordinatorDirectHousekeeping, waveAgentCount,
38
+ * isCoordinatorDirectWave, normalizeWaveRole
38
39
  */
39
40
 
40
41
  // ---------------------------------------------------------------------------
@@ -111,3 +112,57 @@ export function isCoordinatorDirectHousekeeping(record) {
111
112
  waves.every((w) => w?.role === 'Housekeeping' && w?.coordinator_direct === true)
112
113
  );
113
114
  }
115
+
116
+ /**
117
+ * A wave's dispatched-agent count, read defensively across the key spellings
118
+ * the ledger holds (`/evolve analyze` effective-sizing, #1652): `agent_count`,
119
+ * else `agents` when it is a number, else the length of `agents` when it is an
120
+ * array (of descriptions), else `agents_dispatched`, else `agent_count_started`
121
+ * / its legacy spelling `agents_started` (the only count some waves carry).
122
+ *
123
+ * @param {unknown} wave — one entry of a record's `waves`
124
+ * @returns {number|null} — null when no key carries a count (unmeasured, never 0)
125
+ */
126
+ export function waveAgentCount(wave) {
127
+ if (wave === null || typeof wave !== 'object' || Array.isArray(wave)) return null;
128
+ if (typeof wave.agent_count === 'number') return wave.agent_count;
129
+ if (typeof wave.agents === 'number') return wave.agents;
130
+ if (Array.isArray(wave.agents)) return wave.agents.length;
131
+ if (typeof wave.agents_dispatched === 'number') return wave.agents_dispatched;
132
+ if (typeof wave.agent_count_started === 'number') return wave.agent_count_started;
133
+ if (typeof wave.agents_started === 'number') return wave.agents_started;
134
+ return null;
135
+ }
136
+
137
+ /**
138
+ * True when a wave dispatched no agents — flagged `coordinator_direct: true`,
139
+ * or a measured agent count of 0 without the flag (#1652). The ledger holds
140
+ * 222 unflagged 0-agent waves (2026-10-17, 249 real records); counting them in
141
+ * an agents-per-wave ratio drags the average down with waves no agent ran in.
142
+ * Read side on purpose: it covers the history without rewriting the ledger.
143
+ * An unmeasured count (`waveAgentCount` → null) is not coordinator-direct.
144
+ *
145
+ * @param {unknown} wave — one entry of a record's `waves`
146
+ * @returns {boolean}
147
+ */
148
+ export function isCoordinatorDirectWave(wave) {
149
+ if (wave === null || typeof wave !== 'object' || Array.isArray(wave)) return false;
150
+ return wave.coordinator_direct === true || waveAgentCount(wave) === 0;
151
+ }
152
+
153
+ /**
154
+ * Grouping key for a wave role, case- and separator-insensitive (#1652): the
155
+ * ledger spells one role several ways (`Finalization`/`finalization`,
156
+ * `Express-Path`/`Express Path`). Lowercases and collapses runs of spaces,
157
+ * underscores and hyphens to one `-`. Ceiling: distinct words stay distinct —
158
+ * `Express` and `Express-Path` remain two keys; merging synonyms needs an
159
+ * alias table, revisit when a consumer has to report them as one role.
160
+ *
161
+ * @param {unknown} role
162
+ * @returns {string|null} — null for a non-string or blank role
163
+ */
164
+ export function normalizeWaveRole(role) {
165
+ if (typeof role !== 'string') return null;
166
+ const key = role.trim().toLowerCase().replace(/[\s_-]+/g, '-').replace(/^-+|-+$/g, '');
167
+ return key.length > 0 ? key : null;
168
+ }
@@ -37,6 +37,11 @@ const COST_BASES = Object.freeze(['api-list-equivalent']);
37
37
  /** `pricing_table_date` (#1470 Pkt 6): a calendar date, `YYYY-MM-DD`. */
38
38
  const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
39
39
 
40
+ /** #1603: namespace/project#issue, allowing nested groups and ASCII path segments.
41
+ * The final assertion requires the actual end of input, including after newlines.
42
+ */
43
+ const QUALIFIED_ISSUE_RE = /^(?:[A-Za-z0-9](?:[A-Za-z0-9_.-]*[A-Za-z0-9])?\/)+[A-Za-z0-9](?:[A-Za-z0-9_.-]*[A-Za-z0-9])?#[1-9][0-9]*(?![\s\S])/;
44
+
40
45
  /**
41
46
  * Valid values for the optional `status` field (Epic #724 C1).
42
47
  * `completed` — record written by a normal /close flow.
@@ -329,12 +334,17 @@ function _validateOptionalFields(entry) {
329
334
  }
330
335
  }
331
336
  if (entry.issues_created !== undefined) {
332
- if (
333
- !Array.isArray(entry.issues_created) ||
334
- entry.issues_created.some((n) => typeof n !== 'number')
335
- ) {
336
- throw new ValidationError('issues_created must be an array of numbers');
337
+ if (!Array.isArray(entry.issues_created)) {
338
+ throw new ValidationError('issues_created must be an array of numbers or qualified references group/repo#n');
337
339
  }
340
+ entry.issues_created.forEach((issue, index) => {
341
+ if (typeof issue !== 'number' &&
342
+ !(typeof issue === 'string' && QUALIFIED_ISSUE_RE.test(issue))) {
343
+ throw new ValidationError(
344
+ `issues_created[${index}] must be a number or qualified reference group/repo#n`
345
+ );
346
+ }
347
+ });
338
348
  }
339
349
 
340
350
  // ADR-364 optional additive fields — remote-agent substrate thin-slice.
@@ -76,8 +76,15 @@ const CI_UNKNOWN_HINT_DEFAULT = 'run `glab ci status` on demand';
76
76
  /**
77
77
  * `ci-status` "no usable reading for HEAD" reasons after which the last PUSHED
78
78
  * commit is worth asking about: GitLab's `no-pipeline-for-head-sha`, GitHub's
79
- * `no-check-runs-for-head`, and GitLab's `pipeline-unmatched-ref` (all emitted
80
- * by `checkCiStatus`).
79
+ * `no-check-runs-for-head` and `head-sha-not-on-remote`, and GitLab's
80
+ * `pipeline-unmatched-ref` (all emitted by `checkCiStatus`).
81
+ *
82
+ * `head-sha-not-on-remote` (#1727) is GitHub's answer for a local HEAD the
83
+ * remote does not know — the normal state of a session with unpushed commits,
84
+ * now that the GitHub path asks for the LOCAL HEAD sha instead of the literal
85
+ * ref `HEAD`. Left out of this set, an unpushed GitHub HEAD rendered only the
86
+ * on-demand hint while the last pushed commit could be red: silence there
87
+ * reads as green, the #1332 collapse on the other host.
81
88
  *
82
89
  * `pipeline-unmatched-ref` (#857) is not a "no data" state — pipelines EXIST for
83
90
  * the sha, they just all sit on a foreign ref, so the reading stays `unknown`.
@@ -88,9 +95,24 @@ const CI_UNKNOWN_HINT_DEFAULT = 'run `glab ci status` on demand';
88
95
  const PUSHED_FOLLOW_UP_REASONS = new Set([
89
96
  'no-pipeline-for-head-sha',
90
97
  'no-check-runs-for-head',
98
+ 'head-sha-not-on-remote',
91
99
  'pipeline-unmatched-ref',
92
100
  ]);
93
101
 
102
+ /**
103
+ * The {@link PUSHED_FOLLOW_UP_REASONS} whose HEAD reading was taken for the
104
+ * LOCAL HEAD sha and found no CI data for it — so when the pushed commit IS
105
+ * HEAD, re-querying it would ask the identical question (see
106
+ * {@link ciPushedFollowUp}). `pipeline-unmatched-ref` is deliberately absent:
107
+ * there pipelines DO exist for the sha, only on a foreign ref, and the
108
+ * re-query keeps the behaviour #857 gave it.
109
+ */
110
+ const NO_DATA_FOR_LOCAL_HEAD_REASONS = new Set([
111
+ 'no-pipeline-for-head-sha',
112
+ 'no-check-runs-for-head',
113
+ 'head-sha-not-on-remote',
114
+ ]);
115
+
94
116
  /**
95
117
  * Full SHAs for `refs`, in order, from ONE `git rev-parse` — or `null` when
96
118
  * any ref does not resolve (no upstream, detached HEAD, not a git repo, git
@@ -143,16 +165,23 @@ async function revParseShas(repoRoot, refs) {
143
165
  * `skipped: network-probe-opt-in` and this never runs). It runs INSIDE the
144
166
  * probe's budget race and gets only what is left of that budget; if it
145
167
  * overruns or throws, the runner keeps the HEAD reading it was given (see
146
- * `runSessionStartProbes`). REVISIT TRIGGER: if `follow_up: 'budget-exceeded'`
147
- * shows up regularly in `orchestrator.probes.completed`, cache the verdict per
148
- * pushed SHA instead of re-asking.
168
+ * `runSessionStartProbes`). REVISIT TRIGGER: `follow_up: 'budget-exceeded'`
169
+ * on the `ci-status` record of `orchestrator.probes.completed` showing up
170
+ * regularly. Since #1728 that value means EITHER `ci-status` follow-up
171
+ * overran — this pushed-SHA requery OR {@link ciRedHistoryFollowUp}'s
172
+ * last-green history read — and the record carries no field that tells the
173
+ * two apart. Before caching the verdict per pushed SHA instead of re-asking,
174
+ * check the record's `outcome`: the pushed-SHA path runs only on an
175
+ * `unknown` reading (`ran-warn`), the history path only on a red one
176
+ * (`ran-alert`).
149
177
  *
150
- * The CLI round trip is skipped when the pushed commit IS HEAD on GitLab: its
151
- * HEAD reading was taken for the local HEAD SHA, so the re-query would ask the
152
- * identical question. Not on GitHub: `checkCiStatus` asks the API for the
153
- * literal ref `HEAD`, which GitHub resolves to the REMOTE default branch, not
154
- * the local HEAD — there the pushed SHA is a different question even when it
155
- * equals local HEAD.
178
+ * The CLI round trip is skipped when the pushed commit IS HEAD on a
179
+ * {@link NO_DATA_FOR_LOCAL_HEAD_REASONS} reading, on GitLab and GitHub alike:
180
+ * both paths take the HEAD reading for the LOCAL HEAD SHA, so the re-query
181
+ * would ask the identical question. GitHub joined that skip with #1727 —
182
+ * before it, `checkCiStatus` asked the GitHub API for the literal ref `HEAD`,
183
+ * which GitHub resolves to the REMOTE default branch, so the pushed SHA was a
184
+ * different question there even when it equalled local HEAD.
156
185
  *
157
186
  * The resolved SHA is handed to the runner through `deliver` BEFORE the CLI
158
187
  * round trip, so a requery that overruns the budget or throws still leaves
@@ -172,7 +201,7 @@ async function ciPushedFollowUp(result, ctx, requery, deliver) {
172
201
  // `pushed.sha: null` records "no upstream" as DATA — the renderer never asks git.
173
202
  if (!shas) return { ...result, pushed: { sha: null } };
174
203
  const [head, sha] = shas;
175
- if (reason === 'no-pipeline-for-head-sha' && sha === head) {
204
+ if (NO_DATA_FOR_LOCAL_HEAD_REASONS.has(reason) && sha === head) {
176
205
  return { ...result, pushed: { sha, sameAsHead: true } };
177
206
  }
178
207
  deliver({ ...result, pushed: { sha } });
@@ -186,6 +215,36 @@ async function ciPushedFollowUp(result, ctx, requery, deliver) {
186
215
  return { ...result, pushed: { sha, verdict } };
187
216
  }
188
217
 
218
+ /**
219
+ * `ci-status` follow-up (#1728): the GitHub red reading arrives WITHOUT its
220
+ * last-green history (`checkCiStatus({ deferHistory: true })`, reason
221
+ * `last-green-deferred`), so the red verdict is in the runner's hands before
222
+ * the ~0.5 s history query starts. It is delivered FIRST, re-marked
223
+ * `last-green-budget-exceeded` — the reading that stands if the query overruns
224
+ * the budget or throws, and that renders "history not read within the probe
225
+ * budget". Then the history query alone runs via the module's
226
+ * `readDeferredGithubHistory` — never a second full `checkCiStatus`, which
227
+ * would repeat `gh repo view` and the check-runs query.
228
+ *
229
+ * @param {*} result A red reading with `details.reason: 'last-green-deferred'`
230
+ * @param {{repoRoot?: string}} ctx
231
+ * @param {(partial: *) => void} deliver
232
+ * @param {*} mod The probe's imported module (`ci-status-banner.mjs`)
233
+ * @returns {Promise<*>}
234
+ */
235
+ async function ciRedHistoryFollowUp(result, ctx, deliver, mod) {
236
+ const { deferredHistory: _pending, ...reading } = result;
237
+ const fallback = {
238
+ ...reading,
239
+ details: { ...reading.details, reason: 'last-green-budget-exceeded' },
240
+ };
241
+ deliver(fallback);
242
+ // No reader on the module: the query never ran, so no budget was exceeded —
243
+ // keep the reading's own `last-green-deferred` ("history not read").
244
+ if (typeof mod?.readDeferredGithubHistory !== 'function') return reading;
245
+ return mod.readDeferredGithubHistory(result, { repoRoot: ctx?.repoRoot });
246
+ }
247
+
189
248
  /**
190
249
  * The `ci-status` follow-up (#1332, #1396): every git fact a `ci-status`
191
250
  * banner names is resolved HERE, inside the probe's budget race, and carried
@@ -198,14 +257,20 @@ async function ciPushedFollowUp(result, ctx, requery, deliver) {
198
257
  * {@link ciPushedFollowUp} (`pushed`)
199
258
  * - a `green` reading beside a set-aside FAILED same-commit pipeline →
200
259
  * `head: { sha }` for {@link droppedFailedLine} (#1390 P5)
260
+ * - a GitHub `red` reading whose history was deferred →
261
+ * {@link ciRedHistoryFollowUp} (`redCount` / `lastGreen`, #1728)
201
262
  *
202
263
  * @param {*} result
203
264
  * @param {{repoRoot?: string}} ctx
204
265
  * @param {(extra: object) => Promise<*>} requery
205
266
  * @param {(partial: *) => void} deliver
267
+ * @param {*} [mod] The probe's imported module
206
268
  * @returns {Promise<*>}
207
269
  */
208
- async function ciStatusFollowUp(result, ctx, requery, deliver) {
270
+ async function ciStatusFollowUp(result, ctx, requery, deliver, mod) {
271
+ if (result?.status === 'red' && result?.details?.reason === 'last-green-deferred') {
272
+ return ciRedHistoryFollowUp(result, ctx, deliver, mod);
273
+ }
209
274
  if (result?.status === 'unknown' && PUSHED_FOLLOW_UP_REASONS.has(result?.details?.reason)) {
210
275
  return ciPushedFollowUp(result, ctx, requery, deliver);
211
276
  }
@@ -276,6 +341,56 @@ function ciUnknownHint(result) {
276
341
  return `last pushed: ${short} (pipeline not checked; run \`${commitRunsCommand(gh, sha)}\`)`;
277
342
  }
278
343
 
344
+ /**
345
+ * The `(pipeline #<id>)` part of a red `ci-status` line. GitLab readings carry
346
+ * `details.currentPipelineId` (`?` when it is null, the pre-#856 text). GitHub
347
+ * readings have no pipeline id at all (#856) — there the part is omitted
348
+ * instead of printing a `(pipeline #?)` that names a pipeline that never
349
+ * existed.
350
+ *
351
+ * @param {*} r
352
+ * @returns {string}
353
+ */
354
+ function redPipelineText(r) {
355
+ const details = r?.details;
356
+ if (!details || typeof details !== 'object' || !('currentPipelineId' in details)) return '';
357
+ return ` (pipeline #${details.currentPipelineId ?? '?'})`;
358
+ }
359
+
360
+ /**
361
+ * The `— last green: …` part of a red `ci-status` line.
362
+ *
363
+ * - GitLab (`lastGreen.pipelineId` present): unchanged text, byte for byte.
364
+ * - GitHub (#856, no pipeline ids): `— last green: commit <sha7> (<n>
365
+ * commit(s) ago)` — interpolating the GitLab form there printed
366
+ * `#undefined` into the banner the operator reads.
367
+ * - `details.reason: 'last-green-query-failed'` with no `lastGreen`: says
368
+ * so. Omitting it would read exactly like "no green commit in the window",
369
+ * a different fact (three states, not two: found / none / not measured).
370
+ * - `last-green-budget-exceeded` (#1728): the red verdict arrived, the
371
+ * history query did not finish inside the probe budget — says so.
372
+ * `last-green-deferred` (the follow-up never re-marked it): not read.
373
+ *
374
+ * @param {*} r
375
+ * @returns {string}
376
+ */
377
+ function lastGreenText(r) {
378
+ const lg = r?.lastGreen;
379
+ if (lg && typeof lg === 'object') {
380
+ const sha7 = String(lg.sha ?? '').slice(0, 7);
381
+ if (lg.pipelineId !== undefined) {
382
+ return ` — last green: #${lg.pipelineId} (commit ${sha7}, ${r.redCount} pipelines ago)`;
383
+ }
384
+ const n = lg.ageCommits;
385
+ return ` — last green: commit ${sha7} (${n} commit${n === 1 ? '' : 's'} ago)`;
386
+ }
387
+ const reason = r?.details?.reason;
388
+ if (reason === 'last-green-query-failed') return ' — last green: unknown (history query failed)';
389
+ if (reason === 'last-green-budget-exceeded') return ' — last green: unknown (history not read within the probe budget)';
390
+ if (reason === 'last-green-deferred') return ' — last green: unknown (history not read)';
391
+ return '';
392
+ }
393
+
279
394
  /** Additive GitHub diagnostics; absent counts leave existing banners unchanged. */
280
395
  function checkRunCountsText(result) {
281
396
  const counts = result?.details?.checkRunCounts;
@@ -508,10 +623,12 @@ function startLoopBlockedMeter() {
508
623
  * - `precondition` optional; returns a skip-reason string to skip the probe
509
624
  * - `render` optional; maps a result to a banner line. Default:
510
625
  * `result.message` when severity is warn/alert.
511
- * - `followUp` optional; `async (result, ctx, requery, deliver) => result'`
626
+ * - `followUp` optional; `async (result, ctx, requery, deliver, mod) => result'`
512
627
  * run INSIDE the budget race after the entry function, where
513
- * `requery(extra)` re-invokes it with `{...args, ...extra}`
514
- * and `deliver(partial)` replaces the fallback result. If it
628
+ * `requery(extra)` re-invokes it with `{...args, ...extra}`,
629
+ * `deliver(partial)` replaces the fallback result, and `mod`
630
+ * is the probe's imported module (for a narrower second call
631
+ * than a full requery, #1728). If it
515
632
  * throws or overruns the budget, the last delivered result
516
633
  * stands (recorded `followUp: 'threw'|'budget-exceeded'`).
517
634
  * - `render` is a PURE function of the result (#1396): anything it names
@@ -571,10 +688,23 @@ export const PROBES = [
571
688
  spec: local('./ci-status-banner.mjs'),
572
689
  fn: 'checkCiStatus',
573
690
  network: true,
574
- args: ({ repoRoot }) => ({ repoRoot }),
691
+ // #1728: `deferHistory` keeps the GitHub last-green history query out of
692
+ // the entry function, so a red verdict is delivered before the query can
693
+ // overrun PROBE_BUDGET_MS (measured 2026-10-22: red with history
694
+ // 1605–1717 ms, the query alone ~495 ms). The follow-up reads it inside
695
+ // what is left of the budget. Ceiling: other local-HEAD callers of
696
+ // `checkCiStatus` still pay the query inline on red (see `checkGithub`).
697
+ // Second ceiling (BV-004): the red verdict is still lost when
698
+ // `checkCiStatus` ITSELF (git lookups + `gh repo view` + check-runs,
699
+ // measured 915–941 ms on 2026-10-22) overruns the work-time budget —
700
+ // `delivered` is set only after the entry function returns. Revisit
701
+ // trigger: an `orchestrator.probes.completed` record with `ci-status`
702
+ // `outcome: 'timeout'`.
703
+ args: ({ repoRoot }) => ({ repoRoot, deferHistory: true }),
575
704
  // #1332: on `no-pipeline-for-head-sha` / `no-check-runs-for-head`, query
576
705
  // the last PUSHED commit's verdict via `checkCiStatus({ sha })` — see the
577
706
  // ceiling on the function. #1396: also resolves every SHA the renderer names.
707
+ // #1728: on a deferred GitHub red reading, reads the last-green history.
578
708
  followUp: ciStatusFollowUp,
579
709
  // Bespoke shape: `{status, ok, details, …}` with no `message` field. The
580
710
  // banner text is prescribed by SKILL.md § Phase 4.
@@ -602,12 +732,8 @@ export const PROBES = [
602
732
  return `${mark} ci-status: CI status for HEAD could not be determined (${reason})${checkRunCountsText(r)} — ${ciUnknownHint(r)}`;
603
733
  }
604
734
  if (r.status === 'red') {
605
- const pid = r.details?.currentPipelineId ?? '?';
606
- const green = r.lastGreen
607
- ? ` — last green: #${r.lastGreen.pipelineId} (commit ${String(r.lastGreen.sha ?? '').slice(0, 7)}, ${r.redCount} pipelines ago)`
608
- : '';
609
735
  const job = r.failingJobName ? ` Failing job: ${r.failingJobName}` : '';
610
- return `🚨 CI RED on HEAD (pipeline #${pid})${green}.${job}${checkRunCountsText(r)}`;
736
+ return `🚨 CI RED on HEAD${redPipelineText(r)}${lastGreenText(r)}.${job}${checkRunCountsText(r)}`;
611
737
  }
612
738
  if (r.status === 'green') {
613
739
  const lines = [];
@@ -1134,6 +1260,7 @@ export async function runSessionStartProbes(opts = {}, deps = {}) {
1134
1260
  ctx,
1135
1261
  (extra) => fn({ ...probe.args(ctx), ...extra }),
1136
1262
  (partial) => { delivered = { __probeResult: partial }; },
1263
+ mod,
1137
1264
  ),
1138
1265
  };
1139
1266
  } catch {
@@ -122,6 +122,7 @@
122
122
  import { readFileSync } from 'node:fs';
123
123
  import { resolve } from 'node:path';
124
124
  import { costUsd } from './telemetry/pricing.mjs';
125
+ import { resolveLedgerPath } from './ledger-path.mjs';
125
126
 
126
127
  // ---------------------------------------------------------------------------
127
128
  // Default subagents.jsonl path (relative to cwd, mirroring the rest of the
@@ -262,7 +263,7 @@ function recordCostUsd(record) {
262
263
  */
263
264
  export function rollupSessionTokens({
264
265
  parentSessionId,
265
- subagentsPath = DEFAULT_SUBAGENTS_PATH,
266
+ subagentsPath = resolveLedgerPath(DEFAULT_SUBAGENTS_PATH, process.cwd()),
266
267
  }) {
267
268
  /** @type {TokenRollupResult} */
268
269
  const ZERO = {