session-orchestrator 3.22.0 → 3.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (268) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/commands/autopilot-multi.md +14 -0
  5. package/.cursor/commands/autopilot.md +14 -0
  6. package/.cursor/commands/bootstrap.md +14 -0
  7. package/.cursor/commands/brainstorm.md +14 -0
  8. package/.cursor/commands/close.md +13 -0
  9. package/.cursor/commands/contract-version-bump.md +14 -0
  10. package/.cursor/commands/debug.md +14 -0
  11. package/.cursor/commands/discovery.md +14 -0
  12. package/.cursor/commands/dispatcher.md +14 -0
  13. package/.cursor/commands/eli5.md +14 -0
  14. package/.cursor/commands/eval.md +14 -0
  15. package/.cursor/commands/evolve.md +14 -0
  16. package/.cursor/commands/go.md +14 -0
  17. package/.cursor/commands/grill.md +14 -0
  18. package/.cursor/commands/harness-audit.md +13 -0
  19. package/.cursor/commands/journey-audit.md +14 -0
  20. package/.cursor/commands/memory-cleanup.md +14 -0
  21. package/.cursor/commands/persona-panel.md +14 -0
  22. package/.cursor/commands/plan.md +14 -0
  23. package/.cursor/commands/portfolio.md +14 -0
  24. package/.cursor/commands/reconcile.md +14 -0
  25. package/.cursor/commands/release.md +14 -0
  26. package/.cursor/commands/repo-audit.md +13 -0
  27. package/.cursor/commands/session.md +14 -0
  28. package/.cursor/commands/spinout.md +14 -0
  29. package/.cursor/commands/sunset-review.md +14 -0
  30. package/.cursor/commands/templates-ack.md +14 -0
  31. package/.cursor/commands/test.md +14 -0
  32. package/.cursor/hooks.json +60 -0
  33. package/.cursor/rules/000-session-orchestrator.mdc +8 -0
  34. package/.cursor/rules/010-session-workflow.mdc +9 -1
  35. package/.cursor/rules/020-quality-gates.mdc +1 -1
  36. package/.cursor/rules/030-wave-execution.mdc +1 -1
  37. package/.cursor/rules/050-plan.mdc +2 -2
  38. package/.cursor/rules/070-gitlab-ops.mdc +73 -57
  39. package/.cursor/rules/080-ecosystem-health.mdc +7 -7
  40. package/.cursor/skills/architecture/SKILL.md +13 -0
  41. package/.cursor/skills/autopilot/SKILL.md +12 -0
  42. package/.cursor/skills/bootstrap/SKILL.md +12 -0
  43. package/.cursor/skills/brainstorm/SKILL.md +13 -0
  44. package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
  45. package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
  46. package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
  47. package/.cursor/skills/daily/SKILL.md +12 -0
  48. package/.cursor/skills/debug/SKILL.md +13 -0
  49. package/.cursor/skills/discovery/SKILL.md +13 -0
  50. package/.cursor/skills/dispatcher/SKILL.md +13 -0
  51. package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
  52. package/.cursor/skills/domain-model/SKILL.md +13 -0
  53. package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
  54. package/.cursor/skills/eli5/SKILL.md +13 -0
  55. package/.cursor/skills/eval/SKILL.md +12 -0
  56. package/.cursor/skills/evolve/SKILL.md +13 -0
  57. package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
  58. package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
  59. package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
  60. package/.cursor/skills/grill/SKILL.md +13 -0
  61. package/.cursor/skills/hook-development/SKILL.md +13 -0
  62. package/.cursor/skills/journey-audit/SKILL.md +13 -0
  63. package/.cursor/skills/mcp-builder/SKILL.md +13 -0
  64. package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
  65. package/.cursor/skills/mode-selector/SKILL.md +13 -0
  66. package/.cursor/skills/npm-publish/SKILL.md +12 -0
  67. package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
  68. package/.cursor/skills/persona-panel/SKILL.md +12 -0
  69. package/.cursor/skills/plan/SKILL.md +13 -0
  70. package/.cursor/skills/playwright-driver/SKILL.md +13 -0
  71. package/.cursor/skills/quality-gates/SKILL.md +13 -0
  72. package/.cursor/skills/reconcile/SKILL.md +12 -0
  73. package/.cursor/skills/repo-audit/SKILL.md +13 -0
  74. package/.cursor/skills/session-end/SKILL.md +13 -0
  75. package/.cursor/skills/session-plan/SKILL.md +13 -0
  76. package/.cursor/skills/session-start/SKILL.md +13 -0
  77. package/.cursor/skills/skill-creator/SKILL.md +13 -0
  78. package/.cursor/skills/spinout/SKILL.md +12 -0
  79. package/.cursor/skills/sunset-review/SKILL.md +13 -0
  80. package/.cursor/skills/test-runner/SKILL.md +13 -0
  81. package/.cursor/skills/tmux-layout/SKILL.md +13 -0
  82. package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
  83. package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
  84. package/.cursor/skills/vault-mirror/SKILL.md +13 -0
  85. package/.cursor/skills/vault-sync/SKILL.md +13 -0
  86. package/.cursor/skills/wave-executor/SKILL.md +13 -0
  87. package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
  88. package/.mcp.json +4 -1
  89. package/CHANGELOG.md +168 -0
  90. package/README.md +18 -15
  91. package/agents/AGENTS.md +23 -4
  92. package/agents/code-implementer.md +2 -1
  93. package/agents/db-specialist.md +2 -1
  94. package/agents/docs-writer.md +3 -1
  95. package/agents/eval-judge.md +1 -1
  96. package/agents/session-reviewer.md +7 -1
  97. package/agents/test-writer.md +2 -1
  98. package/agents/ui-developer.md +2 -1
  99. package/commands/bootstrap.md +2 -2
  100. package/commands/close.md +3 -1
  101. package/commands/go.md +1 -1
  102. package/commands/journey-audit.md +43 -0
  103. package/docs/USER-GUIDE.md +2 -2
  104. package/docs/ci-setup.md +14 -0
  105. package/docs/codex-setup.md +64 -0
  106. package/docs/components.md +6 -6
  107. package/docs/cursor-setup.md +26 -47
  108. package/docs/events-schema.md +76 -4
  109. package/docs/github-mirror-protection.md +197 -0
  110. package/docs/pi-setup.md +2 -0
  111. package/docs/rule-authoring.md +3 -1
  112. package/docs/scope-collision-guard.md +49 -2
  113. package/docs/session-config-reference.md +26 -4
  114. package/docs/session-config-template.md +4 -3
  115. package/docs/telemetry.md +22 -0
  116. package/hooks/_lib/lock-bootstrap.mjs +8 -4
  117. package/hooks/_lib/vcs-create-matcher.mjs +397 -38
  118. package/hooks/enforce-scope.mjs +64 -0
  119. package/hooks/hooks-codex.json +1 -1
  120. package/hooks/hooks-cursor.json +201 -20
  121. package/hooks/hooks-pi.json +1 -1
  122. package/hooks/hooks.json +2 -2
  123. package/hooks/on-session-end.mjs +211 -10
  124. package/hooks/on-session-start.mjs +214 -11
  125. package/hooks/on-stop.mjs +48 -9
  126. package/hooks/post-subagent-discovery-validator.mjs +34 -3
  127. package/hooks/post-tool-batch-wave-signal.mjs +11 -2
  128. package/hooks/pre-bash-issue-budget.mjs +117 -4
  129. package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
  130. package/hooks/pre-bash-staging-fence.mjs +4 -0
  131. package/hooks/pre-task-scope-disjoint.mjs +368 -35
  132. package/hooks/skill-invocation-telemetry.mjs +21 -10
  133. package/monitors/monitors.json +6 -0
  134. package/package.json +1 -1
  135. package/pi/prompts/journey-audit.md +12 -0
  136. package/rules/_index.md +9 -1
  137. package/rules/always-on/ask-via-tool.md +62 -0
  138. package/rules/always-on/bash-harness-pitfalls.md +168 -0
  139. package/rules/always-on/build-value.md +47 -0
  140. package/rules/always-on/cross-session-messaging.md +59 -0
  141. package/rules/always-on/loop-and-monitor.md +221 -0
  142. package/rules/always-on/parallel-sessions.md +142 -12
  143. package/rules/always-on/receiving-review.md +108 -0
  144. package/rules/always-on/test-value.md +40 -0
  145. package/rules/always-on/verification-before-completion.md +77 -0
  146. package/scripts/archive-closed-prds.mjs +258 -18
  147. package/scripts/autopilot.mjs +5 -0
  148. package/scripts/backfill-evidence-digest.mjs +376 -0
  149. package/scripts/cursor-install.mjs +89 -48
  150. package/scripts/export-hw-learnings.mjs +143 -2
  151. package/scripts/express-path.mjs +299 -0
  152. package/scripts/generate-cursor-adapter.mjs +253 -0
  153. package/scripts/github-protection-audit.mjs +358 -0
  154. package/scripts/lib/autopilot/worktree-pipeline.mjs +240 -16
  155. package/scripts/lib/build-live-signals.mjs +24 -5
  156. package/scripts/lib/ci-status-banner.mjs +158 -11
  157. package/scripts/lib/command-blocker.mjs +70 -0
  158. package/scripts/lib/config/reconcile.mjs +79 -4
  159. package/scripts/lib/config/section-extractor.mjs +235 -36
  160. package/scripts/lib/config-schema.mjs +9 -1
  161. package/scripts/lib/config.mjs +57 -6
  162. package/scripts/lib/convergence-monitor.mjs +13 -2
  163. package/scripts/lib/cursor-hook-bridge.mjs +443 -0
  164. package/scripts/lib/dispatcher/cli.mjs +2 -2
  165. package/scripts/lib/express-path.mjs +327 -0
  166. package/scripts/lib/file-lock.mjs +22 -4
  167. package/scripts/lib/gates/gate-full.mjs +81 -8
  168. package/scripts/lib/gates/gate-helpers.mjs +76 -15
  169. package/scripts/lib/git-config-drift.mjs +134 -5
  170. package/scripts/lib/host-identity.mjs +247 -2
  171. package/scripts/lib/instruction-budget-guard.mjs +31 -1
  172. package/scripts/lib/issue-budget.mjs +229 -30
  173. package/scripts/lib/learnings/io.mjs +55 -10
  174. package/scripts/lib/learnings/schema.mjs +95 -28
  175. package/scripts/lib/lock-reaper.mjs +7 -1
  176. package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
  177. package/scripts/lib/locks/state-md-lock.mjs +8 -1
  178. package/scripts/lib/memory-banner.mjs +5 -2
  179. package/scripts/lib/memory-paths.mjs +15 -6
  180. package/scripts/lib/mode-selector/scoring.mjs +53 -6
  181. package/scripts/lib/platform.mjs +72 -9
  182. package/scripts/lib/plugin-root.mjs +143 -19
  183. package/scripts/lib/project-hygiene.mjs +43 -3
  184. package/scripts/lib/quality-gate.mjs +271 -13
  185. package/scripts/lib/reconcile/emitter.mjs +87 -19
  186. package/scripts/lib/reconcile/engine.mjs +281 -13
  187. package/scripts/lib/reconcile/idempotency.mjs +102 -1
  188. package/scripts/lib/reconcile/renderer.mjs +148 -3
  189. package/scripts/lib/reconcile/sanitize.mjs +40 -17
  190. package/scripts/lib/reconcile/writer.mjs +415 -84
  191. package/scripts/lib/rule-loader.mjs +37 -2
  192. package/scripts/lib/rules-sync.mjs +51 -8
  193. package/scripts/lib/scope-gate.mjs +90 -0
  194. package/scripts/lib/session-close-backfill.mjs +369 -28
  195. package/scripts/lib/session-discovery.mjs +13 -3
  196. package/scripts/lib/session-end/phase-skip.mjs +37 -4
  197. package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
  198. package/scripts/lib/session-id.mjs +30 -14
  199. package/scripts/lib/session-identity/own-session.mjs +159 -0
  200. package/scripts/lib/session-lock.mjs +85 -30
  201. package/scripts/lib/session-schema/normalizer.mjs +70 -3
  202. package/scripts/lib/session-schema/validator.mjs +40 -0
  203. package/scripts/lib/session-start-probes.mjs +608 -0
  204. package/scripts/lib/session-transition.mjs +277 -0
  205. package/scripts/lib/sessions-staleness-banner.mjs +124 -57
  206. package/scripts/lib/spiral-carryover.mjs +90 -9
  207. package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
  208. package/scripts/lib/state-md/mission-status.mjs +350 -52
  209. package/scripts/lib/state-md/yaml-parser.mjs +145 -16
  210. package/scripts/lib/state-md.mjs +12 -2
  211. package/scripts/lib/telemetry/sync.mjs +46 -8
  212. package/scripts/lib/validate/check-agents.mjs +66 -0
  213. package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
  214. package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
  215. package/scripts/lib/validate/check-doc-cli-commands.mjs +16 -32
  216. package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
  217. package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
  218. package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
  219. package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
  220. package/scripts/lib/validate/check-unwired-features.mjs +340 -32
  221. package/scripts/lib/validate/repo-files.mjs +275 -0
  222. package/scripts/lib/validate-vendored-rules.mjs +229 -7
  223. package/scripts/lib/vault-mirror/process.mjs +99 -43
  224. package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
  225. package/scripts/lib/vault-staleness-banner.mjs +76 -6
  226. package/scripts/lib/vault-status/board-writer.mjs +211 -10
  227. package/scripts/lib/vault-status/narrative-mirror.mjs +188 -8
  228. package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
  229. package/scripts/lib/wave-transcript-tail.mjs +869 -0
  230. package/scripts/materialize-wave-scope.mjs +209 -12
  231. package/scripts/mcp-server.sh +11 -2
  232. package/scripts/parse-config.mjs +65 -0
  233. package/scripts/token-audit.sh +9 -2
  234. package/scripts/validate-plugin.mjs +3 -0
  235. package/scripts/validate-wave-scope.mjs +67 -0
  236. package/scripts/vault-mirror.mjs +203 -34
  237. package/skills/_shared/monitor-patterns.md +31 -5
  238. package/skills/_shared/parallel-aware-auq.md +1 -1
  239. package/skills/_shared/parallel-aware-preamble.md +4 -2
  240. package/skills/_shared/platform-tools.md +11 -5
  241. package/skills/_shared/state-ownership.md +29 -2
  242. package/skills/autopilot/SKILL.md +5 -1
  243. package/skills/bootstrap/SKILL.md +3 -3
  244. package/skills/bootstrap/_shared-template.md +18 -10
  245. package/skills/bootstrap/deep-template.md +10 -6
  246. package/skills/bootstrap/fast-template.md +15 -8
  247. package/skills/bootstrap/standard-template.md +10 -6
  248. package/skills/claude-md-drift-check/checker.mjs +39 -11
  249. package/skills/dispatcher/SKILL.md +1 -1
  250. package/skills/journey-audit/SKILL.md +269 -0
  251. package/skills/peekaboo-driver/SKILL.md +15 -3
  252. package/skills/persona-panel/SKILL.md +1 -1
  253. package/skills/reconcile/SKILL.md +41 -1
  254. package/skills/session-end/SKILL.md +17 -4
  255. package/skills/session-end/metrics-collection.md +7 -4
  256. package/skills/session-end/phase-3-6-tail.md +11 -3
  257. package/skills/session-end/phase-3-7a-recommendations.md +16 -2
  258. package/skills/session-plan/SKILL.md +6 -1
  259. package/skills/session-plan/wave-template.md +1 -0
  260. package/skills/session-start/SKILL.md +30 -16
  261. package/skills/session-start/phase-7-5-mode-selector.md +15 -3
  262. package/skills/session-start/phase-8-5-express-path.md +77 -12
  263. package/skills/vault-sync/validator.mjs +31 -0
  264. package/skills/wave-executor/SKILL.md +4 -2
  265. package/skills/wave-executor/circuit-breaker.md +34 -9
  266. package/skills/wave-executor/wave-loop.md +102 -19
  267. package/templates/_shared/journey-manifest.md +110 -0
  268. package/templates/_shared/rules/parallel-sessions.md +0 -77
@@ -0,0 +1,608 @@
1
+ /**
2
+ * session-start-probes.mjs — the mechanical runner for the Phase 4 probe family.
3
+ *
4
+ * ## Why this module exists
5
+ *
6
+ * `skills/session-start/SKILL.md` § Phase 4 names 18 measurement probes, each
7
+ * with a module path and an entry function. Measured 2026-08-23 at `4f6404e`,
8
+ * NONE of them had a mechanical caller:
9
+ *
10
+ * ```
11
+ * $ grep -c "session-start-probes\|checkSessionsStaleness\|checkProjectHygiene" \
12
+ * hooks/*.mjs .husky/* package.json .gitlab-ci.yml
13
+ * 0
14
+ * ```
15
+ *
16
+ * The only caller was prose in a SKILL.md — i.e. an LLM had to remember to run
17
+ * eighteen probes by hand, every session. And whether it ever did was itself
18
+ * unobservable: 336 recorded session starts carried no probe event of any kind.
19
+ * That is `.claude/rules/host-resources.md` § HR-105 ("a rule you cannot
20
+ * falsify is not a rule") applied to a whole probe family, and the repo-memory
21
+ * "built-but-not-wired" class in its purest form.
22
+ *
23
+ * This runner is the wiring: it invokes the probes, collects their banner lines
24
+ * into the caller's single-envelope buffer, and writes ONE
25
+ * `orchestrator.probes.completed` telemetry record per run so the next question
26
+ * — "did they run, and what did they find?" — is answerable from the ledger
27
+ * rather than from memory.
28
+ *
29
+ * ## Contract
30
+ *
31
+ * - **Fail-open, always.** A probe that throws, hangs, or is missing from the
32
+ * install produces an `outcome`, never an exception. `runSessionStartProbes`
33
+ * has no rejecting path; a caller needs no try/catch (the hook keeps one
34
+ * anyway as defence-in-depth).
35
+ * - **Budget-bounded.** The whole run shares one deadline (`PROBE_BUDGET_MS`).
36
+ * See the ceiling note on that constant for what the bound can and cannot do.
37
+ * - **Absent is not zero.** A probe that did not run is recorded with the
38
+ * reason it did not (`skipped` + `reason`), never silently omitted and never
39
+ * folded into a clean count.
40
+ *
41
+ * @module scripts/lib/session-start-probes
42
+ */
43
+
44
+ import { existsSync, readFileSync } from 'node:fs';
45
+ import path from 'node:path';
46
+ import { pathToFileURL } from 'node:url';
47
+
48
+ import { emitEvent } from './events.mjs';
49
+
50
+ /**
51
+ * Resolve a sibling module to an absolute `file:` URL.
52
+ *
53
+ * A bare relative specifier (`'./ci-status-banner.mjs'`) resolves against the
54
+ * module doing the `import()`, so the registry would only be importable from
55
+ * THIS file — any other consumer, a test included, would resolve every entry
56
+ * against its own directory and see eighteen phantom "module-absent" skips that
57
+ * look exactly like a stale plugin install. Pinning the specifier to this
58
+ * module's directory makes `PROBES` self-contained.
59
+ *
60
+ * @param {string} rel — path relative to `scripts/lib/`
61
+ * @returns {string}
62
+ */
63
+ const local = (rel) => pathToFileURL(path.join(import.meta.dirname, rel)).href;
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Budget
67
+ // ---------------------------------------------------------------------------
68
+
69
+ /**
70
+ * Wall-clock ceiling for the ENTIRE probe run, in milliseconds.
71
+ *
72
+ * Derived from measurement, not aspiration:
73
+ * - `hooks/hooks.json` gives the whole SessionStart hook `timeout: 5` (5s).
74
+ * Everything below is carved out of that, not out of nothing.
75
+ * - The pre-existing work in the same hook already spends a measured ~845 ms
76
+ * median on `backfillOnSessionStart` alone, plus git, registry and the
77
+ * resource probe.
78
+ * - Measured 2026-08-23 against this repo (the largest in the fleet), one
79
+ * `runSessionStartProbes` call per process, five runs:
80
+ * **855 / 916 / 968 / 1063 / 1104 ms** — median 968 ms, dominated by
81
+ * `project-hygiene` and `tests-src-ratio`, the two probes that shell out.
82
+ *
83
+ * 2000 ms is ~2x the measured median here and still leaves the hook well over
84
+ * half its 5s envelope. Note the headroom is real but not generous, and this is
85
+ * the WORST repo in the fleet by design — see the revisit trigger below, which
86
+ * this repo already sits just underneath rather than comfortably below.
87
+ *
88
+ * NAMED CEILING (BV-004): the deadline is enforced at `await` points. A probe
89
+ * that blocks the event loop *synchronously* (several do — `project-hygiene`
90
+ * and `tests-src-ratio` shell out with `execFileSync`) cannot be preempted by a
91
+ * timer that cannot run; such a probe is reported with its TRUE `durationMs`
92
+ * and can overrun this budget. The bound is therefore hard for async/network
93
+ * probes and advisory for synchronous ones.
94
+ *
95
+ * READING `durationMs`: probes are launched together, so a probe's individual
96
+ * `durationMs` includes time spent waiting for a SIBLING synchronous probe to
97
+ * release the event loop. Measured 2026-08-23 here, `loop-readiness` reports
98
+ * ~513 ms under parallel launch and 0.5 ms in isolation. Only the run-level
99
+ * `duration_ms` is an isolated cost; per-probe figures rank contention, not
100
+ * work.
101
+ *
102
+ * REVISIT TRIGGER: if `duration_ms` in `orchestrator.probes.completed` exceeds
103
+ * half this budget at the median across a repo's recorded starts, or if any
104
+ * single probe's `durationMs` regularly exceeds `PROBE_BUDGET_MS`, move the
105
+ * slow probes off the hook's critical path (a detached child process writing
106
+ * its banner for the NEXT start) rather than raising this number.
107
+ */
108
+ export const PROBE_BUDGET_MS = 2000;
109
+
110
+ /** Sentinel resolved by the deadline race; never leaks to a caller. */
111
+ const TIMED_OUT = Symbol('probe-timeout');
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Probe registry
115
+ // ---------------------------------------------------------------------------
116
+
117
+ /**
118
+ * The Phase 4 probe family, in the order `skills/session-start/SKILL.md`
119
+ * introduces them (banner order is registry order, never completion order, so
120
+ * a run is reproducible).
121
+ *
122
+ * Census (2026-08-23, `skills/session-start/SKILL.md` lines 693-835):
123
+ * sed -n '693,835p' skills/session-start/SKILL.md \
124
+ * | grep -oE 'via `(await )?check[A-Za-z]+' # -> 16
125
+ * plus two probes the prose introduces with different phrasing —
126
+ * `checkBootstrapLockFreshness` ("invoke the bootstrap-lock-freshness probe")
127
+ * and `checkVaultStaleness` ("read the most recent line via …") — for 18.
128
+ *
129
+ * Each entry:
130
+ * - `id` stable telemetry id (also the banner-ordering key)
131
+ * - `spec` absolute `file:` URL to import (see {@link local})
132
+ * - `fn` exported entry function name
133
+ * - `network` true when the probe spawns a VCS CLI that talks to a remote
134
+ * - `args` builds the probe's options object from the run context
135
+ * - `precondition` optional; returns a skip-reason string to skip the probe
136
+ * - `render` optional; maps a result to a banner line. Default:
137
+ * `result.message` when severity is warn/alert.
138
+ */
139
+ export const PROBES = [
140
+ {
141
+ id: 'bootstrap-lock-freshness',
142
+ spec: local('./bootstrap-lock-freshness.mjs'),
143
+ fn: 'checkBootstrapLockFreshness',
144
+ network: false,
145
+ // SKILL.md gates this one on the lock's existence: without a lock the probe
146
+ // returns a hard `alert` ("bootstrap.lock missing") that is not a finding
147
+ // about THIS repo but about it never having been bootstrapped.
148
+ precondition: ({ repoRoot }) =>
149
+ existsSync(path.join(repoRoot, '.orchestrator', 'bootstrap.lock'))
150
+ ? null
151
+ : 'no-bootstrap-lock',
152
+ args: ({ repoRoot }) => ({ repoRoot, currentPluginVersion: pluginVersion() }),
153
+ },
154
+ {
155
+ id: 'vault-staleness',
156
+ spec: local('./vault-staleness-banner.mjs'),
157
+ fn: 'checkVaultStaleness',
158
+ network: false,
159
+ args: ({ repoRoot }) => ({ repoRoot }),
160
+ // No custom severityOf needed (#1159 single-vocabulary fix, N3 in the
161
+ // #1158/#1159 review): the probe's THIRD shape — `{severity:'warn',
162
+ // kind:'probe-stale'}` when its last record is older than
163
+ // MAX_RECORD_AGE_DAYS — now carries `severity: 'warn'` directly, so the
164
+ // module-level default severityOf() below (which reads `result.severity`
165
+ // verbatim) already renders it. The registry previously remapped a
166
+ // distinct `severity: 'info'` value by hand; that second vocabulary is
167
+ // gone from the source, so the remap is gone here too. See
168
+ // vault-staleness-banner.mjs's header for the "one vocabulary, not two"
169
+ // rationale.
170
+ },
171
+ {
172
+ id: 'ci-status',
173
+ spec: local('./ci-status-banner.mjs'),
174
+ fn: 'checkCiStatus',
175
+ network: true,
176
+ args: ({ repoRoot }) => ({ repoRoot }),
177
+ // Bespoke shape: `{status, ok, details, …}` with no `message` field. The
178
+ // banner text is prescribed by SKILL.md § Phase 4.
179
+ render: (r) => {
180
+ if (!r || typeof r !== 'object') return null;
181
+ if (r.status === 'red') {
182
+ const pid = r.details?.currentPipelineId ?? '?';
183
+ const green = r.lastGreen
184
+ ? ` — last green: #${r.lastGreen.pipelineId} (commit ${String(r.lastGreen.sha ?? '').slice(0, 7)}, ${r.redCount} pipelines ago)`
185
+ : '';
186
+ const job = r.failingJobName ? ` Failing job: ${r.failingJobName}` : '';
187
+ return `🚨 CI RED on HEAD (pipeline #${pid})${green}.${job}`;
188
+ }
189
+ if (r.status === 'green' && Array.isArray(r.allowFailureJobs) && r.allowFailureJobs.length > 0) {
190
+ const names = r.allowFailureJobs.map((j) => j?.name ?? String(j)).join(', ');
191
+ return `⚠ CI green on HEAD, but ${r.allowFailureJobs.length} allow_failure job(s) FAILED: ${names}. A pipeline reports success regardless of these.`;
192
+ }
193
+ return null;
194
+ },
195
+ // `status: 'red'` is an alert even though the probe publishes no severity.
196
+ severityOf: (r) => (r?.status === 'red' ? 'alert' : r?.status === 'green' && r?.allowFailureJobs ? 'warn' : 'ok'),
197
+ },
198
+ {
199
+ id: 'qg-command-drift',
200
+ spec: local('./qg-command-drift-banner.mjs'),
201
+ fn: 'checkQgCommandDrift',
202
+ network: false,
203
+ args: ({ repoRoot }) => ({ repoRoot }),
204
+ },
205
+ {
206
+ id: 'peer-cards-staleness',
207
+ spec: local('./peer-cards/staleness-banner.mjs'),
208
+ fn: 'checkPeerCardsStaleness',
209
+ network: false,
210
+ args: ({ repoRoot }) => ({ repoRoot }),
211
+ },
212
+ {
213
+ id: 'loop-readiness',
214
+ spec: local('./loop-readiness-banner.mjs'),
215
+ fn: 'checkLoopReadiness',
216
+ network: false,
217
+ args: ({ repoRoot, env }) => ({ repoRoot, env }),
218
+ },
219
+ {
220
+ id: 'instruction-budget',
221
+ spec: local('./instruction-budget-guard.mjs'),
222
+ fn: 'checkInstructionBudget',
223
+ network: false,
224
+ // No precondition (#1132): this probe counts the always-on directive
225
+ // corpus under `.claude/rules/`, and a repo without that directory has an
226
+ // empty corpus — a legitimate measured answer, not a declined measurement.
227
+ // A `no-rules-dir` precondition used to sit here purely to dodge a stderr
228
+ // side-effect in `rule-loader.loadApplicableRules`; that side-effect is now
229
+ // category-gated at its source, so the precondition suppressed nothing and
230
+ // cost a misleading `skipped: 'no-rules-dir'` record.
231
+ args: ({ repoRoot }) => ({ repoRoot }),
232
+ },
233
+ {
234
+ id: 'reconcile-nudge',
235
+ spec: local('./reconcile-nudge-banner.mjs'),
236
+ fn: 'checkReconcileNudge',
237
+ network: false,
238
+ args: ({ repoRoot, config }) => ({ repoRoot, config }),
239
+ },
240
+ {
241
+ id: 'sessions-staleness',
242
+ spec: local('./sessions-staleness-banner.mjs'),
243
+ fn: 'checkSessionsStaleness',
244
+ network: false,
245
+ args: ({ repoRoot }) => ({ repoRoot }),
246
+ },
247
+ {
248
+ id: 'sessions-integrity',
249
+ spec: local('./sessions-integrity-banner.mjs'),
250
+ fn: 'checkSessionsIntegrity',
251
+ network: false,
252
+ args: ({ repoRoot }) => ({ repoRoot }),
253
+ },
254
+ {
255
+ id: 'owner-config',
256
+ spec: local('./owner-config-banner.mjs'),
257
+ fn: 'checkOwnerConfig',
258
+ network: false,
259
+ // Host-wide `owner.yaml`, deliberately NOT repo-scoped — no repoRoot arg.
260
+ args: () => ({}),
261
+ },
262
+ {
263
+ id: 'moc-staleness',
264
+ spec: local('./moc-staleness-banner.mjs'),
265
+ fn: 'checkMocStaleness',
266
+ network: false,
267
+ args: ({ repoRoot, config }) => ({ repoRoot, config }),
268
+ },
269
+ {
270
+ id: 'context-coverage',
271
+ spec: local('./context-coverage-banner.mjs'),
272
+ fn: 'checkContextCoverage',
273
+ network: false,
274
+ args: ({ repoRoot, config }) => ({ repoRoot, config }),
275
+ },
276
+ {
277
+ id: 'claude-md-budget-lint',
278
+ spec: local('./claude-md-budget-lint.mjs'),
279
+ fn: 'checkClaudeMdBudgetLint',
280
+ network: false,
281
+ args: ({ repoRoot }) => ({ repoRoot }),
282
+ },
283
+ {
284
+ id: 'tests-src-ratio',
285
+ spec: local('./tests-src-ratio.mjs'),
286
+ fn: 'checkTestsSrcRatio',
287
+ network: false,
288
+ args: ({ repoRoot }) => ({ repoRoot }),
289
+ },
290
+ {
291
+ id: 'project-hygiene',
292
+ spec: local('./project-hygiene.mjs'),
293
+ fn: 'checkProjectHygiene',
294
+ network: false,
295
+ args: ({ repoRoot }) => ({ repoRoot }),
296
+ },
297
+ {
298
+ id: 'mirror-issues',
299
+ spec: local('./mirror-issues-banner.mjs'),
300
+ fn: 'checkMirrorIssues',
301
+ network: true,
302
+ args: ({ repoRoot }) => ({ repoRoot }),
303
+ },
304
+ {
305
+ id: 'git-config-drift',
306
+ spec: local('./git-config-drift.mjs'),
307
+ fn: 'checkGitConfigDrift',
308
+ network: false,
309
+ args: ({ repoRoot, env }) => ({ repoRoot, env }),
310
+ },
311
+ ];
312
+
313
+ // ---------------------------------------------------------------------------
314
+ // Helpers
315
+ // ---------------------------------------------------------------------------
316
+
317
+ /**
318
+ * The installed plugin's own version, for `bootstrap-lock-freshness`'s
319
+ * version-drift comparison. Read from THIS plugin's package.json (two levels up
320
+ * from `scripts/lib/`), never from the target repo — the probe compares the
321
+ * lock's recorded plugin version against the plugin that is running now.
322
+ *
323
+ * @returns {string|undefined} — undefined when unreadable (the probe then
324
+ * reports `current=unknown` rather than a fabricated version).
325
+ */
326
+ function pluginVersion() {
327
+ try {
328
+ const pkg = path.resolve(import.meta.dirname, '..', '..', 'package.json');
329
+ const parsed = JSON.parse(readFileSync(pkg, 'utf8'));
330
+ return typeof parsed.version === 'string' ? parsed.version : undefined;
331
+ } catch {
332
+ return undefined;
333
+ }
334
+ }
335
+
336
+ /**
337
+ * Race `promise` against a deadline.
338
+ *
339
+ * The loser is neutralised (`.catch`) before the race so a late rejection can
340
+ * never surface as an unhandled rejection and kill an exit-0 hook.
341
+ *
342
+ * @template T
343
+ * @param {Promise<T>} promise
344
+ * @param {number} ms — remaining budget; `<= 0` times out immediately.
345
+ * @returns {Promise<T|symbol>} — resolves to {@link TIMED_OUT} on expiry.
346
+ */
347
+ async function withDeadline(promise, ms) {
348
+ const settled = promise.catch((err) => ({ __probeError: err }));
349
+ if (ms <= 0) return TIMED_OUT;
350
+ let timer;
351
+ const expiry = new Promise((resolve) => {
352
+ timer = setTimeout(() => resolve(TIMED_OUT), ms);
353
+ // Never hold the event loop open for the timer alone.
354
+ if (typeof timer?.unref === 'function') timer.unref();
355
+ });
356
+ try {
357
+ return await Promise.race([settled, expiry]);
358
+ } finally {
359
+ clearTimeout(timer);
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Normalise a probe result's severity.
365
+ *
366
+ * `null` is the family's universal "clean, say nothing" return. Everything else
367
+ * is judged by its `severity` field — including the THREE-state probes
368
+ * (`mirror-issues`, `git-config-drift`, `ci-status`) whose `degraded` result
369
+ * means "state unknown" and must NEVER be read as clean.
370
+ *
371
+ * @param {*} result
372
+ * @param {{severityOf?: (r: *) => string}} probe
373
+ * @returns {'ok'|'warn'|'alert'}
374
+ */
375
+ function severityOf(result, probe) {
376
+ if (typeof probe.severityOf === 'function') {
377
+ const s = probe.severityOf(result);
378
+ return s === 'alert' || s === 'warn' ? s : 'ok';
379
+ }
380
+ if (result === null || result === undefined) return 'ok';
381
+ const s = result.severity;
382
+ if (s === 'alert') return 'alert';
383
+ if (s === 'warn') return 'warn';
384
+ return 'ok';
385
+ }
386
+
387
+ /**
388
+ * Default banner renderer: the probe's own `message`, but only when it actually
389
+ * found something. A probe that returns `{severity: 'ok'|'info'}` is silent.
390
+ *
391
+ * @param {*} result
392
+ * @param {string} severity
393
+ * @returns {string|null}
394
+ */
395
+ function defaultRender(result, severity) {
396
+ if (severity === 'ok') return null;
397
+ const msg = result?.message;
398
+ return typeof msg === 'string' && msg.length > 0 ? msg : null;
399
+ }
400
+
401
+ /**
402
+ * Load Session Config for the repo, fail-open to `{}`.
403
+ * @param {string} repoRoot
404
+ * @returns {Promise<object>}
405
+ */
406
+ async function loadConfig(repoRoot) {
407
+ try {
408
+ const { readConfigFile, parseSessionConfig } = await import('./config.mjs');
409
+ return parseSessionConfig(await readConfigFile(repoRoot)) ?? {};
410
+ } catch {
411
+ return {};
412
+ }
413
+ }
414
+
415
+ // ---------------------------------------------------------------------------
416
+ // Runner
417
+ // ---------------------------------------------------------------------------
418
+
419
+ /**
420
+ * Run the Phase 4 probe family and record the run.
421
+ *
422
+ * Never rejects and never throws: every failure mode of every probe is folded
423
+ * into that probe's `outcome`.
424
+ *
425
+ * @param {object} [opts]
426
+ * @param {string} opts.repoRoot — repo the probes measure AND the repo whose
427
+ * `.orchestrator/metrics/events.jsonl` receives the telemetry record. Pinning
428
+ * this is what keeps a test out of the real ledger.
429
+ * @param {object} [opts.config] — parsed Session Config; loaded from `repoRoot`
430
+ * when omitted.
431
+ * @param {object} [opts.env=process.env] — environment passed to env-reading
432
+ * probes and consulted for `SO_PROBES_INCLUDE_NETWORK`.
433
+ * @param {number} [opts.timeoutMs=PROBE_BUDGET_MS] — wall-clock budget.
434
+ * @param {object} [deps] — test seams.
435
+ * @param {Array<object>} [deps.probes=PROBES] — probe registry override.
436
+ * @param {(type: string, payload: object, opts: object) => Promise<void>} [deps.emit=emitEvent]
437
+ * @returns {Promise<{bannerLines: string[], results: Array<{id: string, outcome: string, severity?: string, durationMs: number, reason?: string}>, event: object|null}>}
438
+ */
439
+ export async function runSessionStartProbes(opts = {}, deps = {}) {
440
+ const started = Date.now();
441
+ const {
442
+ repoRoot,
443
+ env = process.env,
444
+ timeoutMs = PROBE_BUDGET_MS,
445
+ } = opts;
446
+ const probes = Array.isArray(deps.probes) ? deps.probes : PROBES;
447
+ const emit = typeof deps.emit === 'function' ? deps.emit : emitEvent;
448
+
449
+ // No repoRoot means every repo-scoped probe would measure the wrong tree and
450
+ // the event would land in whatever `SO_PROJECT_DIR` happens to resolve to.
451
+ // Refusing here is the fail-open answer: report nothing, write nothing.
452
+ if (!repoRoot || typeof repoRoot !== 'string') {
453
+ return { bannerLines: [], results: [], event: null };
454
+ }
455
+
456
+ const config = opts.config ?? (await loadConfig(repoRoot));
457
+
458
+ // Network probes: excluded by default, and the exclusion is RECORDED.
459
+ //
460
+ // `ci-status` and `mirror-issues` spawn `glab`/`gh` against a remote with an
461
+ // 8s CLI timeout of their own — a single one can exceed the hook's entire 5s
462
+ // `hooks.json` budget. Measured warm here on 2026-08-23: 520 ms and 498 ms,
463
+ // and that is the BEST case (warm CLI, live network, authenticated). Paying
464
+ // that on every session start of every repo buys a signal the operator can
465
+ // get on demand from `/session`, so the default is off.
466
+ //
467
+ // What is NOT acceptable is dropping them silently — that reproduces the
468
+ // exact defect this module repairs. They appear in every run's telemetry as
469
+ // `outcome: 'skipped'`, `reason: 'network-probe-opt-in'`, so "were they run?"
470
+ // stays an answerable question.
471
+ const includeNetwork = env?.SO_PROBES_INCLUDE_NETWORK === '1';
472
+
473
+ const ctx = { repoRoot, config, env };
474
+ const deadline = started + Math.max(0, Number(timeoutMs) || 0);
475
+
476
+ /** @type {Map<string, object>} */
477
+ const byId = new Map();
478
+
479
+ await Promise.all(
480
+ probes.map(async (probe) => {
481
+ const t0 = Date.now();
482
+ const record = (outcome, extra = {}) => {
483
+ byId.set(probe.id, {
484
+ id: probe.id,
485
+ outcome,
486
+ durationMs: Date.now() - t0,
487
+ ...extra,
488
+ });
489
+ };
490
+
491
+ if (probe.network && !includeNetwork) {
492
+ record('skipped', { reason: 'network-probe-opt-in' });
493
+ return;
494
+ }
495
+
496
+ let skipReason;
497
+ try {
498
+ skipReason = typeof probe.precondition === 'function' ? probe.precondition(ctx) : null;
499
+ } catch {
500
+ // A precondition that throws is a defect in the precondition, not a
501
+ // finding about the repo — treat it as "cannot decide", run nothing.
502
+ record('error', { reason: 'precondition-threw' });
503
+ return;
504
+ }
505
+ if (typeof skipReason === 'string' && skipReason.length > 0) {
506
+ record('skipped', { reason: skipReason });
507
+ return;
508
+ }
509
+
510
+ // The whole invocation — import included — is inside the race, because a
511
+ // pre-#369-style absent module and a hung probe are both "did not
512
+ // deliver" and both must resolve to an outcome rather than to a throw.
513
+ const invocation = (async () => {
514
+ const mod = await import(probe.spec);
515
+ const fn = mod?.[probe.fn];
516
+ if (typeof fn !== 'function') {
517
+ return { __probeError: new Error(`export ${probe.fn} missing`), __absent: true };
518
+ }
519
+ return { __probeResult: await fn(probe.args(ctx)) };
520
+ })();
521
+
522
+ const raced = await withDeadline(invocation, deadline - Date.now());
523
+
524
+ if (raced === TIMED_OUT) {
525
+ record('timeout', { reason: 'budget-exceeded' });
526
+ return;
527
+ }
528
+ if (raced && raced.__probeError) {
529
+ // A missing module is the documented "pre-#N plugin install" case:
530
+ // SKILL.md says skip silently. Made visible as a skip, not an error.
531
+ if (raced.__absent || raced.__probeError?.code === 'ERR_MODULE_NOT_FOUND') {
532
+ record('skipped', { reason: 'module-absent' });
533
+ } else {
534
+ record('error', { reason: String(raced.__probeError?.message ?? raced.__probeError).slice(0, 200) });
535
+ }
536
+ return;
537
+ }
538
+
539
+ const result = raced?.__probeResult;
540
+ const severity = severityOf(result, probe);
541
+ const line = typeof probe.render === 'function'
542
+ ? probe.render(result)
543
+ : defaultRender(result, severity);
544
+ record(severity === 'ok' ? 'ran-clean' : severity === 'warn' ? 'ran-warn' : 'ran-alert', {
545
+ severity,
546
+ ...(line ? { line } : {}),
547
+ });
548
+ }),
549
+ );
550
+
551
+ // Registry order, never completion order — a run must be reproducible.
552
+ const results = [];
553
+ const bannerLines = [];
554
+ for (const probe of probes) {
555
+ const r = byId.get(probe.id);
556
+ if (!r) continue;
557
+ if (r.line) bannerLines.push(r.line);
558
+ const { line: _line, ...rest } = r;
559
+ results.push(rest);
560
+ }
561
+
562
+ const count = (pred) => results.filter(pred).length;
563
+ const ran = count((r) => r.outcome.startsWith('ran-'));
564
+ const warned = count((r) => r.outcome === 'ran-warn' || r.outcome === 'ran-alert');
565
+ const skipped = count((r) => r.outcome === 'skipped');
566
+ const errored = count((r) => r.outcome === 'error');
567
+ const timedOut = count((r) => r.outcome === 'timeout');
568
+
569
+ // One aggregate line, and only when something actually failed to deliver — a
570
+ // banner that fires on every start teaches the operator to ignore banners
571
+ // (`.claude/rules/host-resources.md` § HR-101).
572
+ if (errored + timedOut > 0) {
573
+ const parts = [];
574
+ if (errored > 0) parts.push(`${errored} errored`);
575
+ if (timedOut > 0) parts.push(`${timedOut} timed out`);
576
+ bannerLines.push(
577
+ `⚠ session-start probes: ${parts.join(', ')} — see orchestrator.probes.completed in .orchestrator/metrics/events.jsonl.`,
578
+ );
579
+ }
580
+
581
+ const event = {
582
+ total: probes.length,
583
+ ran,
584
+ warned,
585
+ skipped,
586
+ errored,
587
+ timed_out: timedOut,
588
+ duration_ms: Date.now() - started,
589
+ // `reason` travels. Dropping it here was the module's own rule broken at its
590
+ // own boundary: `module-absent` is the ONE skip reason that means a probe is
591
+ // permanently dead, and without it the ledger cannot tell that apart from
592
+ // `network-probe-opt-in`, which is the intended default. "Measured, then
593
+ // discarded" is worse than "not measured" — the value existed.
594
+ probes: results.map((r) => ({
595
+ id: r.id,
596
+ outcome: r.outcome,
597
+ ...(typeof r.reason === 'string' && r.reason.length > 0 ? { reason: r.reason } : {}),
598
+ })),
599
+ };
600
+
601
+ // Telemetry is best-effort like every other side effect here: a ledger that
602
+ // cannot be written must not cost the operator his banners.
603
+ try {
604
+ await emit('orchestrator.probes.completed', event, { repoRoot });
605
+ } catch { /* never block the caller */ }
606
+
607
+ return { bannerLines, results, event };
608
+ }