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
@@ -107,8 +107,8 @@
107
107
  * | 2 | repo module failed to load | ALLOW + GUARD INACTIVE on stderr | #992/#993: a broken module must never brick the session — but never SILENTLY, or a crash is indistinguishable from `emitAllow` |
108
108
  * | 3 | stdin empty / not JSON | ALLOW | not a real hook call; denying here blocks every dispatch on a harness quirk |
109
109
  * | 4 | `tool_name` is not the dispatch tool | ALLOW | not our tool |
110
- * | 5 | prompt carries no scope marker | ALLOW | 105 of 147 real prompts (71.4 %) have none. Non-extractable ≠ violation; denying these would deny 7 dispatches in 10 |
111
- * | 6 | scope block present but unparseable | ALLOW | same reason as 5 — the parser is the fragile part, so its failures must resolve to the harmless side |
110
+ * | 5 | prompt carries no scope marker | ALLOW + COUNT | 105 of 147 real prompts (71.4 %) have none. Non-extractable ≠ violation; denying these would deny 7 dispatches in 10. #1092: the allow now carries a counter-only record, so it is no longer byte-identical to "the guard never ran" |
111
+ * | 6 | scope block present but unparseable | ALLOW + COUNT | same reason as 5 — the parser is the fragile part, so its failures must resolve to the harmless side. Counted under a DISTINCT class from row 5 |
112
112
  * | 7 | ledger unreadable / corrupt | WARN + ALLOW + SELF-HEAL | loss of state is not evidence of a violation; loud so it gets noticed. The verdict now CARRIES a fresh ledger, so the corruption is repaired on the spot — without it the guard stayed OFF for the whole remaining wave, visible only in one `systemMessage` |
113
113
  * | 8 | `git ls-files` failed | ALLOW (degraded) | glob-vs-glob expansion degrades, concrete collisions are still found. A git outage is not a scope violation |
114
114
  * | 9 | `findScopeCollisions` → not evaluable | WARN + ALLOW | the library says "not evaluable". Denying on a verdict with no witness is an assertion without evidence |
@@ -119,6 +119,13 @@
119
119
  * |13 | liveness probe throws / no evidence at all | treat as IN FLIGHT | keeps row 11 biting; the blind case is bounded by `IN_FLIGHT_TTL_MS`, never unbounded |
120
120
  * |14 | ledger lock not acquirable in `LEDGER_LOCK_TIMEOUT_MS` | run UNLOCKED (degraded) | the lock removes the read-modify-write race (below); failing to take it must not deny, so the cycle degrades to the pre-lock behaviour |
121
121
  *
122
+ * Every row that reaches a verdict from `decide()` — 5–11 and 13–14 — also
123
+ * leaves an event record (§ Observability), so "which row fired" is answerable
124
+ * after the fact and not only in the moment. Rows 1–4 emit nothing (no decision
125
+ * was made), and neither do the two crash rows 2 and 12: a hook that fell over
126
+ * cannot describe itself, which is precisely why the GUARD INACTIVE banner on
127
+ * stderr is the signal there.
128
+ *
122
129
  * Rows 7 and 9 use `emitWarn`, which calls `process.exit(0)` and NEVER RETURNS.
123
130
  * That is why `decide()` below is a PURE function returning a verdict object and
124
131
  * this module emits exactly ONCE, at the end. Warning from inside the checking
@@ -137,6 +144,26 @@
137
144
  * PSA-005 STATE.md lock), with a dead-PID stale override and a short timeout;
138
145
  * on timeout it degrades to the unlocked cycle (row 14) rather than denying.
139
146
  *
147
+ * ## Observability — the ledger half of #1092
148
+ *
149
+ * Every dispatch DECISION also appends one `orchestrator.wave_dispatch.scope_checked`
150
+ * record to `<projectDir>/.orchestrator/metrics/events.jsonl`. The reason it
151
+ * exists is matrix rows 5/6: the no-signal ALLOW used to be byte-identical to
152
+ * "the guard never ran", and the in-ledger counter added first is a WAVE tally —
153
+ * it cannot say WHICH dispatch carried a scope. Payload: `wave` (omitted, never
154
+ * `0`, when unknown), `agent_id`, `declared_path_count`, `injected`, `shape`,
155
+ * `signal`, `ledger_result`, `collision_count`, `hook`, plus `session_id` /
156
+ * `semantic_session_id` when a session lock is readable.
157
+ *
158
+ * WHAT IT PROVES: this hook SAW (or did not see) a `FILE-SCOPE` declaration in
159
+ * the prompt the coordinator handed to the dispatch tool, and what the guard
160
+ * then decided. WHAT IT DOES NOT PROVE: that the block reached the agent's
161
+ * context, or that the agent read it. That is a RECEIVE-side question and the
162
+ * platform exposes no prompt-assembly boundary to answer it — the open half of
163
+ * #1092, with its revisit trigger in `docs/scope-collision-guard.md` § 4.2.
164
+ * NOTHING derived from the prompt BODY is in the payload: counts and enums
165
+ * only, no paths (#1092 acceptance criterion 3).
166
+ *
140
167
  * ## stdout discipline
141
168
  *
142
169
  * Under the exit-0 protocol (#906, ADR-0011) allow and deny share exit code 0 —
@@ -240,6 +267,34 @@ const MAX_PATH_CHARS = 120;
240
267
  /** Ledger bound: a wave dispatching more than this is pathological; drop oldest. */
241
268
  const MAX_LEDGER_AGENTS = 64;
242
269
 
270
+ /**
271
+ * Scope-signal classes (#1092). Counter-only — see {@link bumpSignalCounter}.
272
+ */
273
+ const SIGNAL_MARKER_ABSENT = 'marker-absent';
274
+ const SIGNAL_UNPARSEABLE = 'unparseable';
275
+ const SIGNAL_EXTRACTED = 'extracted';
276
+
277
+ /**
278
+ * Which declaration SHAPE produced the extracted paths (#1092). `none` means no
279
+ * shape yielded a path — the `marker-absent` AND the `unparseable` case alike,
280
+ * which is why the event carries `signal` beside `shape`: `shape` says which
281
+ * parser won, `signal` says whether a declaration was there at all.
282
+ */
283
+ const SHAPE_FENCED = 'fenced';
284
+ const SHAPE_INLINE = 'inline';
285
+ const SHAPE_NONE = 'none';
286
+
287
+ /** The per-dispatch observability record (#1092) — see § Observability. */
288
+ const SCOPE_EVENT = 'orchestrator.wave_dispatch.scope_checked';
289
+
290
+ /**
291
+ * Clamp for the one free-form string the event carries (`agent_id`, built from
292
+ * the coordinator's own `description`). A dispatch description is a label, but
293
+ * nothing enforces that, so the ledger line is bounded like every other payload
294
+ * in this file (§ stdout discipline).
295
+ */
296
+ const MAX_AGENT_ID_CHARS = 120;
297
+
243
298
  /**
244
299
  * Blind-fallback liveness bound — see § Liveness for the measurement, the named
245
300
  * ceiling and the revisit trigger. Only reached when the transcript carries NO
@@ -321,12 +376,74 @@ async function bootstrap() {
321
376
  // Scope extraction — the fragile part, so every failure resolves to ALLOW
322
377
  // ---------------------------------------------------------------------------
323
378
 
379
+ /** The marker vocabulary, shared by both declaration shapes below. */
380
+ const SCOPE_TERMS = 'DATEI[- ]SCOPE|FILE[- ]SCOPE|FILE SCOPE|DEIN SCOPE|SCOPE \\(|FILES? IN SCOPE';
381
+
324
382
  /**
325
- * Markers that introduce a file-scope block in a dispatch prompt. Measured
326
- * coverage: 42 of 147 archived prompts (28.6 %) carry one of these. The other
327
- * 71.4 % are matrix row 5 allowed, not denied.
383
+ * SHAPE 1 (highest precedence) — a marker near the START of a line, followed by
384
+ * a fenced block. This is the form `skills/wave-executor/wave-loop.md` § Scope
385
+ * Manifest specifies. Measured coverage: 42 of 147 archived prompts (28.6 %)
386
+ * carry one of these; the other 71.4 % are matrix row 5 — allowed, not denied.
387
+ *
388
+ * ## The 80-char window is NOT widened, and that is a measurement (#1092)
389
+ *
390
+ * The obvious repair for "the marker sits at column 210…506 of its line" is to
391
+ * widen the window. Measured 2026-08-26 over 4452 real first-record subagent
392
+ * prompts under `~/.claude/projects/ * / * /subagents/agent-*.jsonl` (709 of them
393
+ * this repo's own), comparing this regex against `^.{0,600}`:
394
+ *
395
+ * window 80 → 701 marker hits, 267 prompts yield ≥1 extracted path
396
+ * window 600 → 810 marker hits, 267 prompts yield ≥1 extracted path
397
+ * of the 102 prompts the wider window newly matches, 12 have any fenced block
398
+ * after the marker at all, and 0 yield a single path. In THIS repo: +34 newly
399
+ * matched, 0 with a fence, 0 paths.
400
+ *
401
+ * So widening recovers NOTHING and costs something real: it reclassifies 102
402
+ * prompts from "no marker" to "marker present but unparseable", which is
403
+ * precisely the distinction the signal counter below exists to record. Worse,
404
+ * most of what it newly matches is a CITATION, not a declaration — "quote the
405
+ * exact command, the file scope, the result", "outside your file scope",
406
+ * "File-Scope-Disjunktheit". A citation is not a declaration; reading the first
407
+ * hit in a region as one is the recorded failure of `parseEpicRef` (#1112).
408
+ *
409
+ * The real miss class is a different SHAPE, handled by {@link INLINE_SCOPE_DECL}.
328
410
  */
329
- const SCOPE_MARKER = /^.{0,80}(DATEI[- ]SCOPE|FILE[- ]SCOPE|FILE SCOPE|DEIN SCOPE|SCOPE \(|FILES? IN SCOPE)/im;
411
+ const SCOPE_MARKER = new RegExp(`^.{0,80}(${SCOPE_TERMS})`, 'im');
412
+
413
+ /**
414
+ * SHAPE 2 (lower precedence) — the measured miss class: a declaration written
415
+ * INLINE, mid-sentence, with its paths comma-separated on the same line rather
416
+ * than in a fenced block:
417
+ *
418
+ * "…Max 25 turns. Edit ONLY your FILE-SCOPE: scripts/a.mjs, scripts/b.mjs"
419
+ *
420
+ * What separates this from a citation is not WHERE it sits but that it
421
+ * INTRODUCES something — the marker is followed by an optional short qualifier
422
+ * and/or parenthetical and then a declaration operator (`:` or an em/en dash).
423
+ * A bare hyphen is deliberately NOT an operator: it would admit
424
+ * "File-Scope-Planung". Anchored declaration shapes with precedence, rather than
425
+ * a narrower search space, is the fix #1112's learning prescribes.
426
+ *
427
+ * The tail window before the operator is 2 characters, and that is measured
428
+ * too: at 24 it admitted \`File-Scope-Disjunktheit: a.mjs, b.mjs\` — a compound
429
+ * NOUN reading as a declaration. A dash-introduced qualifier
430
+ * (\`FILE-SCOPE — exactly these:\`) is allowed explicitly instead of by window
431
+ * width, so widening the window is never the way to admit one.
432
+ *
433
+ * Measured 2026-08-26 on this repo's 709 subagent prompts: 136 yield paths
434
+ * today; this shape recovers 9 more, all of them genuine wave file scopes.
435
+ * Host-wide the same operator test admits 41 additional marker hits and its
436
+ * one false extraction (`[".filter"]`) is a prose fragment, which is why the
437
+ * fenced shape keeps precedence and this one runs only when that found nothing.
438
+ */
439
+ const INLINE_SCOPE_DECL = new RegExp(`(${SCOPE_TERMS})`, 'ig');
440
+ const INLINE_DECL_OPERATOR = /^[^\n(:—–]{0,2}(\([^)\n]{0,80}\))?\s*(?:[—–][^\n:]{0,30})?\s*(:|—|–)/;
441
+
442
+ /** Separators a coordinator uses between paths in an inline declaration. */
443
+ const INLINE_SCOPE_SEPARATOR = /[,;·]| und | and | sowie /;
444
+
445
+ /** Bound the inline scan; a prompt naming the vocabulary this often is prose. */
446
+ const MAX_INLINE_MARKER_SCANS = 8;
330
447
 
331
448
  /**
332
449
  * A plausible repo-relative path. Deliberately strict — a false ACCEPT here
@@ -427,30 +544,19 @@ export function promoteDirEntries(files, known) {
427
544
  }
428
545
 
429
546
  /**
430
- * Extract the declared file scope from a dispatch prompt.
547
+ * Accept the segments that survive `looksLikeRepoPath`, normalised and deduped
548
+ * with order preserved. Shared by both declaration shapes.
431
549
  *
432
- * Strategy: find a scope marker line, take the FIRST fenced block after it, and
433
- * accept only lines that survive `looksLikeRepoPath`. Returns `[]` when nothing
434
- * is confidently extractable — which the caller treats as ALLOW (matrix rows
435
- * 5 and 6), never as an empty scope that could collide.
436
- *
437
- * @param {string} prompt
438
- * @returns {string[]} repo-relative paths/globs, normalised, deduped, order preserved
550
+ * @param {string[]} segments
551
+ * @returns {string[]}
439
552
  */
440
- export function extractScopeFromPrompt(prompt) {
441
- if (typeof prompt !== 'string' || prompt.length === 0) return [];
442
- const markerMatch = SCOPE_MARKER.exec(prompt);
443
- if (markerMatch === null) return [];
444
-
445
- const after = prompt.slice(markerMatch.index + markerMatch[0].length);
446
- // First fenced block after the marker. Non-greedy body; tolerates a language tag.
447
- const fence = /```[^\n]*\n([\s\S]*?)```/.exec(after);
448
- if (fence === null) return [];
449
-
553
+ function collectScopePaths(segments) {
450
554
  const out = [];
451
555
  const seen = new Set();
452
- for (const rawLine of fence[1].split('\n')) {
453
- const cleaned = normalizeScopeEntry(cleanScopeLine(rawLine));
556
+ for (const raw of segments) {
557
+ // A trailing sentence period is punctuation, never part of a path
558
+ // (measured: "docs/events-schema.md." at the end of an inline declaration).
559
+ const cleaned = normalizeScopeEntry(cleanScopeLine(raw).replace(/\.$/, ''));
454
560
  if (!looksLikeRepoPath(cleaned)) continue;
455
561
  if (seen.has(cleaned)) continue;
456
562
  seen.add(cleaned);
@@ -459,6 +565,94 @@ export function extractScopeFromPrompt(prompt) {
459
565
  return out;
460
566
  }
461
567
 
568
+ /**
569
+ * SHAPE 2 extraction — see {@link INLINE_SCOPE_DECL}. Reports whether a
570
+ * DECLARATION (not a citation) was seen at all, so the caller can tell matrix
571
+ * row 5 from row 6 even when no path survives.
572
+ *
573
+ * @param {string} prompt
574
+ * @returns {{seen: boolean, files: string[]}}
575
+ */
576
+ function extractInlineScopeDeclaration(prompt) {
577
+ INLINE_SCOPE_DECL.lastIndex = 0;
578
+ let seen = false;
579
+ let match;
580
+ let scans = 0;
581
+ while ((match = INLINE_SCOPE_DECL.exec(prompt)) !== null && scans < MAX_INLINE_MARKER_SCANS) {
582
+ scans++;
583
+ const after = prompt.slice(match.index + match[0].length);
584
+ const operator = INLINE_DECL_OPERATOR.exec(after);
585
+ if (operator === null) continue; // a citation, not a declaration
586
+ seen = true;
587
+ const line = after.slice(operator[0].length).split('\n')[0];
588
+ const files = collectScopePaths(line.split(INLINE_SCOPE_SEPARATOR));
589
+ if (files.length > 0) return { seen: true, files };
590
+ }
591
+ return { seen, files: [] };
592
+ }
593
+
594
+ /**
595
+ * Classify the scope signal a dispatch prompt carries, and extract it.
596
+ *
597
+ * Precedence, deliberately: SHAPE 1 (line-leading marker + fenced block, the
598
+ * documented form) first; SHAPE 2 (inline comma-separated declaration) only
599
+ * when SHAPE 1 produced nothing. The status is what makes a no-signal ALLOW
600
+ * distinguishable after the fact:
601
+ *
602
+ * `marker-absent` — no declaration of any recognised shape (matrix row 5)
603
+ * `unparseable` — a declaration is present but no path survived (row 6)
604
+ * `extracted` — `files` is non-empty
605
+ *
606
+ * `shape` names the parser that WON — `fenced` (SHAPE 1), `inline` (SHAPE 2), or
607
+ * `none` when neither produced a path. It is deliberately NOT a second spelling
608
+ * of `status`: a prompt carrying a fenced block whose lines are prose is
609
+ * `{status: 'unparseable', shape: 'none'}`, and collapsing the two would lose
610
+ * exactly the row-5/row-6 distinction the counter exists for.
611
+ *
612
+ * @param {string} prompt
613
+ * @returns {{status: 'marker-absent'|'unparseable'|'extracted',
614
+ * shape: 'fenced'|'inline'|'none', files: string[]}}
615
+ */
616
+ export function extractScopeSignal(prompt) {
617
+ if (typeof prompt !== 'string' || prompt.length === 0) {
618
+ return { status: SIGNAL_MARKER_ABSENT, shape: SHAPE_NONE, files: [] };
619
+ }
620
+
621
+ const markerMatch = SCOPE_MARKER.exec(prompt);
622
+ if (markerMatch !== null) {
623
+ const after = prompt.slice(markerMatch.index + markerMatch[0].length);
624
+ // First fenced block after the marker. Non-greedy body; tolerates a language tag.
625
+ const fence = /```[^\n]*\n([\s\S]*?)```/.exec(after);
626
+ if (fence !== null) {
627
+ const files = collectScopePaths(fence[1].split('\n'));
628
+ if (files.length > 0) return { status: SIGNAL_EXTRACTED, shape: SHAPE_FENCED, files };
629
+ }
630
+ }
631
+
632
+ const inline = extractInlineScopeDeclaration(prompt);
633
+ if (inline.files.length > 0) return { status: SIGNAL_EXTRACTED, shape: SHAPE_INLINE, files: inline.files };
634
+ return {
635
+ status: markerMatch !== null || inline.seen ? SIGNAL_UNPARSEABLE : SIGNAL_MARKER_ABSENT,
636
+ shape: SHAPE_NONE,
637
+ files: [],
638
+ };
639
+ }
640
+
641
+ /**
642
+ * Extract the declared file scope from a dispatch prompt. Returns `[]` when
643
+ * nothing is confidently extractable — which the caller treats as ALLOW (matrix
644
+ * rows 5 and 6), never as an empty scope that could collide.
645
+ *
646
+ * Thin wrapper over {@link extractScopeSignal}: callers that only need the paths
647
+ * (and the tests that pin them) keep the original signature.
648
+ *
649
+ * @param {string} prompt
650
+ * @returns {string[]} repo-relative paths/globs, normalised, deduped, order preserved
651
+ */
652
+ export function extractScopeFromPrompt(prompt) {
653
+ return extractScopeSignal(prompt).files;
654
+ }
655
+
462
656
  /**
463
657
  * The dispatch's human description — the field the liveness probe matches
464
658
  * against the transcript's `tool_use` blocks (present in 147/147 measured
@@ -662,6 +856,26 @@ export function waveKeyOf(projectDir, sessionId, readFn) {
662
856
  return `${sessionId}|w?|?`;
663
857
  }
664
858
 
859
+ /**
860
+ * The wave NUMBER out of a `waveKeyOf()` key, for the telemetry record (#1092).
861
+ *
862
+ * Returns `null` — never `0` — when the key carries the `w?` fallback or a
863
+ * non-positive value, so the caller can OMIT the field. `.claude/rules/host-
864
+ * resources.md` § HR-105 in one line: an invented `wave: 0` would read as a real
865
+ * wave in every later query, exactly like the `wave_number` contract the
866
+ * quality-gate event already follows (`docs/events-schema.md`).
867
+ *
868
+ * @param {string} waveKey
869
+ * @returns {number|null}
870
+ */
871
+ function waveNumberOf(waveKey) {
872
+ if (typeof waveKey !== 'string') return null;
873
+ const seg = waveKey.split('|')[1];
874
+ if (typeof seg !== 'string' || !seg.startsWith('w')) return null;
875
+ const n = Number(seg.slice(1));
876
+ return Number.isSafeInteger(n) && n > 0 ? n : null;
877
+ }
878
+
665
879
  /**
666
880
  * Tracked files, for glob expansion inside `findScopeCollisions`. The library is
667
881
  * pure and must not spawn — supplying this is precisely the hook's job.
@@ -716,9 +930,59 @@ function clipPath(p) {
716
930
  // since #1020's lock landed, would also skip the lock's release `finally`.
717
931
  // ---------------------------------------------------------------------------
718
932
 
933
+ /**
934
+ * Record that ONE dispatch carried a given scope-signal class (#1092).
935
+ *
936
+ * ## Why this exists
937
+ *
938
+ * The no-signal path returned `{action: 'allow'}` with no `ledger` field, and
939
+ * the caller only writes `if (verdict.ledger)` — so nothing was written
940
+ * anywhere. Correct operation ("this agent legitimately has no scope") and total
941
+ * absence ("the coordinator injected nothing, or the marker never matched")
942
+ * produced BYTE-IDENTICAL evidence. Measured 2026-08-26 over 709 of this repo's
943
+ * subagent prompts: 136 yield paths, 47 carry a line-leading marker with no
944
+ * fenced block, 59 carry marker + fence but no surviving path. Without a counter
945
+ * none of those three classes is distinguishable from "the guard never ran".
946
+ *
947
+ * ## What it is NOT
948
+ *
949
+ * This is a SEND-SIDE counter. It observes what the COORDINATOR PUT IN THE
950
+ * PROMPT at dispatch time — never what the agent received, parsed, or obeyed. A
951
+ * non-zero `extracted` proves a scope was written into the prompt; it proves
952
+ * nothing about delivery or about the agent honouring it. Whether the injected
953
+ * block actually reached the agent's context is a RECEIVE-side question that
954
+ * only the subagent transcript can answer, and no number here may be read as
955
+ * that proof.
956
+ *
957
+ * Counter ONLY: no prompt text, no paths, no agent ids. The ledger is a shared
958
+ * working-copy artefact, and a scope-signal tally must not become a second,
959
+ * unreviewed copy of prompt content.
960
+ *
961
+ * Scoped to the wave, like `agents`: a new `waveKey` starts a fresh tally
962
+ * rather than accumulating across waves.
963
+ *
964
+ * @param {object|null} ledger previously recorded wave state
965
+ * @param {string} waveKey
966
+ * @param {'marker-absent'|'unparseable'|'extracted'} status
967
+ * @returns {{'marker-absent': number, unparseable: number, extracted: number}}
968
+ */
969
+ export function bumpSignalCounter(ledger, waveKey, status) {
970
+ const carried = (ledger !== null && ledger?.waveKey === waveKey && ledger.scopeSignals !== null
971
+ && typeof ledger.scopeSignals === 'object' && !Array.isArray(ledger.scopeSignals))
972
+ ? ledger.scopeSignals
973
+ : {};
974
+ const next = {};
975
+ for (const key of [SIGNAL_MARKER_ABSENT, SIGNAL_UNPARSEABLE, SIGNAL_EXTRACTED]) {
976
+ const prior = carried[key];
977
+ next[key] = Number.isSafeInteger(prior) && prior >= 0 ? prior : 0;
978
+ }
979
+ next[status] += 1;
980
+ return next;
981
+ }
982
+
719
983
  /**
720
984
  * @typedef {{action: 'allow'|'deny'|'warn', reason?: string, suggestion?: string,
721
- * ledger?: object|null, note?: string}} Verdict
985
+ * ledger?: object|null, note?: string, telemetry?: object}} Verdict
722
986
  */
723
987
 
724
988
  /**
@@ -743,14 +1007,54 @@ export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, coll
743
1007
  const toolInput = input?.tool_input;
744
1008
  if (toolInput === null || typeof toolInput !== 'object') return { action: 'allow' };
745
1009
 
746
- const files = extractScopeFromPrompt(toolInput.prompt);
1010
+ const at = typeof nowIso === 'string' ? nowIso : new Date().toISOString();
1011
+
1012
+ const signal = extractScopeSignal(toolInput.prompt);
1013
+ const files = signal.files;
1014
+ const id = agentIdOf(toolInput);
1015
+ const priorAgents = (ledger !== null && ledger?.waveKey === waveKey && Array.isArray(ledger.agents))
1016
+ ? ledger.agents
1017
+ : [];
1018
+
1019
+ // #1092 — the observability record this dispatch will leave in the ledger.
1020
+ // BUILT here, EMITTED by the caller: `decide()` is a pure function (§ stdout
1021
+ // discipline), and an `await` inside it would put an I/O failure on the
1022
+ // decision path. Counter-shaped by construction — a count, three enums and the
1023
+ // agent id, never a path, never a byte of the prompt (issue #1092 acceptance
1024
+ // criterion 3). `wave` is omitted rather than zeroed when unknown.
1025
+ const wave = waveNumberOf(waveKey);
1026
+ const telemetryFor = (ledgerResult, collisionCount = 0) => ({
1027
+ ...(wave === null ? {} : { wave }),
1028
+ agent_id: id.slice(0, MAX_AGENT_ID_CHARS),
1029
+ declared_path_count: files.length,
1030
+ injected: files.length > 0,
1031
+ shape: signal.shape,
1032
+ signal: signal.status,
1033
+ ledger_result: ledgerResult,
1034
+ collision_count: collisionCount,
1035
+ });
1036
+
747
1037
  // Rows 5 + 6: nothing confidently extractable → allow. Non-extractable is not
748
1038
  // a violation, and denying here would deny ~7 dispatches in 10.
749
- if (files.length === 0) return { action: 'allow' };
1039
+ //
1040
+ // #1092: the ALLOW now CARRIES a counter so it leaves a trace. Before this,
1041
+ // "no scope in the prompt" and "the guard never ran" were indistinguishable
1042
+ // after the fact. `agents` is carried through UNCHANGED — this dispatch
1043
+ // declared no scope, so it adds no scope claim to the wave.
1044
+ if (files.length === 0) {
1045
+ return {
1046
+ action: 'allow',
1047
+ telemetry: telemetryFor('no-scope'),
1048
+ ledger: {
1049
+ waveKey,
1050
+ updated: at,
1051
+ agents: priorAgents,
1052
+ scopeSignals: bumpSignalCounter(ledger, waveKey, signal.status),
1053
+ },
1054
+ };
1055
+ }
750
1056
 
751
- const id = agentIdOf(toolInput);
752
1057
  const desc = agentDescOf(toolInput);
753
- const at = typeof nowIso === 'string' ? nowIso : new Date().toISOString();
754
1058
  const self = { id, desc, files, at };
755
1059
 
756
1060
  // Row 7: ledger existed but was unparseable. Terminal warn — decided here and
@@ -760,7 +1064,8 @@ export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, coll
760
1064
  if (ledgerCorrupt) {
761
1065
  return {
762
1066
  action: 'warn',
763
- ledger: { waveKey, updated: at, agents: [self] },
1067
+ telemetry: telemetryFor('warn-ledger-corrupt'),
1068
+ ledger: { waveKey, updated: at, agents: [self], scopeSignals: bumpSignalCounter(null, waveKey, signal.status) },
764
1069
  note:
765
1070
  `${HOOK_NAME}: wave dispatch ledger was unreadable — scope-disjointness NOT checked for ` +
766
1071
  `"${id}"; the ledger has been reset, so the next dispatch is checked again.`,
@@ -792,6 +1097,7 @@ export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, coll
792
1097
  waveKey,
793
1098
  updated: at,
794
1099
  agents: [...others, self].slice(-MAX_LEDGER_AGENTS),
1100
+ scopeSignals: bumpSignalCounter(ledger, waveKey, signal.status),
795
1101
  };
796
1102
 
797
1103
  const collisions = Array.isArray(verdictLib?.collisions) ? verdictLib.collisions : [];
@@ -806,6 +1112,7 @@ export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, coll
806
1112
  if (verdictLib?.ok !== true && collisions.length === 0 && duplicateIds.length === 0) {
807
1113
  return {
808
1114
  action: 'warn',
1115
+ telemetry: telemetryFor('warn-not-evaluable'),
809
1116
  ledger: nextLedger,
810
1117
  note:
811
1118
  `${HOOK_NAME}: scope collision check not evaluable for "${id}" — dispatch allowed, ` +
@@ -818,7 +1125,7 @@ export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, coll
818
1125
  // this guard, and re-denying it would block an innocent third agent.
819
1126
  const mine = collisions.filter((c) => c?.a === id || c?.b === id);
820
1127
 
821
- if (mine.length === 0) return { action: 'allow', ledger: nextLedger };
1128
+ if (mine.length === 0) return { action: 'allow', telemetry: telemetryFor('allow'), ledger: nextLedger };
822
1129
 
823
1130
  // § Liveness — the review's HIGH finding. A collision with an agent that has
824
1131
  // ALREADY FINISHED is a sequential repair pass, not a race. The probe is called
@@ -844,7 +1151,13 @@ export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, coll
844
1151
  const kept = others.filter((a) => !finishedIds.has(a.id));
845
1152
  return {
846
1153
  action: 'allow',
847
- ledger: { waveKey, updated: at, agents: [...kept, self].slice(-MAX_LEDGER_AGENTS) },
1154
+ telemetry: telemetryFor('allow-finished', mine.length),
1155
+ ledger: {
1156
+ waveKey,
1157
+ updated: at,
1158
+ agents: [...kept, self].slice(-MAX_LEDGER_AGENTS),
1159
+ scopeSignals: nextLedger.scopeSignals,
1160
+ },
848
1161
  };
849
1162
  }
850
1163
 
@@ -876,7 +1189,7 @@ export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, coll
876
1189
 
877
1190
  // Deliberately NOT persisting the ledger on deny: the dispatch did not happen,
878
1191
  // so recording it would make the retry-after-fix look like a duplicate.
879
- return { action: 'deny', reason, suggestion };
1192
+ return { action: 'deny', telemetry: telemetryFor('deny', mine.length), reason, suggestion };
880
1193
  }
881
1194
 
882
1195
  // ---------------------------------------------------------------------------
@@ -964,6 +1277,26 @@ async function main() {
964
1277
  verdict = cycle();
965
1278
  }
966
1279
 
1280
+ // #1092 — one ledger line per dispatch DECISION, awaited BEFORE the terminal
1281
+ // emit: `emitAllow`/`emitDeny`/`emitWarn` all call `process.exit()`, which
1282
+ // discards a pending append (the same ordering `hooks/enforce-scope.mjs`
1283
+ // documents at its own event site). Best-effort in BOTH directions — the
1284
+ // import is dynamic so a checkout without `scripts/lib/events.mjs` degrades to
1285
+ // silence instead of crashing (§ Import safety), and the catch guarantees a
1286
+ // failed write cannot change the verdict or the exit code.
1287
+ if (verdict.telemetry) {
1288
+ try {
1289
+ const { emitEvent, sessionAttribution } = await import(
1290
+ pathToFileURL(path.join(PLUGIN_ROOT, 'scripts', 'lib', 'events.mjs')).href
1291
+ );
1292
+ await emitEvent(
1293
+ SCOPE_EVENT,
1294
+ { hook: HOOK_NAME, ...verdict.telemetry, ...sessionAttribution(projectDir) },
1295
+ { repoRoot: projectDir }
1296
+ );
1297
+ } catch { /* observability is best-effort — it never blocks the decision */ }
1298
+ }
1299
+
967
1300
  if (verdict.action === 'deny') return emitDeny(verdict.reason, verdict.suggestion);
968
1301
  if (verdict.action === 'warn') return emitWarn(verdict.note);
969
1302
  return emitAllow();
@@ -11,11 +11,13 @@
11
11
  * 2. Read JSON payload from stdin: { tool_name, tool_input: { skill }, session_id }.
12
12
  * 3. Belt-and-suspenders guard: if tool_name !== "Skill", exit 0 immediately.
13
13
  * 4. Build a 'selected' record and call appendSkillInvocation().
14
- * 5. Daily-fallback telemetry flush check (Epic #841, #844) — non-blocking:
15
- * when a bounded offline queue has aged past 24h AND consent resolves to
16
- * send, spawn a detached child that runs `telemetry _flush`. Cheap by
17
- * construction (env kill-switch pre-check queue+state stat consent)
18
- * and never loads the roster on the hot hook path.
14
+ * 5. Daily-fallback telemetry flush check (Epic #841, #844; #1138) — non-blocking:
15
+ * when more than 24h have passed since the last successful flush AND either
16
+ * the offline queue is non-empty or a session has completed since, AND
17
+ * consent resolves to send, spawn a detached child that runs
18
+ * `telemetry _flush`. Cheap by construction (env kill-switch pre-check
19
+ * state stat → queue/ledger probe → consent) and never loads the roster on
20
+ * the hot hook path.
19
21
  * 6. Output: nothing on stdout. Diagnostic errors to stderr only.
20
22
  *
21
23
  * Exit codes: 0 always (informational, never blocking).
@@ -40,7 +42,9 @@ import { loadOwnerConfig } from '../scripts/lib/owner-yaml.mjs';
40
42
  // Constants
41
43
  // ---------------------------------------------------------------------------
42
44
 
43
- const JSONL_PATH = path.join(SO_PROJECT_DIR, '.orchestrator', 'metrics', 'skill-invocations.jsonl');
45
+ const METRICS_DIR = path.join(SO_PROJECT_DIR, '.orchestrator', 'metrics');
46
+
47
+ const JSONL_PATH = path.join(METRICS_DIR, 'skill-invocations.jsonl');
44
48
 
45
49
  /** Absolute path to the telemetry CLI (carries the hidden `_flush` subcommand). */
46
50
  const TELEMETRY_CLI_PATH = path.resolve(
@@ -95,8 +99,9 @@ function readStdinJson() {
95
99
  /**
96
100
  * Non-blocking daily-fallback flush trigger. Ordered cheapest-first:
97
101
  * 1. env kill-switches (no I/O) — DO_NOT_TRACK / SO_TELEMETRY_DISABLED.
98
- * 2. shouldDailyFlush (one telemetry.json read + one queue stat) the common
99
- * case (empty queue) returns here WITHOUT loading owner.yaml or the roster.
102
+ * 2. shouldDailyFlush (one telemetry.json read; a queue stat + a sessions.jsonl
103
+ * read only once the 24h gate has passed) the common case (flushed within
104
+ * 24h) returns here WITHOUT loading owner.yaml or the roster.
100
105
  * 3. full consent resolution — spawn a detached `telemetry _flush` only when it
101
106
  * resolves to send.
102
107
  *
@@ -108,6 +113,8 @@ function readStdinJson() {
108
113
  * @param {number} [opts.now] Reference time epoch-ms (default Date.now()).
109
114
  * @param {string} [opts.statePath] telemetry.json path override (test injection).
110
115
  * @param {string} [opts.queuePath] queue path override (test injection).
116
+ * @param {string} [opts.metricsDir] Metrics dir for shouldDailyFlush's catch-up
117
+ * probe (default: this repo's metrics dir).
111
118
  * @returns {{ spawned: boolean, reason: string }}
112
119
  */
113
120
  export function maybeSpawnDailyFlush({
@@ -116,6 +123,7 @@ export function maybeSpawnDailyFlush({
116
123
  now = Date.now(),
117
124
  statePath,
118
125
  queuePath,
126
+ metricsDir = METRICS_DIR,
119
127
  } = {}) {
120
128
  try {
121
129
  // 1. Cheapest gate: env kill-switches, no file I/O.
@@ -123,8 +131,11 @@ export function maybeSpawnDailyFlush({
123
131
  return { spawned: false, reason: 'disabled-env' };
124
132
  }
125
133
 
126
- // 2. Cheap backlog check — bails out before owner.yaml load in the common case.
127
- if (!shouldDailyFlush({ statePath, queuePath, now })) {
134
+ // 2. Backlog / catch-up check — bails out before owner.yaml load in the
135
+ // common case. Since #1138 this is due EITHER on a stale non-empty queue
136
+ // OR on a session that completed after the last successful flush, so the
137
+ // fallback can originate a ping instead of only retrying failed sends.
138
+ if (!shouldDailyFlush({ statePath, queuePath, metricsDir, now })) {
128
139
  return { spawned: false, reason: 'not-due' };
129
140
  }
130
141
 
@@ -10,5 +10,11 @@
10
10
  "command": "node \"${CLAUDE_PLUGIN_ROOT}\"/scripts/lib/convergence-monitor.mjs --tail",
11
11
  "description": "Wave convergence telemetry monitor — tails .orchestrator/metrics/events.jsonl + sessions.jsonl, surfaces convergence signals (shrinking diff, pass-rate plateau, velocity).",
12
12
  "when": "on-skill-invoke:wave-executor"
13
+ },
14
+ {
15
+ "name": "wave-transcript-tail",
16
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}\"/scripts/lib/wave-transcript-tail.mjs --tail",
17
+ "description": "FA-1 wave supervision — tails live subagent transcripts of the OWN session and emits stagnation_detected(source:tail) to .orchestrator/metrics/events.jsonl (#1114)",
18
+ "when": "on-skill-invoke:wave-executor"
13
19
  }
14
20
  ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "session-orchestrator",
3
- "version": "3.22.0",
3
+ "version": "3.23.0",
4
4
  "description": "Loop engineering for AI coding agents — turn ad-hoc sessions into a repeatable research → plan → wave-execute → close loop with verification gates. Runs on Claude Code, Codex CLI, Cursor, and Pi.",
5
5
  "type": "module",
6
6
  "homepage": "https://session-orchestrator.com",
@@ -0,0 +1,12 @@
1
+ ---
2
+ description: Outside-in product audit as a deep session — 7 read-only roles check what the site promises against what the code does, what a user experiences, what arrives by mail, and what the data says is used. Writes a dossier; needs a per-repo journey-manifest.
3
+ argument-hint: "[manifest-path]"
4
+ ---
5
+
6
+ # /journey-audit
7
+
8
+ Use the Session Orchestrator command definition at `commands/journey-audit.md`.
9
+
10
+ Arguments: $@
11
+
12
+ Read that command file and follow it exactly. When it references `$ARGUMENTS`, substitute the arguments above. Keep all Session Orchestrator platform fallbacks intact.
package/rules/_index.md CHANGED
@@ -30,9 +30,17 @@ baseline project type (`nextjs-saas`, `express-service`, `docker-service`,
30
30
 
31
31
  ## always-on (vendored to every consumer repo)
32
32
 
33
- - `always-on/parallel-sessions.md` — PSA-001/002/003/004 multi-session discipline
33
+ - `always-on/parallel-sessions.md` — PSA-001..007 multi-session discipline (detect / pause / destructive-ops / commit / STATE.md lock / grep-verification / subagent git-write ban)
34
34
  - `always-on/commit-discipline.md` — atomic commits, stage-by-name, no `git add .`
35
35
  - `always-on/npm-quality-gates.md` — the typecheck + test + lint triad before commit
36
+ - `always-on/verification-before-completion.md` — VBC-001..005: no completion claim without fresh, quoted verification evidence
37
+ - `always-on/receiving-review.md` — RCR-001..009: the 6-step review-handling pattern, four-class finding triage, push-back posture
38
+ - `always-on/ask-via-tool.md` — AUQ-001..006: route before you ask; structured options over prose question lists
39
+ - `always-on/test-value.md` — TV-001..005: name the bug a test catches, or do not write it; deletion is a feature
40
+ - `always-on/build-value.md` — BV-001..004: the seven-rung build ladder, the four protections never simplified away
41
+ - `always-on/cross-session-messaging.md` — CSM-001..005: messaging is transport, not shared state; no permission laundering
42
+ - `always-on/loop-and-monitor.md` — LM-001..008: routing between `/goal`, Workflows, Channels, Monitor, `/loop`, and Routines
43
+ - `always-on/bash-harness-pitfalls.md` — six false-green shell-harness failure classes (path-scoped via its own `globs:`/`paths:` frontmatter)
36
44
 
37
45
  ## opt-in-stack (vendored on match)
38
46