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
@@ -4,41 +4,203 @@
4
4
  *
5
5
  * Extracted from `hooks/pre-bash-templates-first.mjs` (#519) so the
6
6
  * issue-budget hook (`hooks/pre-bash-issue-budget.mjs`) reuses the SAME
7
- * regex + argument-boundary semantics instead of maintaining a second,
7
+ * matcher + argument-boundary semantics instead of maintaining a second,
8
8
  * silently-diverging copy. Both hooks share the PreToolUse Bash matcher and
9
9
  * run sequentially, so a divergence here would mean one hook gates a command
10
10
  * the other waves through.
11
11
  *
12
- * ZERO IMPORTS by designa clean leaf so any hook can adopt it without
13
- * pulling the scripts/lib layer in.
12
+ * ## Statement splitting (#1106)why this is no longer a single regex
14
13
  *
15
- * LOAD-BEARING semantics (contract, mirrors the pre-#-extraction behaviour):
16
- * MATCH: `gh|glab` + `pr|mr|issue` + `create|new`, anchored at start with
17
- * optional leading whitespace, word-boundary on the trailing edge
18
- * (so `created` / `news` do NOT match).
19
- * NO MATCH: edit operations (`gh pr edit`, `glab mr edit`) deliberately
20
- * out of scope per the #519 PRD § 2 Out-of-Scope.
14
+ * Until #1106 the matcher was one regex anchored at the START of the whole
15
+ * command string (`/^\s*(gh|glab)\s+(pr|mr|issue)\s+(create|new)\b/`). Every
16
+ * shape that does not BEGIN with the CLI name was therefore invisible, and the
17
+ * most common agent shape — a `cd` into the repo, then the create call — is
18
+ * exactly such a shape. Measured 2026-08-23 in a live session: four issues were
19
+ * created as `cd <repo>\nglab issue create …` and the session's issue-budget
20
+ * counter file recorded `count: 0` (then the flat
21
+ * `.orchestrator/runtime/issue-budget.json`; one file per session under
22
+ * `.orchestrator/runtime/issue-budget/<hash>.json` since #1141). The
23
+ * `issue-budget` cap was not circumvented — for that command form it had never
24
+ * existed.
25
+ *
26
+ * The fix is to split the command into SHELL STATEMENTS and test each one, so
27
+ * a create call anywhere in a chain is seen. The hard part is not the splitting
28
+ * but the false-positive edge: `echo "glab issue create"`, a `#`-comment, and
29
+ * `grep -rn "glab issue create" docs/` all contain the literal words and must
30
+ * still NOT match. Naive splitting on `\n` / `&&` / `;` / `||` gets those wrong
31
+ * whenever a separator or a create-shaped line sits inside a quoted string or a
32
+ * here-doc body.
33
+ *
34
+ * So the splitting is delegated to `scripts/lib/command-blocker.mjs`
35
+ * (`tokenizeCommand` + `splitChainSegments`) — the lexer the destructive-command
36
+ * guard already runs on every Bash call. It carries quote state, `#`-comments,
37
+ * here-doc bodies, line continuations, arithmetic `<<`, and newline-as-separator,
38
+ * each of which was a measured bypass of that guard at some point. Writing a
39
+ * second shell splitter here would re-open every one of those holes in a file
40
+ * whose entire reason to exist is "no second, silently-diverging copy".
41
+ *
42
+ * That import is a deliberate revision of this file's former "ZERO IMPORTS by
43
+ * design" note: the leaf property bought nothing that a shared lexer does not,
44
+ * and the cost is one `node:path`-only module (~2 ms import, no side effects).
45
+ *
46
+ * LOAD-BEARING semantics (contract):
47
+ * MATCH: a SHELL STATEMENT whose RESOLVED VERB is `gh|glab` and whose two
48
+ * following tokens are `pr|mr|issue` + `create|new`, anywhere in
49
+ * the command chain. Statements are separated by unquoted `;` `&&`
50
+ * `||` `|` `&` and newline, and their command-position reserved
51
+ * words (`do`, `then`, `{`, …) are stripped by the lexer (#1145),
52
+ * so a create inside a loop body, a brace group, an `if` branch or
53
+ * a spaced subshell is a statement like any other. "Resolved verb"
54
+ * means: after leading `VAR=value` assignments, after transparent
55
+ * process wrappers (`nohup`, `command`, `sudo`, `env`, `timeout`,
56
+ * `xargs`, `exec`), and basename-normalised, so an absolute path
57
+ * (`/opt/homebrew/bin/glab`) resolves to `glab` (#1145).
58
+ * NO MATCH: the words inside a quoted string, a `#` comment, or a here-doc
59
+ * body (they are data, not a command); a single token that merely
60
+ * CONTAINS the three words (`"glab issue create"` runs a binary of
61
+ * that literal name, it is not a create call); edit operations
62
+ * (`gh pr edit`) — deliberately out of scope per the #519 PRD § 2;
63
+ * a create statement carrying `--help`, which prints help and
64
+ * creates nothing (#1145 — see {@link hasHelpFlag}).
65
+ *
66
+ * ## Verb resolution (#1145) — why the head is no longer `tokens.slice(0, 3)`
67
+ *
68
+ * #1106 made the matcher see every STATEMENT; it still read each statement's
69
+ * head as the raw first three tokens. So a statement whose first token is not
70
+ * literally `gh`/`glab` stayed invisible even when the shell would run exactly
71
+ * that binary. Measured 2026-08-26 against this file on `main`:
72
+ *
73
+ * nohup glab issue create --title g → isIssueCreate false
74
+ * /opt/homebrew/bin/glab issue create … → isIssueCreate false
75
+ * GITLAB_HOST=y glab issue create … → isIssueCreate false
76
+ *
77
+ * None of those is a segmentation defect — `splitChainSegments` already yields
78
+ * the whole statement, and `resolveSegmentVerb` already resolves all three
79
+ * (`nohup`/`command`/`sudo`/`env` are transparent wrappers in `WRAPPER_UNWRAP`,
80
+ * an absolute path is basename-normalised, and leading `VAR=value` assignments
81
+ * are skipped). The matcher simply never asked. It now does, via the same call
82
+ * shape the two sibling consumers use — `hooks/pre-bash-destructive-guard.mjs`
83
+ * (`parseRmTargets`, `commandHasRecursiveForceRm`) and
84
+ * `scripts/lib/scope-gate.mjs` (`extractBashWriteTargets`): split, then
85
+ * `resolveSegmentVerb`, then read the head at the RESOLVED index.
86
+ *
87
+ * ## Named ceilings (BV-004)
88
+ *
89
+ * - A create call reached only through a command substitution
90
+ * (`x=$(glab issue create …)`) is NOT matched: the substitution body never
91
+ * becomes a segment of its own. Unchanged since before #1106. Likewise a
92
+ * create carried as an interpreter PAYLOAD (`bash -c 'glab issue create'`)
93
+ * — `resolveSegmentVerb` reports the payload string, but this matcher does
94
+ * not recurse into it. Revisit either if the shape shows up in the overflow
95
+ * triage of a per-session counter file
96
+ * (`.orchestrator/runtime/issue-budget/<hash>.json`, #1141) or in a
97
+ * transcript census of real create calls.
98
+ * - A paren glued to the verb (`(glab issue create …)`) is not reached: it
99
+ * lexes as the single word `(glab`. This is `command-blocker.mjs`'s own
100
+ * named ceiling on `COMMAND_POSITION_KEYWORDS` (#1145), inherited here
101
+ * rather than re-patched — peeling the paren belongs in the lexer, whose
102
+ * tokens five consumers read positionally.
103
+ * - {@link matchesBypass} still compares from token index 0, NOT from the
104
+ * resolved verb index. So a wrapped create (`nohup gh pr create --dry-run`)
105
+ * is gated even when `gh pr create --dry-run` is a bypass entry. That is
106
+ * the fail-CLOSED direction of this widening and is deliberate: making an
107
+ * operator escape hatch reachable through a wrapper is the one change here
108
+ * that would LOOSEN a gate. Revisit only if an operator reports a live
109
+ * bypass entry going dead on a wrapped command.
21
110
  */
22
111
 
112
+ import {
113
+ tokenizeCommand,
114
+ splitChainSegments,
115
+ resolveSegmentVerb,
116
+ } from '../../scripts/lib/command-blocker.mjs';
117
+
23
118
  /**
24
- * Matches the canonical `gh` / `glab` issue/PR/MR creation invocations.
25
- * Anchored at start (^) with optional leading whitespace to catch indented
26
- * shell snippets. Word-boundary at the end avoids false positives on tokens
27
- * like `created` or `news`.
119
+ * The accepted create shape, applied to the first THREE tokens of a shell
120
+ * statement (joined by single spaces the lexer has already stripped quoting
121
+ * and collapsed whitespace).
122
+ *
123
+ * Kept as the single source of truth for the accepted vocabulary — the part
124
+ * most likely to drift — rather than being spread across three token
125
+ * comparisons. The leading `^\s*` and the trailing `\b` are retained so the
126
+ * regex still describes the same shape it did before #1106 (`created` / `news`
127
+ * do not match), and so a caller holding the old export still gets the old
128
+ * single-statement behaviour.
28
129
  */
29
130
  export const CREATE_REGEX = /^\s*(gh|glab)\s+(pr|mr|issue)\s+(create|new)\b/;
30
131
 
31
132
  /**
32
- * Parse a shell command into its VCS-create shape.
133
+ * Split a raw shell command into statements, each as a token array.
134
+ *
135
+ * Thin, named wrapper over the shared lexer so the two call sites below (and
136
+ * any future one) cannot disagree about what "a statement" means.
33
137
  *
34
138
  * @param {string} command
139
+ * @returns {Array<Array<{ text: string, quoted: boolean }>>}
140
+ */
141
+ function statementsOf(command) {
142
+ if (typeof command !== 'string' || command.length === 0) return [];
143
+ try {
144
+ return splitChainSegments(tokenizeCommand(command));
145
+ } catch {
146
+ // Fail OPEN, matching every other error path in the two consuming hooks:
147
+ // a lexer that throws must not wedge every Bash call. Worst case is a
148
+ // missed enforcement.
149
+ return [];
150
+ }
151
+ }
152
+
153
+ /**
154
+ * `--help` short-circuits a cobra command (both `gh` and `glab` are cobra
155
+ * CLIs): the help text is printed and the command body never runs, so
156
+ * `glab issue create --help` — the exact call an agent makes to discover the
157
+ * flag names BEFORE filing — creates nothing. Counting it charged the session
158
+ * cap for a non-creation and, at the boundary, denied the next real issue.
159
+ * This is the narrowing that pairs with the #1145 widening.
160
+ *
161
+ * NAMED CEILING (BV-004): only the LONG form is honoured. `-h` is cobra's
162
+ * DEFAULT help shorthand but a subcommand may rebind it, and a rebound `-h`
163
+ * would turn this narrowing into a cap bypass on a command that really does
164
+ * create. `--help` is reserved by cobra and cannot be rebound, so it is safe
165
+ * where `-h` is not. Revisit if `gh`/`glab` ever ship a create subcommand
166
+ * whose `--help` is not the help flag.
167
+ *
168
+ * Quoted tokens do not count: `--title "run with --help"` is a title.
169
+ *
170
+ * @param {Array<{ text: string, quoted: boolean }>} tokens
171
+ * @param {number} from — first index AFTER the three head words
172
+ * @returns {boolean}
173
+ */
174
+ function hasHelpFlag(tokens, from) {
175
+ for (let i = from; i < tokens.length; i++) {
176
+ const t = tokens[i];
177
+ if (!t.quoted && t.text === '--help') return true;
178
+ }
179
+ return false;
180
+ }
181
+
182
+ /**
183
+ * Test one READING of a statement (a resolved verb plus the two tokens that
184
+ * follow it) against {@link CREATE_REGEX}.
185
+ *
186
+ * The three head words are checked for embedded whitespace first. Without that
187
+ * guard, `"glab issue create" --title x` — ONE token that happens to contain
188
+ * all three words, i.e. an attempt to run a binary literally named
189
+ * `glab issue create` — would join back into a matching head. The lexer strips
190
+ * quotes, so token boundaries are the only remaining evidence that the words
191
+ * arrived as three separate arguments.
192
+ *
193
+ * @param {string} verb — basename-normalised verb from resolveSegmentVerb
194
+ * @param {Array<{ text: string, quoted: boolean }>} tokens — the whole statement
195
+ * @param {number} index — token index the verb resolved to
35
196
  * @returns {{ host: 'github'|'gitlab', kind: 'pr'|'mr'|'issue', verb: 'create'|'new' } | null}
36
- * `null` when the command is not a `gh`/`glab` create/new invocation.
37
197
  */
38
- export function matchVcsCreate(command) {
39
- if (typeof command !== 'string' || command.length === 0) return null;
40
- const m = command.match(CREATE_REGEX);
198
+ function matchReading(verb, tokens, index) {
199
+ const head = [verb, tokens[index + 1]?.text, tokens[index + 2]?.text];
200
+ if (head.some((t) => typeof t !== 'string' || /\s/.test(t))) return null;
201
+ const m = head.join(' ').match(CREATE_REGEX);
41
202
  if (!m) return null;
203
+ if (hasHelpFlag(tokens, index + 3)) return null;
42
204
  return {
43
205
  host: m[1] === 'gh' ? 'github' : 'gitlab',
44
206
  kind: /** @type {'pr'|'mr'|'issue'} */ (m[2]),
@@ -46,6 +208,68 @@ export function matchVcsCreate(command) {
46
208
  };
47
209
  }
48
210
 
211
+ /**
212
+ * Test one statement's token array against {@link CREATE_REGEX}, reading the
213
+ * head at the RESOLVED verb index rather than at token 0 (#1145).
214
+ *
215
+ * BOTH readings of an ambiguous segment are judged. `resolveSegmentVerb`
216
+ * returns an `alt` reading whenever it skipped an unknown wrapper dash-flag
217
+ * that might have taken a value (`env -Q x glab issue create` — is `x` the
218
+ * verb, or `-Q`'s operand?). Its own contract tells consumers that JUDGE
219
+ * (as opposed to consumers that walk operands positionally) to consider both;
220
+ * matching in either reading is the fail-CLOSED direction for a gate, since a
221
+ * match means "count it / require a template", never "let it through".
222
+ *
223
+ * @param {Array<{ text: string, quoted: boolean }>} tokens
224
+ * @returns {{ host: 'github'|'gitlab', kind: 'pr'|'mr'|'issue', verb: 'create'|'new' } | null}
225
+ */
226
+ function matchStatement(tokens) {
227
+ if (!Array.isArray(tokens) || tokens.length < 3) return null;
228
+ let resolved;
229
+ try {
230
+ resolved = resolveSegmentVerb(tokens);
231
+ } catch {
232
+ return null; // fail OPEN, same posture as statementsOf
233
+ }
234
+ for (const reading of [resolved, resolved.alt]) {
235
+ if (!reading || typeof reading.verb !== 'string' || reading.index < 0) continue;
236
+ const shape = matchReading(reading.verb, tokens, reading.index);
237
+ if (shape) return shape;
238
+ }
239
+ return null;
240
+ }
241
+
242
+ /**
243
+ * Find the first statement in the command chain that is a VCS-create call.
244
+ *
245
+ * Returns the shape AND the statement's tokens, because `extractTitle` must
246
+ * read `--title` off the SAME statement the gate fired on — reading it off the
247
+ * whole command silently returns a neighbouring statement's title (e.g. the
248
+ * `--title` of a preceding `glab issue list --search …`).
249
+ *
250
+ * @param {string} command
251
+ * @returns {{ shape: { host: string, kind: string, verb: string },
252
+ * tokens: Array<{ text: string, quoted: boolean }> } | null}
253
+ */
254
+ function findCreateStatement(command) {
255
+ for (const tokens of statementsOf(command)) {
256
+ const shape = matchStatement(tokens);
257
+ if (shape) return { shape, tokens };
258
+ }
259
+ return null;
260
+ }
261
+
262
+ /**
263
+ * Parse a shell command into its VCS-create shape.
264
+ *
265
+ * @param {string} command
266
+ * @returns {{ host: 'github'|'gitlab', kind: 'pr'|'mr'|'issue', verb: 'create'|'new' } | null}
267
+ * `null` when no statement in the command is a `gh`/`glab` create/new call.
268
+ */
269
+ export function matchVcsCreate(command) {
270
+ return findCreateStatement(command)?.shape ?? null;
271
+ }
272
+
49
273
  /**
50
274
  * Determine which host the command targets. `gh` → "github", `glab` → "gitlab".
51
275
  * Thin wrapper kept for call-site readability in pre-bash-templates-first.mjs.
@@ -70,10 +294,107 @@ export function isIssueCreate(command) {
70
294
  }
71
295
 
72
296
  /**
73
- * True when the command starts with any of the bypass patterns. Bypass match
74
- * is a prefix check with a word/EOL boundary on the trailing edge — this
75
- * prevents trivial bypass via prefix-inclusion (e.g. a policy entry
76
- * "gh issue create --label bot" must not match "gh issue create --label botanical").
297
+ * True when the matched issue-create statement sits inside a LOOP BODY, i.e.
298
+ * the command creates an unknown number of issues (#1145).
299
+ *
300
+ * ## Why this is a separate question from {@link isIssueCreate}
301
+ *
302
+ * `for t in a b c; do glab issue create --title $t; done` is textually ONE
303
+ * create statement that files THREE issues. Since #1145 the segmenter strips
304
+ * `do`, so the statement is finally visible to the cap — but charging it `1`
305
+ * would be a worse failure than the pre-#1145 miss: the ledger would then carry
306
+ * a number it KNOWS is wrong, and `for i in $(seq 1 50)` would file 50 issues
307
+ * against a count of 1. The true multiplicity is not computable at hook time
308
+ * (`$(cat list)`, `"$@"`, a glob) — so this function reports the FACT and the
309
+ * hook decides the POLICY (`hooks/pre-bash-issue-budget.mjs` G3b denies in
310
+ * `strict`, warns in `warn`).
311
+ *
312
+ * ## Mechanism — derived from the lexer, not re-derived here
313
+ *
314
+ * `do` and `done` open and close every bash loop body (`for`, `while`,
315
+ * `until`, `select` all use them) and both are in `command-blocker.mjs`'s
316
+ * `COMMAND_POSITION_KEYWORDS`. A token disappears between `tokenizeCommand`
317
+ * and `splitChainSegments` for exactly two reasons: it was a chain SEPARATOR,
318
+ * or it was a reserved word in COMMAND POSITION. `do`/`done` are not
319
+ * separators — so a `do` that is absent from the segments is provably a
320
+ * command-position `do`, and `echo do` / `--title done` (kept, because they
321
+ * are ordinary arguments) are provably not. Identity is object identity: the
322
+ * splitter pushes the very token objects the tokenizer produced.
323
+ *
324
+ * That is why this does not re-implement the separator scan: re-deriving
325
+ * command position here would be the second, silently-diverging shell parser
326
+ * this whole file exists to prevent.
327
+ *
328
+ * Depth is counted so a create AFTER a loop is not implicated:
329
+ * `while x; do echo a; done; glab issue create` → depth 0 at the create.
330
+ *
331
+ * NAMED CEILING (BV-004): this is coupled to `do`/`done` remaining in
332
+ * `COMMAND_POSITION_KEYWORDS`. If either is removed there, the token stops
333
+ * disappearing and this silently returns `false` — an under-report, i.e. back
334
+ * to charging 1 for N. The unit test `tests/hooks/vcs-create-matcher.test.mjs`
335
+ * pins the coupling directly. Revisit if that set is ever narrowed.
336
+ *
337
+ * @param {string} command
338
+ * @returns {boolean} false when the command has no issue-create statement at all
339
+ */
340
+ export function isLoopedIssueCreate(command) {
341
+ if (typeof command !== 'string' || command.length === 0) return false;
342
+
343
+ let tokens;
344
+ let segments;
345
+ try {
346
+ tokens = tokenizeCommand(command);
347
+ segments = splitChainSegments(tokens);
348
+ } catch {
349
+ return false; // fail OPEN, same posture as statementsOf
350
+ }
351
+
352
+ // The create statement's HEAD token, by object identity — the position in the
353
+ // raw stream at which the loop depth must be read.
354
+ //
355
+ // Deliberately the FIRST create statement of any kind, then a kind check —
356
+ // exactly what isIssueCreate() does via matchVcsCreate(). Searching for the
357
+ // first ISSUE create instead would diverge on `glab mr create … ; glab issue
358
+ // create …`: the hook's G3 would look at the mr, this at the issue, and the
359
+ // two would disagree about which statement they are judging.
360
+ let headTok = null;
361
+ for (const seg of segments) {
362
+ const shape = matchStatement(seg);
363
+ if (!shape) continue;
364
+ if (shape.kind === 'issue') headTok = seg[0];
365
+ break;
366
+ }
367
+ if (!headTok) return false;
368
+
369
+ const kept = new Set(segments.flat());
370
+ let depth = 0;
371
+ for (const tok of tokens) {
372
+ if (tok === headTok) return depth > 0;
373
+ if (tok.quoted || kept.has(tok)) continue; // an argument, or a separator
374
+ if (tok.text === 'do') depth += 1;
375
+ else if (tok.text === 'done' && depth > 0) depth -= 1;
376
+ }
377
+ return false;
378
+ }
379
+
380
+ /**
381
+ * True when any STATEMENT in the command starts with one of the bypass
382
+ * patterns.
383
+ *
384
+ * Comparison is token-prefix, not string-prefix: the pattern is lexed with the
385
+ * same lexer as the command, and every one of its tokens must equal the
386
+ * statement's token at the same index. That preserves the property the previous
387
+ * string-prefix + boundary check existed for — a policy entry
388
+ * `gh issue create --label bot` must not match `--label botanical`, because
389
+ * `bot !== 'botanical'` as tokens — while dropping the hand-rolled boundary
390
+ * scan and, incidentally, tolerating whitespace and quoting differences between
391
+ * policy text and command text.
392
+ *
393
+ * Statement-scoped for the same reason the matcher is (#1106): after splitting,
394
+ * `cd /repo && gh pr create --dry-run` is a gated create call, and the
395
+ * operator's `gh pr create --dry-run` bypass entry must still exempt it.
396
+ * Checking the bypass against the whole command string would leave that entry
397
+ * dead exactly for the shapes the widening newly gates.
77
398
  *
78
399
  * @param {string} command
79
400
  * @param {string[]} bypassPatterns
@@ -84,14 +405,37 @@ export function matchesBypass(command, bypassPatterns) {
84
405
  if (!Array.isArray(bypassPatterns) || bypassPatterns.length === 0) {
85
406
  return false;
86
407
  }
87
- const stripped = command.replace(/^\s+/, '');
408
+
409
+ // The bypass is checked against THE CREATE STATEMENT, never against any
410
+ // statement in the chain. Getting this wrong is a security regression, and it
411
+ // was one: scoping the MATCH to statements while leaving the BYPASS a boolean
412
+ // over the whole command let an appended decoy lift the gate for a real call.
413
+ // Reproduced 2026-08-23 against the live policy (`glab issue create --label ci`
414
+ // is a real bypass_patterns entry):
415
+ // "glab issue create --title A" -> DENY
416
+ // "glab issue create --title REAL; glab issue create --label ci" -> ALLOW
417
+ // The second one creates an untemplated issue titled REAL. The pre-#1106 code
418
+ // was not vulnerable here — it prefix-matched the whole command, so a TRAILING
419
+ // pattern could not match. Widening the matcher without narrowing this opened it.
420
+ //
421
+ // The documented intent still holds: `cd /repo && gh pr create --dry-run` must
422
+ // remain exempt when `gh pr create --dry-run` is a bypass entry — that is the
423
+ // create statement itself, so it matches.
424
+ const create = findCreateStatement(command);
425
+ if (!create) return false;
426
+ const tokens = create.tokens;
427
+
88
428
  for (const pat of bypassPatterns) {
89
429
  if (typeof pat !== 'string' || pat.length === 0) continue;
90
- const patStripped = pat.replace(/^\s+/, '');
91
- if (!stripped.startsWith(patStripped)) continue;
92
- // Boundary check: next character must be whitespace, EOL, or absent.
93
- const nextChar = stripped.charAt(patStripped.length);
94
- if (nextChar === '' || /\s/.test(nextChar)) return true;
430
+ const patStatements = statementsOf(pat);
431
+ // A pattern that lexes to nothing (whitespace-only, comment-only) would
432
+ // prefix-match EVERY statement treat it as no pattern at all.
433
+ if (patStatements.length === 0) continue;
434
+ const patTokens = patStatements[0].map((t) => t.text);
435
+ if (patTokens.length === 0) continue;
436
+
437
+ if (tokens.length < patTokens.length) continue;
438
+ if (patTokens.every((text, i) => tokens[i].text === text)) return true;
95
439
  }
96
440
  return false;
97
441
  }
@@ -99,21 +443,36 @@ export function matchesBypass(command, bypassPatterns) {
99
443
  /**
100
444
  * Best-effort extraction of the `--title` value from a create command, for
101
445
  * human-readable overflow bookkeeping. Handles `--title "x"`, `--title 'x'`,
102
- * `--title=x` and the bare unquoted form. Returns `null` when no title flag
103
- * is present.
446
+ * `--title=x` and the bare unquoted form uniformly, because the lexer has
447
+ * already resolved quoting by the time this reads the tokens.
104
448
  *
105
- * Deliberately NOT a shell parser: the value is only ever used as a display
106
- * label in an overflow record, never re-executed.
449
+ * Scoped to the create STATEMENT when there is one (#1106): reading `--title`
450
+ * off the whole command picks up a neighbouring statement's flag and labels the
451
+ * overflow record with the wrong title. Falls back to the whole token stream
452
+ * when no statement is a create call, preserving the previous lenient behaviour
453
+ * for callers that ask about a non-create command.
454
+ *
455
+ * Deliberately NOT a shell parser beyond that: the value is only ever used as a
456
+ * display label in an overflow record, never re-executed.
107
457
  *
108
458
  * @param {string} command
109
459
  * @returns {string|null}
110
460
  */
111
461
  export function extractTitle(command) {
112
462
  if (typeof command !== 'string' || command.length === 0) return null;
113
- const m =
114
- command.match(/--title[=\s]+"((?:[^"\\]|\\.)*)"/) ??
115
- command.match(/--title[=\s]+'([^']*)'/) ??
116
- command.match(/--title[=\s]+(\S+)/);
117
- if (!m) return null;
118
- return m[1].replace(/\\(["\\])/g, '$1').trim() || null;
463
+
464
+ const found = findCreateStatement(command);
465
+ const tokens = found ? found.tokens : statementsOf(command).flat();
466
+
467
+ for (let i = 0; i < tokens.length; i++) {
468
+ const text = tokens[i].text;
469
+ if (text === '--title') {
470
+ const value = tokens[i + 1]?.text;
471
+ return typeof value === 'string' ? value.trim() || null : null;
472
+ }
473
+ if (text.startsWith('--title=')) {
474
+ return text.slice('--title='.length).trim() || null;
475
+ }
476
+ }
477
+ return null;
119
478
  }
@@ -9,6 +9,10 @@
9
9
  * G1 tool filter — only Edit/Write/MultiEdit are gated
10
10
  * G2 file_path present + string
11
11
  * G3 wave-scope.json exists
12
+ * G3b (#1123) the manifest belongs to THIS session — a manifest that
13
+ * PROVABLY names another live session in this shared working copy is not
14
+ * ours to enforce; allow + emit one event. Runs AFTER the parse so a
15
+ * corrupt manifest still fails closed.
12
16
  * G4 path-guard gate enabled
13
17
  * G5 enforcement != "off"
14
18
  * G5b (#792) allowlist-first: an EXPLICIT absolute allowedPaths entry that
@@ -98,6 +102,9 @@ let readJson;
98
102
  let classifyEmptyScope;
99
103
  let suggestForEmptyScope;
100
104
  let sessionStartedAtMs;
105
+ // #1123 — "is this manifest even mine?" (G3b).
106
+ let readOwnSessionIds;
107
+ let classifyManifestSession;
101
108
 
102
109
  const PLUGIN_ROOT = path.resolve(import.meta.dirname, '..');
103
110
 
@@ -154,6 +161,10 @@ async function bootstrap() {
154
161
  // `scope-gate` transitively, so this adds no new failure mode — a broken
155
162
  // scope-gate already banners GUARD INACTIVE through that edge.
156
163
  scopeGate: { specifier: lib('scope-gate.mjs') },
164
+ // #1123. Bound through the same loader as every other repo dependency, so
165
+ // a SyntaxError in the identity module banners GUARD INACTIVE instead of
166
+ // link-crashing the hook into a silent fail-open (#993).
167
+ sessionIdentity: { specifier: lib('session-identity/own-session.mjs') },
157
168
  },
158
169
  {
159
170
  hookName: HOOK_NAME,
@@ -169,6 +180,7 @@ async function bootstrap() {
169
180
  ({ findScopeFile, pathMatchesPattern, suggestForScopeViolation } = modules.hardening);
170
181
  ({ readJson } = modules.common);
171
182
  ({ classifyEmptyScope, suggestForEmptyScope, sessionStartedAtMs } = modules.scopeGate);
183
+ ({ readOwnSessionIds, classifyManifestSession } = modules.sessionIdentity);
172
184
  }
173
185
 
174
186
  async function main() {
@@ -220,6 +232,58 @@ async function main() {
220
232
  scope = {};
221
233
  }
222
234
 
235
+ // -------------------------------------------------------------------------
236
+ // Gate 3b (#1123) — is this manifest even MINE?
237
+ //
238
+ // `wave-scope.json` lives in the WORKING COPY, not in the session. Two live
239
+ // sessions sharing one checkout read the same file, so session A's manifest
240
+ // enforced session B's writes: measured 2026-08-22 (#1082), where a Discovery
241
+ // wave's `allowedPaths: []` — which `wave-loop.md` prescribes for EVERY
242
+ // Discovery wave — denied every write of an unrelated parallel session, with
243
+ // a deny reason that could only tell it to fix a wave plan it does not own.
244
+ //
245
+ // Deliberately runs AFTER the parse block: a corrupt manifest yields `{}`,
246
+ // hence no ids, hence `'unknown'` — and keeps failing closed. A gate that
247
+ // peeked at the raw bytes instead would let a truncated manifest disarm the
248
+ // guard.
249
+ //
250
+ // `'own'` and `'unknown'` fall through completely unchanged. A legacy manifest
251
+ // without a `session` field is `'unknown'` and stays enforced: only what is
252
+ // PROVABLY foreign is treated as foreign.
253
+ //
254
+ // ACCEPTED RESIDUAL, named rather than hidden: `session` is a plain field in a
255
+ // file any process in this working copy can write, so writing a foreign id
256
+ // into it switches this guard off for that manifest. That is the SAME power
257
+ // `enforcement: "off"` already grants in the same file — this gate adds no new
258
+ // authority, and the manifest is the coordinator's own artefact either way.
259
+ {
260
+ const ownIds = readOwnSessionIds(projectRoot, { hookInput: input });
261
+ const { verdict, manifestIds } = classifyManifestSession(scope, ownIds);
262
+ if (verdict === 'foreign') {
263
+ // Observability only, and deliberately NOT emitWarn: this branch is hit on
264
+ // every single Edit of the non-owning session, so a stderr line per write
265
+ // would be noise the operator learns to ignore. One event per decision
266
+ // point, awaited before emitAllow() — emitAllow() calls process.exit(),
267
+ // which would discard a pending append.
268
+ try {
269
+ const { emitEvent } = await import('../scripts/lib/events.mjs');
270
+ await emitEvent(
271
+ 'orchestrator.scope.foreign_session_ignored',
272
+ {
273
+ hook: HOOK_NAME,
274
+ manifest: scopePath,
275
+ manifest_session: manifestIds,
276
+ own_session: [...ownIds],
277
+ wave: scope.wave,
278
+ file_path: filePath,
279
+ },
280
+ { repoRoot: projectRoot },
281
+ );
282
+ } catch { /* observability is best-effort — never blocks the decision */ }
283
+ return emitAllow();
284
+ }
285
+ }
286
+
223
287
  const enforcement = scope.enforcement ?? 'strict';
224
288
  const allowedPaths = Array.isArray(scope.allowedPaths) ? scope.allowedPaths : [];
225
289
  // A PRESENT-but-non-array `allowedPaths` (#558: null / string / object) is a
@@ -7,7 +7,7 @@
7
7
  "hooks": [
8
8
  {
9
9
  "type": "command",
10
- "command": "echo '🎯 Session Orchestrator v3.22.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
10
+ "command": "echo '🎯 Session Orchestrator v3.23.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
11
11
  "async": false
12
12
  },
13
13
  {