session-orchestrator 3.24.0 → 4.0.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 (350) hide show
  1. package/.agents/skills/architecture/SKILL.md +18 -0
  2. package/.agents/skills/autopilot/SKILL.md +17 -0
  3. package/.agents/skills/bootstrap/SKILL.md +20 -0
  4. package/.agents/skills/brainstorm/SKILL.md +22 -0
  5. package/.agents/skills/claude-md-drift-check/SKILL.md +15 -0
  6. package/.agents/skills/convergence-monitoring/SKILL.md +22 -0
  7. package/.agents/skills/debug/SKILL.md +22 -0
  8. package/.agents/skills/discovery/SKILL.md +20 -0
  9. package/.agents/skills/dispatcher/SKILL.md +15 -0
  10. package/.agents/skills/docs-orchestrator/SKILL.md +18 -0
  11. package/.agents/skills/ecosystem-health/SKILL.md +20 -0
  12. package/.agents/skills/eli5/SKILL.md +20 -0
  13. package/.agents/skills/eval/SKILL.md +21 -0
  14. package/.agents/skills/evolve/SKILL.md +21 -0
  15. package/.agents/skills/frontmatter-guard/SKILL.md +15 -0
  16. package/.agents/skills/gitlab-ops/SKILL.md +20 -0
  17. package/.agents/skills/gitlab-portfolio/SKILL.md +15 -0
  18. package/.agents/skills/grill/SKILL.md +22 -0
  19. package/.agents/skills/hook-development/SKILL.md +15 -0
  20. package/.agents/skills/mcp-builder/SKILL.md +15 -0
  21. package/.agents/skills/memory-cleanup/SKILL.md +21 -0
  22. package/.agents/skills/mode-selector/SKILL.md +17 -0
  23. package/.agents/skills/npm-publish/SKILL.md +16 -0
  24. package/.agents/skills/peekaboo-driver/SKILL.md +18 -0
  25. package/.agents/skills/persona-panel/SKILL.md +17 -0
  26. package/.agents/skills/plan/SKILL.md +20 -0
  27. package/.agents/skills/playwright-driver/SKILL.md +20 -0
  28. package/.agents/skills/quality-gates/SKILL.md +20 -0
  29. package/.agents/skills/reconcile/SKILL.md +21 -0
  30. package/.agents/skills/remote-offload/SKILL.md +20 -0
  31. package/.agents/skills/repo-audit/SKILL.md +16 -0
  32. package/.agents/skills/session-end/SKILL.md +20 -0
  33. package/.agents/skills/session-plan/SKILL.md +20 -0
  34. package/.agents/skills/session-start/SKILL.md +20 -0
  35. package/.agents/skills/spinout/SKILL.md +16 -0
  36. package/.agents/skills/sunset-review/SKILL.md +16 -0
  37. package/.agents/skills/test-runner/SKILL.md +20 -0
  38. package/.agents/skills/tmux-layout/SKILL.md +21 -0
  39. package/.agents/skills/using-orchestrator/SKILL.md +17 -0
  40. package/.agents/skills/vault-mirror/SKILL.md +15 -0
  41. package/.agents/skills/vault-sync/SKILL.md +15 -0
  42. package/.agents/skills/wave-executor/SKILL.md +20 -0
  43. package/.agents/skills/write-executable-plan/SKILL.md +22 -0
  44. package/.claude-plugin/marketplace.json +1 -1
  45. package/.claude-plugin/plugin.json +1 -1
  46. package/.codex-plugin/plugin.json +1 -1
  47. package/.cursor/commands/autopilot.md +2 -2
  48. package/.cursor/commands/bootstrap.md +1 -1
  49. package/.cursor/commands/brainstorm.md +1 -1
  50. package/.cursor/commands/debug.md +1 -1
  51. package/.cursor/commands/discovery.md +1 -1
  52. package/.cursor/commands/dispatcher.md +2 -2
  53. package/.cursor/commands/eli5.md +2 -2
  54. package/.cursor/commands/eval.md +2 -2
  55. package/.cursor/commands/evolve.md +1 -1
  56. package/.cursor/commands/go.md +1 -1
  57. package/.cursor/commands/grill.md +2 -2
  58. package/.cursor/commands/memory-cleanup.md +2 -2
  59. package/.cursor/commands/persona-panel.md +1 -1
  60. package/.cursor/commands/plan.md +1 -1
  61. package/.cursor/commands/portfolio.md +1 -1
  62. package/.cursor/commands/reconcile.md +2 -2
  63. package/.cursor/commands/release.md +2 -2
  64. package/.cursor/commands/session.md +2 -2
  65. package/.cursor/commands/spinout.md +2 -2
  66. package/.cursor/commands/sunset-review.md +2 -2
  67. package/.cursor/commands/templates-ack.md +2 -2
  68. package/.cursor/commands/test.md +2 -2
  69. package/.cursor/skills/brainstorm/SKILL.md +1 -1
  70. package/.cursor/skills/eval/SKILL.md +1 -1
  71. package/.cursor/skills/quality-gates/SKILL.md +1 -1
  72. package/.cursor/skills/remote-offload/SKILL.md +1 -1
  73. package/.orchestrator/policy/blocked-commands.json +121 -0
  74. package/.orchestrator/policy/ecosystem.schema.json +66 -0
  75. package/.orchestrator/policy/quality-gates.example.json +16 -0
  76. package/.orchestrator/policy/quality-gates.schema.json +38 -0
  77. package/.orchestrator/policy/templates-policy.json +27 -0
  78. package/.orchestrator/policy/test-profiles.json +47 -0
  79. package/AGENTS.md +225 -0
  80. package/CHANGELOG.md +1125 -2
  81. package/NOTICE +11 -6
  82. package/README.md +127 -94
  83. package/agents/eval-judge.md +1 -1
  84. package/agents/skill-applied-judge.md +1 -1
  85. package/assets/wave-lifecycle.svg +98 -0
  86. package/commands/release.md +6 -3
  87. package/commands/session.md +18 -3
  88. package/docs/README.md +4 -0
  89. package/{agents/AGENTS.md → docs/agent-authoring.md} +19 -26
  90. package/docs/baseline.md +67 -0
  91. package/docs/ci-setup.md +108 -62
  92. package/docs/codex-setup.md +65 -21
  93. package/docs/components.md +36 -15
  94. package/docs/cursor-setup.md +6 -2
  95. package/docs/events-schema.md +9 -6
  96. package/docs/instruction-delivery.md +62 -0
  97. package/{agents/memory-proposal-collector.md → docs/memory-proposal-flow.md} +1 -8
  98. package/docs/migration-v4.md +341 -0
  99. package/docs/pi-setup.md +6 -1
  100. package/docs/plugin-architecture-v3.md +1 -1
  101. package/docs/rule-authoring.md +85 -19
  102. package/docs/scope-collision-guard.md +5 -5
  103. package/docs/session-config-reference.md +57 -56
  104. package/docs/session-config-template.md +6 -29
  105. package/docs/telemetry.md +157 -3
  106. package/docs/vault-docs-architecture.md +50 -11
  107. package/hooks/_lib/hook-import-set.json +1487 -0
  108. package/hooks/_lib/subagent-transcript.mjs +562 -0
  109. package/hooks/config-protection.mjs +2 -2
  110. package/hooks/cwd-change-restore.mjs +2 -2
  111. package/hooks/enforce-commands.mjs +69 -0
  112. package/hooks/hooks-codex.json +1 -1
  113. package/hooks/hooks-cursor.json +10 -0
  114. package/hooks/hooks-pi.json +5 -0
  115. package/hooks/hooks.json +6 -1
  116. package/hooks/loop-guard.mjs +3 -3
  117. package/hooks/on-session-end.mjs +2 -2
  118. package/hooks/on-session-start.mjs +103 -2
  119. package/hooks/on-stop.mjs +36 -11
  120. package/hooks/operator-steer.mjs +2 -2
  121. package/hooks/post-bash-write-verify.mjs +85 -0
  122. package/hooks/post-edit-import-probe.mjs +344 -0
  123. package/hooks/post-subagent-discovery-validator.mjs +187 -431
  124. package/hooks/post-tool-batch-wave-signal.mjs +118 -4
  125. package/hooks/post-tool-failure-corrective-context.mjs +2 -2
  126. package/hooks/post-tooluse-frontend-slop.mjs +3 -3
  127. package/hooks/pre-bash-destructive-guard.mjs +39 -13
  128. package/hooks/skill-invocation-telemetry.mjs +17 -5
  129. package/hooks/subagent-telemetry.mjs +13 -4
  130. package/monitors/monitors.json +3 -3
  131. package/package.json +9 -1
  132. package/pi/prompts/session.md +2 -2
  133. package/plugin.json +27 -0
  134. package/scripts/backfill-abandoned-sessions.mjs +50 -4
  135. package/scripts/backfill-learnings-from-vault.mjs +9 -3
  136. package/scripts/dialectic-deriver.mjs +73 -8
  137. package/scripts/export-hw-learnings.mjs +113 -1
  138. package/scripts/generate-agents-skills.mjs +378 -0
  139. package/scripts/generate-cursor-adapter.mjs +45 -8
  140. package/scripts/generate-hook-import-set.mjs +249 -0
  141. package/scripts/lib/agent-status.mjs +13 -2
  142. package/scripts/lib/auto-dream.mjs +38 -36
  143. package/scripts/lib/autonomy/suitability.mjs +6 -0
  144. package/scripts/lib/autopilot/loop.mjs +2 -2
  145. package/scripts/lib/ci-status-banner.mjs +220 -75
  146. package/scripts/lib/codex/plugin-contract.mjs +82 -6
  147. package/scripts/lib/config/auto-dream.mjs +2 -1
  148. package/scripts/lib/config/block-header.mjs +8 -0
  149. package/scripts/lib/config/block-preprocess.mjs +177 -0
  150. package/scripts/lib/config/broken-window.mjs +2 -1
  151. package/scripts/lib/config/cold-start.mjs +2 -1
  152. package/scripts/lib/config/config-protection.mjs +22 -2
  153. package/scripts/lib/config/context-coverage.mjs +2 -1
  154. package/scripts/lib/config/cross-repo.mjs +2 -1
  155. package/scripts/lib/config/custom-phases.mjs +2 -1
  156. package/scripts/lib/config/dialectic.mjs +2 -1
  157. package/scripts/lib/config/discovery-validator.mjs +2 -1
  158. package/scripts/lib/config/dispatcher-autonomy-capture.mjs +24 -1
  159. package/scripts/lib/config/dispatcher-autonomy.mjs +2 -1
  160. package/scripts/lib/config/docs-orchestrator.mjs +2 -1
  161. package/scripts/lib/config/docs-staleness.mjs +2 -1
  162. package/scripts/lib/config/drift-check.mjs +2 -1
  163. package/scripts/lib/config/eval.mjs +2 -1
  164. package/scripts/lib/config/events-rotation.mjs +2 -1
  165. package/scripts/lib/config/evolve.mjs +8 -2
  166. package/scripts/lib/config/frontend-slop-hook.mjs +7 -3
  167. package/scripts/lib/config/gitlab-portfolio.mjs +2 -1
  168. package/scripts/lib/config/handover-gate.mjs +2 -1
  169. package/scripts/lib/config/health-endpoints.mjs +7 -2
  170. package/scripts/lib/config/issue-budget.mjs +2 -1
  171. package/scripts/lib/config/loop-guard.mjs +2 -1
  172. package/scripts/lib/config/memory.mjs +2 -1
  173. package/scripts/lib/config/moc-staleness.mjs +2 -1
  174. package/scripts/lib/config/persona-gate-wave.mjs +2 -1
  175. package/scripts/lib/config/private-config-dir.mjs +67 -0
  176. package/scripts/lib/config/reconcile.mjs +2 -1
  177. package/scripts/lib/config/remote-hosts.mjs +2 -1
  178. package/scripts/lib/config/section-extractor.mjs +7 -1
  179. package/scripts/lib/config/skill-evolution.mjs +2 -1
  180. package/scripts/lib/config/slopcheck.mjs +2 -1
  181. package/scripts/lib/config/state-md-lock.mjs +2 -1
  182. package/scripts/lib/config/templates-first.mjs +2 -1
  183. package/scripts/lib/config/test.mjs +2 -1
  184. package/scripts/lib/config/vault-integration.mjs +7 -1
  185. package/scripts/lib/config/vault-mirror-quality.mjs +2 -1
  186. package/scripts/lib/config/vault-staleness.mjs +2 -1
  187. package/scripts/lib/config/vault-sync.mjs +2 -1
  188. package/scripts/lib/config/verification-auto-fix.mjs +2 -1
  189. package/scripts/lib/config/wave-reviewers.mjs +2 -1
  190. package/scripts/lib/config/worktree-orphans.mjs +2 -1
  191. package/scripts/lib/convergence-monitor.mjs +82 -16
  192. package/scripts/lib/dispatcher/rank.mjs +124 -48
  193. package/scripts/lib/ecosystem-health.mjs +16 -2
  194. package/scripts/lib/eval/engine.mjs +9 -1
  195. package/scripts/lib/eval/session-resolve.mjs +23 -4
  196. package/scripts/lib/events.mjs +22 -6
  197. package/scripts/lib/frontmatter-guard.mjs +131 -13
  198. package/scripts/lib/gates/gate-full.mjs +26 -0
  199. package/scripts/lib/gates/gate-helpers.mjs +76 -0
  200. package/scripts/lib/hardware-pattern-detector.mjs +18 -1
  201. package/scripts/lib/harness-audit/categories/category4.mjs +31 -11
  202. package/scripts/lib/host-identity.mjs +50 -11
  203. package/scripts/lib/instruction-budget-guard.mjs +171 -5
  204. package/scripts/lib/learnings/evolve-telemetry.mjs +178 -0
  205. package/scripts/lib/learnings/io.mjs +60 -6
  206. package/scripts/lib/memory-proposals/store.mjs +30 -22
  207. package/scripts/lib/owner-config-banner.mjs +43 -6
  208. package/scripts/lib/owner-config-loader.mjs +21 -10
  209. package/scripts/lib/owner-interview.mjs +3 -3
  210. package/scripts/lib/owner-yaml.mjs +207 -14
  211. package/scripts/lib/platform.mjs +108 -15
  212. package/scripts/lib/plugin-update-banner.mjs +406 -0
  213. package/scripts/lib/project-hygiene.mjs +38 -2
  214. package/scripts/lib/qg-command-drift-banner.mjs +50 -12
  215. package/scripts/lib/quality-gate.mjs +133 -44
  216. package/scripts/lib/reconcile/emitter.mjs +68 -6
  217. package/scripts/lib/reconcile/engine.mjs +13 -4
  218. package/scripts/lib/reconcile/idempotency.mjs +37 -4
  219. package/scripts/lib/reconcile/writer.mjs +40 -18
  220. package/scripts/lib/session-close-backfill.mjs +67 -9
  221. package/scripts/lib/session-id.mjs +12 -23
  222. package/scripts/lib/session-identity/own-session.mjs +125 -10
  223. package/scripts/lib/session-lock-shape.mjs +43 -0
  224. package/scripts/lib/session-lock.mjs +5 -10
  225. package/scripts/lib/session-registry.mjs +25 -9
  226. package/scripts/lib/session-schema/constants.mjs +36 -2
  227. package/scripts/lib/session-schema/validator.mjs +38 -4
  228. package/scripts/lib/session-start-probes.mjs +18 -1
  229. package/scripts/lib/sessions-staleness-banner.mjs +18 -11
  230. package/scripts/lib/skill-health/join.mjs +17 -4
  231. package/scripts/lib/state-md.mjs +78 -0
  232. package/scripts/lib/sunset/walker.mjs +6 -0
  233. package/scripts/lib/telemetry/schema.mjs +181 -9
  234. package/scripts/lib/telemetry/sync.mjs +368 -12
  235. package/scripts/lib/validate/check-agents-skills.mjs +327 -0
  236. package/scripts/lib/validate/check-agents.mjs +3 -3
  237. package/scripts/lib/validate/check-cursor-adapter.mjs +234 -72
  238. package/scripts/lib/validate/check-hooks-symmetry.mjs +45 -16
  239. package/scripts/lib/validate/check-owner-leakage.mjs +281 -20
  240. package/scripts/lib/validate/check-skill-links.mjs +163 -0
  241. package/scripts/lib/validate/check-skill-script-paths.mjs +47 -28
  242. package/scripts/lib/validate/check-unwired-features.mjs +0 -2
  243. package/scripts/lib/validate/check-validator-registration.mjs +10 -4
  244. package/scripts/lib/validate/enumerate-repo-files.mjs +317 -0
  245. package/scripts/lib/vault-backfill/template.mjs +63 -6
  246. package/scripts/lib/vault-mirror/process.mjs +165 -42
  247. package/scripts/lib/vault-mirror/telemetry.mjs +2 -2
  248. package/scripts/lib/vault-status/narrative-mirror.mjs +127 -18
  249. package/scripts/lib/wave-executor/dispatch-common.mjs +164 -0
  250. package/scripts/lib/wave-executor/foreign-dispatch.mjs +7 -142
  251. package/scripts/lib/wave-executor/remote-dispatch.mjs +5 -7
  252. package/scripts/lib/wave-resource-gate.mjs +8 -2
  253. package/scripts/lib/wave-sizing.mjs +4 -1
  254. package/scripts/lib/wave-transcript-tail.mjs +118 -4
  255. package/scripts/materialize-wave-scope.mjs +12 -5
  256. package/scripts/memory-propose.mjs +19 -5
  257. package/scripts/migrate-cold-start-seed.mjs +4 -1
  258. package/scripts/parse-config.mjs +60 -3
  259. package/scripts/release.mjs +337 -29
  260. package/scripts/repair-invalid-sessions.mjs +3 -3
  261. package/scripts/run-quality-gate.mjs +128 -11
  262. package/scripts/sweep-expired-learnings.mjs +90 -0
  263. package/scripts/sync-vault-schema.mjs +3 -1
  264. package/scripts/telemetry.mjs +2 -2
  265. package/scripts/validate-plugin.mjs +161 -0
  266. package/scripts/validate-wave-scope.mjs +28 -8
  267. package/scripts/wave-scope-binding.mjs +215 -0
  268. package/skills/_shared/instruction-file-resolution.md +10 -0
  269. package/skills/_shared/parallel-aware-preamble.md +1 -0
  270. package/skills/_shared/platform-tools.md +1 -1
  271. package/skills/_shared/state-ownership.md +1 -1
  272. package/skills/architecture/SKILL.md +7 -5
  273. package/skills/{domain-model/SKILL.md → architecture/references/domain-model.md} +9 -9
  274. package/skills/autopilot/SKILL.md +4 -18
  275. package/skills/claude-md-drift-check/SKILL.md +5 -1
  276. package/skills/claude-md-drift-check/checker.mjs +62 -2
  277. package/skills/convergence-monitoring/SIGNALS.md +55 -0
  278. package/skills/discovery/probes/vault-staleness.mjs +37 -13
  279. package/skills/discovery/probes-arch.md +20 -18
  280. package/skills/dispatcher/SKILL.md +3 -2
  281. package/skills/evolve/SKILL.md +65 -26
  282. package/skills/frontmatter-guard/SKILL.md +11 -5
  283. package/skills/npm-publish/SKILL.md +1 -1
  284. package/skills/reconcile/SKILL.md +33 -0
  285. package/skills/remote-offload/SKILL.md +1 -1
  286. package/skills/session-end/SKILL.md +18 -905
  287. package/skills/session-end/phase-3-6-tail.md +10 -3
  288. package/skills/session-end/plan-verification.md +221 -155
  289. package/skills/session-end/references/phase-2-quality-gate.md +93 -0
  290. package/skills/session-end/references/phase-3-documentation-updates.md +229 -0
  291. package/skills/session-end/references/phase-4a-worktree-cleanup.md +120 -0
  292. package/skills/session-end/references/phase-4b-worktree-orphan-sweep.md +58 -0
  293. package/skills/session-end/references/phase-5-issue-cleanup.md +104 -0
  294. package/skills/session-end/references/session-summary-template.md +62 -0
  295. package/skills/session-plan/SKILL.md +49 -0
  296. package/skills/session-start/SKILL.md +22 -904
  297. package/skills/session-start/phase-8-5-express-path.md +1 -1
  298. package/skills/session-start/references/phase-1-1-dispatcher-autonomy-capture.md +55 -0
  299. package/skills/session-start/references/phase-1-2-session-lock.md +140 -0
  300. package/skills/session-start/references/phase-1-5-session-continuity.md +254 -0
  301. package/skills/session-start/references/phase-1-7-vault-status-board.md +53 -0
  302. package/skills/session-start/references/phase-2-7-portfolio-snapshot.md +75 -0
  303. package/skills/session-start/references/phase-4-ssot-environment-check.md +155 -0
  304. package/skills/session-start/references/phase-6-5-forced-reads.md +75 -0
  305. package/skills/session-start/references/phase-6-6-project-intelligence.md +81 -0
  306. package/skills/session-start/references/phase-6-7-memory-banner-telemetry-consent.md +103 -0
  307. package/skills/vault-sync/validator.mjs +21 -27
  308. package/skills/wave-executor/SKILL.md +15 -1
  309. package/skills/wave-executor/references/wave-loop-dispatch.md +612 -0
  310. package/skills/wave-executor/references/wave-loop-review.md +570 -0
  311. package/skills/wave-executor/references/wave-loop-scope-manifest.md +162 -0
  312. package/skills/wave-executor/wave-loop.md +14 -1309
  313. package/templates/_shared/journey-manifest.md +10 -6
  314. package/.cursor/commands/autopilot-multi.md +0 -14
  315. package/.cursor/commands/contract-version-bump.md +0 -14
  316. package/.cursor/commands/journey-audit.md +0 -14
  317. package/.cursor/skills/contract-version-bump/SKILL.md +0 -12
  318. package/.cursor/skills/daily/SKILL.md +0 -12
  319. package/.cursor/skills/domain-model/SKILL.md +0 -13
  320. package/.cursor/skills/journey-audit/SKILL.md +0 -13
  321. package/.cursor/skills/skill-creator/SKILL.md +0 -13
  322. package/.cursor/skills/ubiquitous-language/SKILL.md +0 -13
  323. package/commands/autopilot-multi.md +0 -74
  324. package/commands/contract-version-bump.md +0 -28
  325. package/commands/journey-audit.md +0 -43
  326. package/pi/prompts/autopilot-multi.md +0 -12
  327. package/pi/prompts/contract-version-bump.md +0 -12
  328. package/pi/prompts/journey-audit.md +0 -12
  329. package/scripts/autopilot-multi.mjs +0 -885
  330. package/scripts/backfill-learnings-expires.mjs +0 -196
  331. package/scripts/backfill-learnings.mjs +0 -203
  332. package/scripts/fleet-instruction-scan.mjs +0 -141
  333. package/scripts/lib/autopilot/dep-graph.mjs +0 -417
  334. package/scripts/lib/autopilot/multi-killswitch.mjs +0 -184
  335. package/scripts/lib/webhook-url.mjs +0 -105
  336. package/scripts/lifecycle-sim-v6.mjs +0 -347
  337. package/scripts/migrate-learnings-jsonl.mjs +0 -189
  338. package/scripts/migrate-subagents-jsonl.mjs +0 -196
  339. package/scripts/upload-social-preview.mjs +0 -316
  340. package/skills/_shared/model-selection.md +0 -64
  341. package/skills/contract-version-bump/SKILL.md +0 -219
  342. package/skills/daily/SKILL.md +0 -222
  343. package/skills/daily/generate.sh +0 -92
  344. package/skills/daily/templates/daily.md.tpl +0 -36
  345. package/skills/journey-audit/SKILL.md +0 -270
  346. package/skills/skill-creator/SKILL.md +0 -168
  347. package/skills/ubiquitous-language/SKILL.md +0 -97
  348. package/skills/vault-sync/package-lock.json +0 -40
  349. /package/skills/{domain-model → architecture/references}/ADR-FORMAT.md +0 -0
  350. /package/skills/{domain-model → architecture/references}/CONTEXT-FORMAT.md +0 -0
@@ -0,0 +1,406 @@
1
+ /**
2
+ * plugin-update-banner.mjs — "is the plugin that is RUNNING behind the plugin
3
+ * that is PUBLISHED?" (#nnn, d7 R2).
4
+ *
5
+ * ── The gap this closes (measured 2026-09-06) ────────────────────────────────
6
+ * The operator's host ran the marketplace-cache copy at **3.19.0**, installed
7
+ * 2026-08-09, while the repo and npm stood at the **3.24** line — five minors, four
8
+ * weeks, and not one warning. Three independent reasons, none of which is a
9
+ * bug in isolation:
10
+ *
11
+ * 1. session-start Phase 4's plugin-freshness probe shells out to
12
+ * `git -C <plugin-dir> log -1`. A marketplace cache is a FILE COPY, so it
13
+ * answers `fatal: not a git repository` (and 28 d < the 30 d threshold
14
+ * anyway).
15
+ * 2. `classifyVersionMismatch()` in `bootstrap-lock-freshness.mjs` downgrades
16
+ * everything below a MAJOR jump to `info` — five minors is "no action
17
+ * required" BY DESIGN.
18
+ * 3. `bootstrap.lock`'s `refreshed-plugin-version` was stamped from the
19
+ * CHECKOUT's package.json while 3.19.0 was the code actually loaded.
20
+ *
21
+ * All three share one root: **no code anywhere compared installed against
22
+ * available.** Measured the same day —
23
+ * `grep -rln "registry.npmjs.org\|dist-tags" scripts/ hooks/ skills/` returned
24
+ * exactly one file, `scripts/release.mjs`, which is the PUBLISHER.
25
+ *
26
+ * ── Two contract rules that are load-bearing ─────────────────────────────────
27
+ * **Fail silent, never optimistic (#1031).** Offline, non-2xx, malformed JSON,
28
+ * timeout, unusable cache — every one of them returns `null`, which means "no
29
+ * statement". None of them may collapse into "you are up to date": five
30
+ * distinct failure states rendering as one all-clear is the exact defect #1031
31
+ * names, and here it would re-create the four silent weeks above.
32
+ *
33
+ * **Installed = the code that is RUNNING, not the checkout.** `pluginRoot`
34
+ * defaults to this module's own package root (`../..` from `scripts/lib/`), so
35
+ * the answer cannot disagree with the loaded bytes. Reading
36
+ * `$CLAUDE_PLUGIN_ROOT` instead is what produced reason (3) above: it resolved
37
+ * to the checkout while the cache copy was executing.
38
+ *
39
+ * @module scripts/lib/plugin-update-banner
40
+ */
41
+
42
+ import { readFileSync } from 'node:fs';
43
+ import { join, resolve } from 'node:path';
44
+
45
+ import { writeJsonAtomicSync } from './io.mjs';
46
+ import { readPluginVersionFromPackageJson } from './bootstrap-lock-freshness.mjs';
47
+
48
+ /** npm registry document for the `latest` dist-tag of this package. */
49
+ export const REGISTRY_URL = 'https://registry.npmjs.org/session-orchestrator/latest';
50
+
51
+ /** Filename of the per-repo latest-version cache inside `cacheDir`. */
52
+ export const CACHE_FILENAME = 'plugin-latest.json';
53
+
54
+ /** Cache lifetime: one fetch per day per repo, at most. */
55
+ export const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
56
+
57
+ /** Registry request budget. A session start must never wait on the network. */
58
+ export const FETCH_TIMEOUT_MS = 2000;
59
+
60
+ /**
61
+ * Env kill-switches, in the order they are checked.
62
+ *
63
+ * `SO_DISABLE_UPDATE_CHECK` is this probe's own switch; the other two are the
64
+ * standard offline flags the telemetry path already honours
65
+ * (`scripts/lib/telemetry/consent.mjs`, `.claude/rules/cross-session-messaging.md`
66
+ * § CSM-005). This probe talks to a PUBLIC REGISTRY on session start, so it has
67
+ * to be at least as easy to silence as telemetry is — an operator who set
68
+ * `DO_NOT_TRACK` did not consent to a per-session npm request either.
69
+ */
70
+ export const KILL_SWITCH_ENV_KEYS = [
71
+ 'SO_DISABLE_UPDATE_CHECK',
72
+ 'DO_NOT_TRACK',
73
+ 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
74
+ ];
75
+
76
+ /**
77
+ * Truthiness for an env kill-switch: set, non-empty, not `0`, not `false`.
78
+ *
79
+ * Deliberately a local four-liner rather than an import: the equivalent in
80
+ * `telemetry/consent.mjs` is module-private (not exported), and widening its
81
+ * visibility to reuse four lines would couple this probe to the telemetry
82
+ * module's internals for no gain.
83
+ *
84
+ * @param {unknown} raw
85
+ * @returns {boolean}
86
+ */
87
+ function isTruthyFlag(raw) {
88
+ if (raw === undefined || raw === null) return false;
89
+ const t = String(raw).trim();
90
+ if (t === '' || t === '0') return false;
91
+ return t.toLowerCase() !== 'false';
92
+ }
93
+
94
+ /**
95
+ * @param {Record<string, string|undefined>} env
96
+ * @returns {string|null} the name of the first kill-switch that is set, else null.
97
+ */
98
+ function firstActiveKillSwitch(env) {
99
+ for (const key of KILL_SWITCH_ENV_KEYS) {
100
+ if (isTruthyFlag(env?.[key])) return key;
101
+ }
102
+ return null;
103
+ }
104
+
105
+ /**
106
+ * Is the update check switched off for this process?
107
+ *
108
+ * Exported because the SessionStart hook gates the `plugin_version_latest`
109
+ * event field on it as well: one switch, one meaning. An operator who sets
110
+ * `SO_DISABLE_UPDATE_CHECK=1` has turned the FEATURE off, not merely the
111
+ * request — reading a leftover cache file into the session record afterwards
112
+ * would be the same surprise as a "disabled" telemetry path that still writes.
113
+ *
114
+ * @param {Record<string,string|undefined>} [env]
115
+ * @returns {boolean}
116
+ */
117
+ export function isUpdateCheckDisabled(env = process.env) {
118
+ return firstActiveKillSwitch(env) !== null;
119
+ }
120
+
121
+ /**
122
+ * Parse a semver-ish string into `[major, minor, patch]`.
123
+ * @param {unknown} v
124
+ * @returns {[number, number, number]|null} null when unparseable.
125
+ */
126
+ function parseSemver(v) {
127
+ if (typeof v !== 'string') return null;
128
+ const m = v.match(/^(\d+)\.(\d+)\.(\d+)/);
129
+ if (!m) return null;
130
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
131
+ }
132
+
133
+ /**
134
+ * The default `pluginRoot`: the package root of the module that is executing.
135
+ *
136
+ * `scripts/lib/plugin-update-banner.mjs` → `../..`. See the module docstring
137
+ * for why this is NOT `$CLAUDE_PLUGIN_ROOT`.
138
+ *
139
+ * @returns {string}
140
+ */
141
+ function ownPluginRoot() {
142
+ return resolve(import.meta.dirname, '..', '..');
143
+ }
144
+
145
+ /**
146
+ * The version of the plugin that is RUNNING.
147
+ *
148
+ * Exported so the SessionStart hook can stamp `plugin_version_installed` on
149
+ * every `orchestrator.session.started` record without re-deriving "which root
150
+ * is the running one" — the question reason (3) in the module docstring got
151
+ * wrong. Delegates to `bootstrap-lock-freshness.mjs`'s reader rather than
152
+ * opening package.json a second time.
153
+ *
154
+ * @param {string} [pluginRoot] Override for tests / callers that already know
155
+ * a specific install root.
156
+ * @returns {string|null} null when package.json is absent or unparseable.
157
+ */
158
+ export function readInstalledPluginVersion(pluginRoot) {
159
+ const root = typeof pluginRoot === 'string' && pluginRoot.length > 0 ? pluginRoot : ownPluginRoot();
160
+ return readPluginVersionFromPackageJson(root);
161
+ }
162
+
163
+ /**
164
+ * Read the cached latest-version record, honouring the 24 h TTL.
165
+ *
166
+ * Exported because the SessionStart hook needs the resolved `latest` for the
167
+ * `orchestrator.session.started` event even on the runs where
168
+ * {@link checkPluginUpdate} returns `null` — most importantly the
169
+ * installed-equals-latest run, which is the DENOMINATOR of the probe's firing
170
+ * rate (`.claude/rules/host-resources.md` HR-105). A verdict whose non-firing
171
+ * case leaves no record is unfalsifiable, which is how the old rule set fired
172
+ * on 99% of starts for four months undetected.
173
+ *
174
+ * @param {{cacheDir?: string, now?: number}} [opts]
175
+ * @returns {{version: string, fetchedAt: string}|null} null when absent, stale,
176
+ * unreadable, or malformed — never throws.
177
+ */
178
+ export function readCachedLatest({ cacheDir, now = Date.now() } = {}) {
179
+ if (typeof cacheDir !== 'string' || cacheDir.length === 0) return null;
180
+ try {
181
+ const parsed = JSON.parse(readFileSync(join(cacheDir, CACHE_FILENAME), 'utf8'));
182
+ if (typeof parsed?.version !== 'string' || typeof parsed?.fetched_at !== 'string') return null;
183
+ const fetchedMs = Date.parse(parsed.fetched_at);
184
+ if (Number.isNaN(fetchedMs)) return null;
185
+ // A future-dated stamp (clock skew, hand-edited file) is treated as stale
186
+ // rather than trusted forever: `now - fetchedMs` would be negative and pass
187
+ // the TTL test for as long as the skew lasts.
188
+ const ageMs = now - fetchedMs;
189
+ if (ageMs < 0 || ageMs >= CACHE_TTL_MS) return null;
190
+ return { version: parsed.version, fetchedAt: parsed.fetched_at };
191
+ } catch {
192
+ return null;
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Resolve the published `latest` version: fresh cache first, network second.
198
+ *
199
+ * @param {{cacheDir: string, now: number, fetchImpl: Function}} opts
200
+ * @returns {Promise<string|null>} null on ANY failure (see the module docstring).
201
+ */
202
+ async function resolveLatestVersion({ cacheDir, now, fetchImpl }) {
203
+ const cached = readCachedLatest({ cacheDir, now });
204
+ if (cached !== null) return cached.version;
205
+
206
+ // Declared without an initialiser on purpose: every path out of the try
207
+ // either returns or assigns, so a `= null` seed would be dead (no-useless-assignment).
208
+ let version;
209
+ try {
210
+ const res = await fetchImpl(REGISTRY_URL, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
211
+ if (!res || res.ok !== true) return null;
212
+ const body = await res.json();
213
+ if (typeof body?.version !== 'string' || body.version.length === 0) return null;
214
+ version = body.version;
215
+ } catch {
216
+ // Offline, DNS failure, timeout, malformed JSON — all indistinguishable
217
+ // here and all equally NOT a statement about freshness. Nothing is written:
218
+ // a failed fetch must never leave a cache entry a later run would read as
219
+ // an answer (#1031).
220
+ return null;
221
+ }
222
+
223
+ // Best-effort persistence. A write failure costs one extra request next
224
+ // session; it must not discard a verdict we already measured correctly.
225
+ writeJsonAtomicSync(
226
+ join(cacheDir, CACHE_FILENAME),
227
+ { version, fetched_at: new Date(now).toISOString() },
228
+ { tmpPrefix: '.plugin-latest.tmp' },
229
+ );
230
+
231
+ return version;
232
+ }
233
+
234
+ /**
235
+ * The four platforms this probe has a SOURCED update recipe for. Each string
236
+ * is quoted from the doc that actually documents it, never invented:
237
+ *
238
+ * - claude: README.md §"Upgrade" (`/plugin update ...`).
239
+ * - codex: docs/codex-setup.md §"Refresh and Explicit Cache Invalidation" —
240
+ * the short-form marketplace install path, labelled Recommended
241
+ * there (`codex plugin marketplace upgrade` + `codex plugin add`).
242
+ * - cursor: README.md's per-platform install row + docs/cursor-setup.md —
243
+ * re-running the same install script is the documented recipe
244
+ * (README §"Upgrade": "...followed by the same install
245
+ * script you originally ran").
246
+ * - pi: the PRIMARY install path is `pi install npm:session-orchestrator`
247
+ * (docs/pi-setup.md §"Option 1", `docs/pi-setup.md:16`), not the
248
+ * checkout — so the instruction leads with re-running that exact
249
+ * documented command. There is still no separately-documented
250
+ * `pi update`/upgrade subcommand anywhere in this repo (checked
251
+ * again here), so this is a repeat of the documented INSTALL
252
+ * command, never an invented one — same honesty bar as the rest
253
+ * of this table. `--settings-only` only rewrites
254
+ * `.pi/settings.json`; it never touches an npm-installed copy, so
255
+ * the checkout + `pi-install.mjs --settings-only` recipe (README
256
+ * §"Upgrade", docs/pi-setup.md §"Option 2/3") is named only as the
257
+ * fallback for a dev-fallback registration, never as the primary
258
+ * remedy.
259
+ *
260
+ * @type {Record<"claude"|"codex"|"cursor"|"pi", string>}
261
+ */
262
+ const PLATFORM_UPDATE_INSTRUCTIONS = {
263
+ claude: '/plugin update session-orchestrator@kanevry, then restart Claude Code',
264
+ codex:
265
+ 'run `codex plugin marketplace upgrade kanevry && codex plugin add session-orchestrator@kanevry` ' +
266
+ '(docs/codex-setup.md), then restart Codex',
267
+ cursor:
268
+ 'run `git pull && npm install && node scripts/cursor-install.mjs <project-dir>` ' +
269
+ '(docs/cursor-setup.md), then reload Cursor',
270
+ pi:
271
+ 'run `pi install npm:session-orchestrator` again (docs/pi-setup.md, the primary path); ' +
272
+ 'dev-fallback checkout installs instead run ' +
273
+ '`git pull && npm install && node scripts/pi-install.mjs <project-dir> --settings-only`, ' +
274
+ 'then restart Pi',
275
+ };
276
+
277
+ /**
278
+ * Fallback instruction for any platform value this probe has no sourced
279
+ * recipe for — a future harness, a malformed override, or (see
280
+ * {@link resolvePlatformFromEnv}) an environment with no platform signal at
281
+ * all reaching a code path other than the default. Never omits an actionable
282
+ * step: the one universal truth about this package is that it publishes to
283
+ * npm (`docs/npm-publish` runbook), so that is what the fallback names.
284
+ * @type {string}
285
+ */
286
+ const GENERIC_UPDATE_INSTRUCTION = 'run `npm update -g session-orchestrator`';
287
+
288
+ /**
289
+ * Best-effort platform guess from environment variables alone — the first
290
+ * two precedence steps of `scripts/lib/platform.mjs`'s `detectPlatform()`
291
+ * (an explicit `SO_PLATFORM` override, then each harness's own compatibility
292
+ * env var), duplicated locally rather than imported.
293
+ *
294
+ * Deliberately NOT an import: this module sits behind the SessionStart
295
+ * hook's own lazy `await import()`, and `hooks/_lib/hook-import-set.json`
296
+ * tracks its closure — adding platform.mjs as a dependency here is a
297
+ * wave-scope change, not a two-line addition, so the fallback stays local
298
+ * exactly like `isTruthyFlag` above (same rationale, same precedent).
299
+ * `detectPlatform()`'s third step — a filesystem walk from `cwd` for marker
300
+ * directories — is left out on purpose: it needs `node:fs`/`node:path`
301
+ * traversal this probe has no other reason to carry, and every non-Claude
302
+ * harness already sets its own compat env var, so the walk would only ever
303
+ * re-confirm the same `'claude'` default this function already falls back to.
304
+ *
305
+ * @param {Record<string,string|undefined>} env
306
+ * @returns {"claude"|"codex"|"cursor"|"pi"}
307
+ */
308
+ function resolvePlatformFromEnv(env) {
309
+ const explicit = String(env?.SO_PLATFORM ?? '').trim().toLowerCase();
310
+ if (explicit === 'claude' || explicit === 'codex' || explicit === 'cursor' || explicit === 'pi') {
311
+ return explicit;
312
+ }
313
+ if (String(env?.CLAUDE_PLUGIN_ROOT ?? '').trim() !== '') return 'claude';
314
+ if (String(env?.CODEX_PLUGIN_ROOT ?? '').trim() !== '') return 'codex';
315
+ if (String(env?.CURSOR_RULES_DIR ?? '').trim() !== '') return 'cursor';
316
+ if (String(env?.PI_PLUGIN_ROOT ?? '').trim() !== '') return 'pi';
317
+ return 'claude';
318
+ }
319
+
320
+ /**
321
+ * Compare the running plugin version against the published `latest` and, when
322
+ * it is a MINOR or MAJOR behind, produce one operator-facing banner line.
323
+ *
324
+ * Patch-only drift is deliberately silent: a patch is by definition a fix with
325
+ * no surface change, and a banner that fires on every patch release is a
326
+ * banner an operator learns to skip (`.claude/rules/host-resources.md` HR-101 —
327
+ * a signal may only warn if it is rare).
328
+ *
329
+ * The message is English by default (this package's docs — README, CHANGELOG
330
+ * — are English; German is an `owner.yaml` per-operator tonality signal, not a
331
+ * package default, and this probe reaches third-party npm/marketplace
332
+ * consumers who never opted into either) and its remedy instruction is
333
+ * platform-aware: a Codex/Cursor/Pi consumer has no `/plugin update` command,
334
+ * so the CLAUDE-only remedy text is wrong for three of the four harnesses
335
+ * that reach this probe.
336
+ *
337
+ * @param {object} [opts]
338
+ * @param {string} [opts.pluginRoot] Package root to read `version` from.
339
+ * Defaults to the RUNNING module's own root — see the module docstring.
340
+ * @param {string} [opts.cacheDir] Directory holding `plugin-latest.json`,
341
+ * normally `<repoRoot>/.orchestrator/runtime`. Absent → `null`, no request:
342
+ * without a cache this probe would issue one npm request per session start.
343
+ * @param {Record<string,string|undefined>} [opts.env] Defaults to `process.env`.
344
+ * @param {number} [opts.now] Injected clock (ms). Defaults to `Date.now()`.
345
+ * @param {Function} [opts.fetchImpl] Injected fetch. Defaults to `globalThis.fetch`.
346
+ * @param {"claude"|"codex"|"cursor"|"pi"} [opts.platform] The harness driving
347
+ * this session, when the caller already knows it (the SessionStart hook
348
+ * computes this once and could pass it straight through). Falls back to
349
+ * {@link resolvePlatformFromEnv} on `opts.env` when omitted — never throws,
350
+ * never leaves the instruction generic just because the caller didn't wire
351
+ * the parameter through yet.
352
+ * @returns {Promise<{severity: 'warn', message: string, installed: string, latest: string}|null>}
353
+ * `null` means NO STATEMENT — never "up to date".
354
+ */
355
+ export async function checkPluginUpdate({
356
+ pluginRoot,
357
+ cacheDir,
358
+ env = process.env,
359
+ now = Date.now(),
360
+ fetchImpl,
361
+ platform,
362
+ } = {}) {
363
+ if (isUpdateCheckDisabled(env)) return null;
364
+ if (typeof cacheDir !== 'string' || cacheDir.length === 0) return null;
365
+
366
+ const installed = readInstalledPluginVersion(pluginRoot);
367
+ const installedParts = parseSemver(installed);
368
+ if (installedParts === null) return null;
369
+
370
+ const fetcher = typeof fetchImpl === 'function' ? fetchImpl : globalThis.fetch;
371
+ if (typeof fetcher !== 'function') return null;
372
+
373
+ const latest = await resolveLatestVersion({ cacheDir, now, fetchImpl: fetcher });
374
+ const latestParts = parseSemver(latest);
375
+ if (latestParts === null) return null;
376
+
377
+ const [instMajor, instMinor] = installedParts;
378
+ const [latestMajor, latestMinor] = latestParts;
379
+
380
+ let count;
381
+ let unit;
382
+ if (latestMajor > instMajor) {
383
+ count = latestMajor - instMajor;
384
+ unit = count === 1 ? 'Major' : 'Majors';
385
+ } else if (latestMajor === instMajor && latestMinor > instMinor) {
386
+ count = latestMinor - instMinor;
387
+ unit = count === 1 ? 'Minor' : 'Minors';
388
+ } else {
389
+ // Equal, patch-only drift, or the installed build is AHEAD of the registry
390
+ // (a local checkout between releases — the normal state in this repo).
391
+ return null;
392
+ }
393
+
394
+ const resolvedPlatform =
395
+ typeof platform === 'string' && platform.length > 0 ? platform : resolvePlatformFromEnv(env);
396
+ const instruction = PLATFORM_UPDATE_INSTRUCTIONS[resolvedPlatform] ?? GENERIC_UPDATE_INSTRUCTION;
397
+
398
+ return {
399
+ severity: 'warn',
400
+ message:
401
+ `⚠ session-orchestrator ${installed} installed, ${latest} available ` +
402
+ `(${count} ${unit} behind) — ${instruction}.`,
403
+ installed,
404
+ latest,
405
+ };
406
+ }
@@ -122,6 +122,42 @@ export function checkReleaseHygiene(repoRoot, driftCommits = DEFAULT_RELEASE_DRI
122
122
  };
123
123
  }
124
124
 
125
+ /**
126
+ * The plugin's own runtime output under `.orchestrator/` is not a stray.
127
+ *
128
+ * Measured on a fresh consumer repo: the very first `SessionStart` hook writes
129
+ * `.orchestrator/` state, and the banner rendered on that same run accused the
130
+ * operator of 5 files "neither tracked nor ignored" — all 5 written seconds
131
+ * earlier by the tool that was complaining (`find <consumer>/.orchestrator -type f`
132
+ * returned exactly the 5 reported files). A first-run accusation about the
133
+ * tool's own output is the single loudest false positive this probe can emit.
134
+ *
135
+ * Excluding here rather than having bootstrap append a `.gitignore` line is
136
+ * deliberate, and it does NOT weaken the check for genuine strays:
137
+ *
138
+ * - `.orchestrator/` is only PARTLY ignorable. `.orchestrator/policy/*.json`
139
+ * and `.orchestrator/steering/*.md` are meant to be TRACKED — `checkStaleArtifacts`
140
+ * below depends on exactly that. A blanket ignore line would tell git to
141
+ * ignore files the plugin wants versioned, which is a worse defect than the
142
+ * one it fixes, and it would mutate a consumer repo uninvited.
143
+ * - Untracked mass under `.orchestrator/` is already covered, by a check built
144
+ * for it: `checkStaleArtifacts` (H3) counts untracked AGED files there with
145
+ * its own retained-artifact carve-outs. Nothing goes unwatched.
146
+ * - Everything outside `.orchestrator/` is untouched, which is where a real
147
+ * "nothing currently decides" stray lives.
148
+ *
149
+ * Prefix match on the porcelain path. `git status --porcelain` quotes a path only
150
+ * when it carries special characters, so the optional leading quote is stripped
151
+ * before comparing.
152
+ *
153
+ * @param {string} porcelainPath path field of a `?? ` porcelain line
154
+ * @returns {boolean}
155
+ */
156
+ function isOwnRuntimeArtifact(porcelainPath) {
157
+ const p = porcelainPath.replace(/^"/, '');
158
+ return p === '.orchestrator' || p.startsWith('.orchestrator/');
159
+ }
160
+
125
161
  /**
126
162
  * H2 — Ignored working-tree ballast.
127
163
  *
@@ -150,7 +186,7 @@ export function checkIgnoredBallast(repoRoot, ballastMb = DEFAULT_BALLAST_MB) {
150
186
  let untrackedUnignored = 0;
151
187
  for (const line of ignored.split('\n').filter(Boolean)) {
152
188
  if (line.startsWith('!! ')) ignoredPaths.push(line.slice(3));
153
- else if (line.startsWith('?? ')) untrackedUnignored++;
189
+ else if (line.startsWith('?? ') && !isOwnRuntimeArtifact(line.slice(3))) untrackedUnignored++;
154
190
  }
155
191
 
156
192
  // Size only the top-level ignored entries — recursing every path would cost
@@ -183,7 +219,7 @@ export function checkIgnoredBallast(repoRoot, ballastMb = DEFAULT_BALLAST_MB) {
183
219
  findings.push({
184
220
  check: 'untracked-unignored',
185
221
  fixable: false,
186
- message: `${untrackedUnignored} file(s) are neither tracked nor ignored — either commit them or add them to .gitignore, since nothing currently decides`,
222
+ message: `${untrackedUnignored} file(s) are neither tracked nor ignored (excluding the plugin's own .orchestrator/ runtime output) — either commit them or add them to .gitignore, since nothing currently decides`,
187
223
  });
188
224
  }
189
225
 
@@ -9,21 +9,31 @@
9
9
  * Used by session-start Phase 4 alongside other freshness/drift probes.
10
10
  *
11
11
  * Config-read path: this module reads Session Config exclusively via
12
- * `loadCommandsFromSessionConfig` from `./quality-gate.mjs`. That helper
13
- * returns a partial object (missing keys are absent — no default substitution),
14
- * which means absent `*-command` keys cannot trigger spurious drift against
15
- * `PROJECT_DEFAULTS`. Drift is only reported when a value is explicitly set
16
- * AND differs from the corresponding `PROJECT_DEFAULTS` entry.
12
+ * `loadCommandsFromSessionConfigDetailed` from `./quality-gate.mjs`. Its
13
+ * `commands` half is a partial object (missing keys are absent — no default
14
+ * substitution), which means absent `*-command` keys cannot trigger spurious
15
+ * drift against `PROJECT_DEFAULTS`. Drift is only reported when a value is
16
+ * explicitly set AND differs from the corresponding `PROJECT_DEFAULTS` entry.
17
+ *
18
+ * THREE return states (#1031 follow-up), not two — the same contract
19
+ * `ci-status-banner.mjs` established:
20
+ * - `null` — the config was READ and nothing drifts
21
+ * (including "this repo has no Session
22
+ * Config at all").
23
+ * - `{severity:'warn', message}` — real drift, one line per deviating key.
24
+ * - `{severity:'warn', message, degraded}` — the config could NOT be read.
25
+ * Previously this collapsed onto `null`, i.e. onto "no drift" — an
26
+ * all-clear derived from a measurement that never happened.
17
27
  *
18
28
  * Cross-references:
19
29
  * - .claude/rules/quality-gates-autofix.md § Session Config Command Injection
20
- * - scripts/lib/quality-gate.mjs `loadCommandsFromSessionConfig()`
30
+ * - scripts/lib/quality-gate.mjs `loadCommandsFromSessionConfigDetailed()`
21
31
  *
22
32
  * @see #525 (Pattern 4 Auto-Fix-Loop residuals)
23
33
  * @see #526 (Pattern 4 banner ecosystem coherence)
24
34
  */
25
35
 
26
- import { loadCommandsFromSessionConfig } from './quality-gate.mjs';
36
+ import { loadCommandsFromSessionConfigDetailed } from './quality-gate.mjs';
27
37
 
28
38
  /**
29
39
  * Default *-command values for the session-orchestrator plugin.
@@ -37,11 +47,28 @@ export const PROJECT_DEFAULTS = Object.freeze({
37
47
  test: 'npm test',
38
48
  });
39
49
 
50
+ /**
51
+ * Build the third return state: the config could not be read.
52
+ *
53
+ * Distinct from `null` on purpose — `null` in the banner contract reads as
54
+ * "checked, all clear", which a failed read has not established.
55
+ *
56
+ * @param {string} reason — a `CONFIG_READ_DEGRADED_REASONS` member.
57
+ * @returns {{severity: 'warn', message: string, degraded: string}}
58
+ */
59
+ function degradedBanner(reason) {
60
+ return {
61
+ severity: 'warn',
62
+ message: `\u26a0 Session Config command drift could not be checked (${reason})`,
63
+ degraded: reason,
64
+ };
65
+ }
66
+
40
67
  /**
41
68
  * Check for *-command drift.
42
69
  *
43
70
  * Reads Session Config from CLAUDE.md (or AGENTS.md) in `repoRoot` via
44
- * `loadCommandsFromSessionConfig` and compares any resolved `lint`,
71
+ * `loadCommandsFromSessionConfigDetailed` and compares any resolved `lint`,
45
72
  * `typecheck`, and `test` values against PROJECT_DEFAULTS. Returns null
46
73
  * when no drift is detected, or when the config-read returns no recognised
47
74
  * `*-command` keys at all (graceful no-op).
@@ -49,22 +76,33 @@ export const PROJECT_DEFAULTS = Object.freeze({
49
76
  * Important: missing keys do NOT trigger drift. Only explicit overrides
50
77
  * that differ from PROJECT_DEFAULTS produce a banner.
51
78
  *
79
+ * A FAILED read (`degraded`) is reported rather than silently treated as
80
+ * "no drift" — see the module header. A repo that simply carries no Session
81
+ * Config is NOT a failed read and stays silent.
82
+ *
52
83
  * Marked `async` for backward compatibility with existing callers that
53
84
  * `await` the result; internally the helper is synchronous.
54
85
  *
55
86
  * @param {object} [opts]
56
87
  * @param {string} [opts.repoRoot] — project root (defaults to process.cwd()).
57
- * @returns {Promise<null | {severity: 'warn', message: string}>}
88
+ * @returns {Promise<null | {severity: 'warn', message: string, degraded?: string}>}
58
89
  */
59
90
  export async function checkQgCommandDrift(opts = {}) {
60
91
  const repoRoot = opts.repoRoot ?? process.cwd();
61
92
 
62
- let cfg;
93
+ let detailed;
63
94
  try {
64
- cfg = loadCommandsFromSessionConfig(repoRoot);
95
+ detailed = loadCommandsFromSessionConfigDetailed(repoRoot);
65
96
  } catch {
66
- return null; // graceful no-op on config load failure
97
+ // The loader is documented never to throw. If it does anyway, that IS a
98
+ // failed read — say so rather than reporting an all-clear.
99
+ return degradedBanner('parse-error');
100
+ }
101
+ if (!detailed || typeof detailed !== 'object') return degradedBanner('parse-error');
102
+ if (typeof detailed.degraded === 'string' && detailed.degraded) {
103
+ return degradedBanner(detailed.degraded);
67
104
  }
105
+ const cfg = detailed.commands;
68
106
  if (!cfg || typeof cfg !== 'object') return null;
69
107
 
70
108
  const drifts = [];