session-orchestrator 3.16.0 → 3.19.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 (220) 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/rules/030-wave-execution.mdc +17 -1
  5. package/CHANGELOG.md +130 -412
  6. package/README.md +17 -12
  7. package/SECURITY.md +190 -27
  8. package/agents/AGENTS.md +20 -3
  9. package/agents/code-implementer.md +6 -6
  10. package/agents/db-specialist.md +1 -1
  11. package/agents/qa-strategist.md +31 -6
  12. package/agents/schemas/qa-strategist.schema.json +27 -0
  13. package/agents/schemas/test-writer.schema.json +60 -2
  14. package/agents/security-reviewer.md +1 -1
  15. package/agents/session-reviewer.md +1 -1
  16. package/agents/test-writer.md +29 -10
  17. package/agents/ui-developer.md +1 -1
  18. package/commands/contract-version-bump.md +28 -0
  19. package/commands/portfolio.md +1 -1
  20. package/docs/README.md +2 -1
  21. package/docs/USER-GUIDE.md +8 -3
  22. package/docs/ci-setup.md +121 -7
  23. package/docs/codex-setup.md +1 -1
  24. package/docs/components.md +7 -7
  25. package/docs/cursor-setup.md +22 -9
  26. package/docs/events-schema.md +5 -1
  27. package/docs/instruction-delivery.md +444 -0
  28. package/docs/pi-setup.md +1 -1
  29. package/docs/rule-authoring.md +58 -9
  30. package/docs/session-config-reference.md +306 -6
  31. package/docs/session-config-template.md +66 -3
  32. package/docs/telemetry/telemetry-claims.md +204 -0
  33. package/docs/telemetry.md +158 -0
  34. package/hooks/_lib/guard-source-loader.mjs +467 -0
  35. package/hooks/_lib/lock-bootstrap.mjs +21 -0
  36. package/hooks/_lib/vcs-create-matcher.mjs +119 -0
  37. package/hooks/config-protection.mjs +0 -0
  38. package/hooks/enforce-commands.mjs +10 -2
  39. package/hooks/hooks-codex.json +1 -1
  40. package/hooks/hooks-cursor.json +11 -2
  41. package/hooks/hooks-pi.json +10 -0
  42. package/hooks/hooks.json +21 -1
  43. package/hooks/on-session-end.mjs +178 -18
  44. package/hooks/on-session-start.mjs +23 -0
  45. package/hooks/post-bash-write-verify.mjs +977 -0
  46. package/hooks/post-subagent-discovery-validator.mjs +256 -41
  47. package/hooks/pre-bash-destructive-guard.mjs +525 -160
  48. package/hooks/pre-bash-issue-budget.mjs +167 -0
  49. package/hooks/pre-bash-sessions-ledger-guard.mjs +627 -0
  50. package/hooks/pre-bash-templates-first.mjs +96 -63
  51. package/hooks/skill-invocation-telemetry.mjs +109 -10
  52. package/hooks/subagent-telemetry.mjs +527 -37
  53. package/package.json +16 -3
  54. package/pi/prompts/contract-version-bump.md +12 -0
  55. package/rules/README.md +32 -0
  56. package/scripts/archive-closed-prds.mjs +12 -22
  57. package/scripts/autopilot-multi.mjs +103 -20
  58. package/scripts/backfill-abandoned-sessions.mjs +160 -4
  59. package/scripts/check-doc-consistency.sh +17 -1
  60. package/scripts/compute-grounding-injection.sh +18 -3
  61. package/scripts/dialectic-deriver.mjs +7 -2
  62. package/scripts/eval-session.mjs +50 -9
  63. package/scripts/fleet-instruction-scan.mjs +141 -0
  64. package/scripts/lib/auto-dialectic.mjs +11 -2
  65. package/scripts/lib/auto-dream.mjs +16 -5
  66. package/scripts/lib/autopilot/mr-draft.mjs +31 -1
  67. package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
  68. package/scripts/lib/backlog-scan.mjs +39 -6
  69. package/scripts/lib/blocked-commands-policy.mjs +340 -0
  70. package/scripts/lib/build-live-signals.mjs +7 -4
  71. package/scripts/lib/ci-status-banner.mjs +75 -12
  72. package/scripts/lib/claude-md-budget-lint.mjs +283 -34
  73. package/scripts/lib/command-blocker.mjs +1013 -58
  74. package/scripts/lib/config/config-protection.mjs +2 -1
  75. package/scripts/lib/config/context-coverage.mjs +82 -0
  76. package/scripts/lib/config/drift-check.mjs +9 -1
  77. package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
  78. package/scripts/lib/config/issue-budget.mjs +123 -0
  79. package/scripts/lib/config/moc-staleness.mjs +98 -0
  80. package/scripts/lib/config/reconcile.mjs +21 -0
  81. package/scripts/lib/config/section-extractor.mjs +121 -1
  82. package/scripts/lib/config/worktree-orphans.mjs +138 -0
  83. package/scripts/lib/config-schema.mjs +23 -3
  84. package/scripts/lib/config.mjs +32 -0
  85. package/scripts/lib/context-coverage-banner.mjs +223 -0
  86. package/scripts/lib/convergence-monitor.mjs +49 -3
  87. package/scripts/lib/description-surface.mjs +535 -0
  88. package/scripts/lib/dispatcher/enumerate.mjs +136 -30
  89. package/scripts/lib/dispatcher/rank.mjs +22 -8
  90. package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
  91. package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
  92. package/scripts/lib/eval/engine.mjs +47 -5
  93. package/scripts/lib/events.mjs +59 -7
  94. package/scripts/lib/evolve/autonomy-verdict.mjs +5 -0
  95. package/scripts/lib/evolve/autopilot-effectiveness.mjs +54 -7
  96. package/scripts/lib/gates/gate-full.mjs +15 -3
  97. package/scripts/lib/gates/gate-helpers.mjs +132 -6
  98. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
  99. package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
  100. package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
  101. package/scripts/lib/handover-gate.mjs +7 -3
  102. package/scripts/lib/harness-audit/categories/category4.mjs +22 -5
  103. package/scripts/lib/instruction-budget-guard.mjs +402 -51
  104. package/scripts/lib/io.mjs +345 -10
  105. package/scripts/lib/issue-budget.mjs +269 -0
  106. package/scripts/lib/issue-close-strip-labels.mjs +39 -9
  107. package/scripts/lib/label-scope.mjs +47 -0
  108. package/scripts/lib/learnings/schema.mjs +43 -3
  109. package/scripts/lib/lock-reaper.mjs +1 -2
  110. package/scripts/lib/memory-proposals/schema.mjs +36 -1
  111. package/scripts/lib/moc-staleness-banner.mjs +267 -0
  112. package/scripts/lib/peer-discovery.mjs +645 -0
  113. package/scripts/lib/pi-hook-bridge.mjs +146 -17
  114. package/scripts/lib/product-repo-detect.mjs +9 -8
  115. package/scripts/lib/project-hygiene.mjs +432 -0
  116. package/scripts/lib/quality-gate.mjs +167 -0
  117. package/scripts/lib/recommendations-v0.mjs +1 -1
  118. package/scripts/lib/reconcile/eligibility.mjs +1 -1
  119. package/scripts/lib/reconcile/emitter.mjs +23 -4
  120. package/scripts/lib/reconcile/engine.mjs +147 -39
  121. package/scripts/lib/reconcile/idempotency.mjs +114 -14
  122. package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
  123. package/scripts/lib/resource-probe/evaluate.mjs +70 -4
  124. package/scripts/lib/resource-probe.mjs +19 -0
  125. package/scripts/lib/rule-loader.mjs +6 -0
  126. package/scripts/lib/scope-baseline.mjs +564 -0
  127. package/scripts/lib/scope-gate.mjs +399 -98
  128. package/scripts/lib/session-close-backfill.mjs +61 -6
  129. package/scripts/lib/session-end/phase-skip.mjs +1 -0
  130. package/scripts/lib/session-end/worktree-orphan-sweep.mjs +252 -0
  131. package/scripts/lib/session-id.mjs +221 -41
  132. package/scripts/lib/session-lock.mjs +304 -6
  133. package/scripts/lib/session-schema/constants.mjs +22 -3
  134. package/scripts/lib/session-schema/filters.mjs +88 -0
  135. package/scripts/lib/session-schema/validator.mjs +16 -0
  136. package/scripts/lib/session-schema.mjs +1 -0
  137. package/scripts/lib/sessions-integrity-banner.mjs +294 -0
  138. package/scripts/lib/sessions-staleness-banner.mjs +121 -12
  139. package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
  140. package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
  141. package/scripts/lib/skill-health/join.mjs +35 -9
  142. package/scripts/lib/spiral-carryover.mjs +142 -30
  143. package/scripts/lib/state-md/mission-status.mjs +53 -3
  144. package/scripts/lib/subagents-schema.mjs +43 -9
  145. package/scripts/lib/telemetry/anon-id.mjs +141 -0
  146. package/scripts/lib/telemetry/consent.mjs +299 -0
  147. package/scripts/lib/telemetry/paths.mjs +27 -0
  148. package/scripts/lib/telemetry/queue.mjs +287 -0
  149. package/scripts/lib/telemetry/schema.mjs +384 -0
  150. package/scripts/lib/telemetry/sync.mjs +312 -0
  151. package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
  152. package/scripts/lib/tests-src-ratio.mjs +484 -0
  153. package/scripts/lib/validate/check-agents.mjs +56 -0
  154. package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
  155. package/scripts/lib/validate/check-rules.mjs +217 -35
  156. package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
  157. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  158. package/scripts/lib/validate-vendored-rules.mjs +10 -2
  159. package/scripts/lib/vault-archive.mjs +17 -2
  160. package/scripts/lib/vault-backfill/glab.mjs +8 -0
  161. package/scripts/lib/vault-mirror/process.mjs +30 -0
  162. package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
  163. package/scripts/lib/vault-status/board-writer.mjs +63 -5
  164. package/scripts/lib/vault-status/narrative-mirror.mjs +13 -7
  165. package/scripts/lib/vcs-repo-spec.mjs +362 -0
  166. package/scripts/lib/wave-resource-gate.mjs +115 -11
  167. package/scripts/lib/worktree/listing.mjs +44 -7
  168. package/scripts/mcp-server.sh +32 -6
  169. package/scripts/measure-context-overhead.sh +151 -0
  170. package/scripts/memory-propose.mjs +72 -9
  171. package/scripts/print-applicable-rules.mjs +51 -12
  172. package/scripts/release.mjs +534 -0
  173. package/scripts/run-quality-gate.mjs +123 -5
  174. package/scripts/telemetry.mjs +250 -0
  175. package/scripts/validate-wave-scope.mjs +182 -17
  176. package/scripts/vault-integration-watcher.mjs +32 -10
  177. package/skills/_shared/config-reading.md +2 -2
  178. package/skills/bootstrap/fast-template.md +1 -1
  179. package/skills/claude-md-drift-check/checker.mjs +145 -28
  180. package/skills/contract-version-bump/SKILL.md +219 -0
  181. package/skills/discovery/SKILL.md +4 -4
  182. package/skills/discovery/issue-templates.md +11 -11
  183. package/skills/discovery/probes-audit.md +1 -1
  184. package/skills/discovery/probes-feature.md +1 -1
  185. package/skills/discovery/probes-session.md +26 -5
  186. package/skills/ecosystem-health/SKILL.md +1 -1
  187. package/skills/ecosystem-health/wizard.md +4 -4
  188. package/skills/evolve/SKILL.md +1 -0
  189. package/skills/gitlab-ops/SKILL.md +20 -12
  190. package/skills/gitlab-portfolio/SKILL.md +2 -2
  191. package/skills/hook-development/SKILL.md +1 -1
  192. package/skills/mode-selector/SKILL.md +1 -1
  193. package/skills/npm-publish/SKILL.md +97 -0
  194. package/skills/plan/SKILL.md +5 -5
  195. package/skills/plan/mode-feature.md +4 -4
  196. package/skills/plan/mode-new.md +10 -10
  197. package/skills/plan/mode-retro.md +1 -1
  198. package/skills/quality-gates/SKILL.md +1 -1
  199. package/skills/reconcile/SKILL.md +21 -4
  200. package/skills/session-end/SKILL.md +108 -14
  201. package/skills/session-end/discovery-scan.md +4 -2
  202. package/skills/session-end/drift-operations.md +4 -4
  203. package/skills/session-end/metrics-collection.md +13 -0
  204. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  205. package/skills/session-end/phase-3-6-tail.md +2 -1
  206. package/skills/session-end/plan-verification.md +5 -2
  207. package/skills/session-end/vault-operations.md +1 -1
  208. package/skills/session-end/verification-checklist.md +1 -1
  209. package/skills/session-plan/SKILL.md +6 -2
  210. package/skills/session-plan/wave-template.md +2 -0
  211. package/skills/session-start/SKILL.md +149 -7
  212. package/skills/session-start/phase-4-5-resource-health.md +15 -2
  213. package/skills/test-runner/SKILL.md +2 -2
  214. package/skills/vault-sync/SKILL.md +1 -1
  215. package/skills/vault-sync/package-lock.json +3 -3
  216. package/skills/vault-sync/validator.mjs +228 -40
  217. package/skills/wave-executor/SKILL.md +5 -2
  218. package/skills/wave-executor/circuit-breaker.md +2 -0
  219. package/skills/wave-executor/wave-loop.md +163 -10
  220. package/templates/_shared/loop.md +4 -4
@@ -0,0 +1,467 @@
1
+ /**
2
+ * guard-source-loader.mjs — degradation-VISIBLE module loading for deny-capable hooks.
3
+ *
4
+ * ## The defect this closes (#992)
5
+ *
6
+ * `hooks/hooks.json` runs `sh run-node.sh <hook>.mjs`, which `exec node "$@"`.
7
+ * A deny-capable hook that STATICALLY imports a repo module inherits that
8
+ * module's parse failure at ESM LINK time — before the first statement of the
9
+ * hook body runs. Consequences, all measured:
10
+ *
11
+ * - the hook's own `main().catch(...)` handler is structurally unreachable
12
+ * (it only covers runtime errors inside `main()`),
13
+ * - node terminates with EXIT 1, **stdout 0 bytes**, stderr = a SyntaxError
14
+ * stack trace.
15
+ *
16
+ * Under the exit-0 PreToolUse protocol (#906) the exit code no longer carries a
17
+ * decision: ALLOW is `exit 0` + empty stdout, DENY is `exit 0` + exactly one
18
+ * `hookSpecificOutput` line. A crashed hook produces 0 bytes on stdout and is
19
+ * therefore, ON THE ONLY DECISION-BEARING CHANNEL, indistinguishable from an
20
+ * explicit `emitAllow()`. `git reset --hard` runs through. One broken
21
+ * `scripts/lib/command-blocker.mjs` silently disarms 4 of the 7 deny-capable
22
+ * hooks at once (destructive-guard, enforce-scope, enforce-commands,
23
+ * sessions-ledger-guard) — Bash *and* Edit/Write enforcement.
24
+ *
25
+ * ## The two parts, and why the banner is the base
26
+ *
27
+ * 1. **Loud once-per-session banner on EVERY load failure.** The damage was not
28
+ * "no fallback", it was an *invisible* outage: an ambiguous harness error
29
+ * line that wave agents read as a crash rather than a policy block, and
30
+ * began routing around. The banner therefore names the CONSEQUENCE ("guard
31
+ * INACTIVE" / "running against HEAD, not your working tree"), not just a
32
+ * file path.
33
+ * 2. **`git show HEAD:<path>` fallback — for `command-blocker.mjs` ONLY.** That
34
+ * module is dependency-free (its single import is `node:path`) and hence
35
+ * `data:`-URL loadable. Deliberately NOT generalised to the guard's other 6
36
+ * repo imports: those were never audited for RELATIVE imports, which a
37
+ * `data:` URL cannot resolve — that would need its own recursive resolver.
38
+ * For those modules part 1 (banner) stands alone.
39
+ *
40
+ * **The coupling is mandatory: the fallback must never fire silently.** A
41
+ * successful HEAD fallback banners too ("running against HEAD, not the working
42
+ * tree"), otherwise a visible hole is traded for an invisible semantic drift.
43
+ *
44
+ * **The HEAD fallback presupposes a git checkout.** It shells out to
45
+ * `git show HEAD:<path>` in the PLUGIN ROOT (not the project dir). For an
46
+ * npm-installed plugin — or any tarball/vendored copy without a `.git` — that
47
+ * command fails by construction, so half of this module is structurally
48
+ * unavailable there: EVERY load failure degrades straight to the GUARD INACTIVE
49
+ * banner below. That is the designed fail-loud direction, not a regression, but
50
+ * it means the banner is the ONLY protection npm consumers get.
51
+ *
52
+ * ## Why the marker is not a mute switch (post-#992 hardening)
53
+ *
54
+ * The once-per-session throttle used to key on the mere EXISTENCE of a
55
+ * predictably-named tmp file, and it gated BOTH banner classes. Measured: an
56
+ * agent could `touch` that path (not a destructive command), then append a
57
+ * syntax error to a `scripts/lib/` module (not on any redirect denylist) and the
58
+ * total-outage banner went silent while the decision channel said ALLOW —
59
+ * strictly worse than the pre-#992 crash, which at least printed a stack trace.
60
+ * Two changes close it:
61
+ *
62
+ * - **The `inactive` (total-outage) banner is NOT throttled at all.** Once per
63
+ * call, every call. Throttling is for the *degraded* case (guard armed,
64
+ * evaluating HEAD); a guard that is OFF must keep saying so. Noise there is
65
+ * strictly cheaper than a silent disarm.
66
+ * - **Where the marker remains (`head-fallback`), CONTENT decides, not
67
+ * existence**: a scoped payload (magic + kind + banner key + projectDir
68
+ * digest + boot epoch) that an empty `touch` cannot forge, opened
69
+ * `O_CREAT|O_EXCL|O_NOFOLLOW` for write and `O_NOFOLLOW` for read — so the
70
+ * marker path can no longer be aimed at an arbitrary file via a symlink,
71
+ * and a foreign or stale file makes the banner REPEAT rather than vanish.
72
+ *
73
+ * Honest residual: an attacker who runs as the same uid and reproduces the
74
+ * payload format can still suppress the `head-fallback` banner. That is why the
75
+ * class that actually means "unprotected" no longer depends on the marker.
76
+ *
77
+ * Measured cost: `git show` median 4.2 ms (n=21) against a hook allow-path
78
+ * median of 61 ms (n=15) — +11 ms, and only in the defect case. Zero in normal
79
+ * operation: nothing here runs unless an import already threw.
80
+ *
81
+ * ## Hard constraint on this file
82
+ *
83
+ * Everything below runs on the error path of a module-loading failure, so it
84
+ * MUST NOT import any repo module that could itself be the broken one —
85
+ * `node:*` builtins only. Keep it that way. An on-disk source cache under
86
+ * `.orchestrator/runtime/` was considered and REJECTED: its cold-start failure
87
+ * mode is exactly the target scenario (a fresh worktree mid-merge), and it
88
+ * creates a deletable trust anchor inside the writable repo.
89
+ *
90
+ * Known, accepted gap: a COMMITTED conflict marker breaks the HEAD copy too —
91
+ * then only the banner fires. That is why the banner is the base and the
92
+ * fallback the topping.
93
+ *
94
+ * Issue: #992.
95
+ */
96
+
97
+ import fs from 'node:fs';
98
+ import os from 'node:os';
99
+ import path from 'node:path';
100
+ import crypto from 'node:crypto';
101
+ import { execFileSync } from 'node:child_process';
102
+
103
+ /** TTL for the session-id-less marker fallback, mirroring `run-node.sh` (6h). */
104
+ const BANNER_TTL_MS = 6 * 60 * 60 * 1000;
105
+
106
+ /** Marker payload discriminator — an empty `touch` matches none of it. */
107
+ const MARKER_MAGIC = 'session-orchestrator/guard-banner';
108
+ const MARKER_VERSION = 1;
109
+
110
+ /** Bucket width for the boot-epoch field (seconds), and its accepted drift. */
111
+ const BOOT_BUCKET_S = 10;
112
+
113
+ /** Repo-relative path of the one module that gets the HEAD fallback. */
114
+ const COMMAND_BLOCKER_REL = 'scripts/lib/command-blocker.mjs';
115
+
116
+ /**
117
+ * The FULL export set `pre-bash-destructive-guard.mjs` needs from
118
+ * `command-blocker.mjs` — the single source of truth for the shape check.
119
+ *
120
+ * It lives here, and ONLY here, on purpose. The hook no longer destructures the
121
+ * module (it holds the namespace object and calls through it), so there is no
122
+ * second list to drift out of sync: a seventh export is added once, right here,
123
+ * and both the working-tree and the HEAD copy are validated against it.
124
+ *
125
+ * Why the check must cover all of them: it used to assert 2 of the 6, so a HEAD
126
+ * copy OLDER than the working tree — the normal case when a newly added export
127
+ * is the very thing that broke, i.e. the #982/#983/#988 history — passed as
128
+ * "DEGRADED, enforcement IS still armed" and then allowed every command with an
129
+ * `⚠ internal error — <fn> is not a function` line. A fallback that cannot
130
+ * enforce must banner as a TOTAL failure, never as "still armed".
131
+ */
132
+ const COMMAND_BLOCKER_EXPORTS = [
133
+ 'tokenizeCommand',
134
+ 'commandMatchesBlocked',
135
+ 'extractRedirectTargets',
136
+ 'redirectRuleMatches',
137
+ 'resolveSegmentVerb',
138
+ 'splitChainSegments',
139
+ ];
140
+
141
+ /**
142
+ * Resolve the once-per-session banner key.
143
+ *
144
+ * At ESM link-time failure stdin has NOT been read yet, so the payload's
145
+ * `session_id` is unavailable — and `readStdin` lives in `io.mjs`, which may
146
+ * itself be the broken module. So the id is read with `node:fs` alone from
147
+ * `.orchestrator/session.lock`; when that fails we fall back to a time-TTL
148
+ * marker exactly like `run-node.sh` does.
149
+ *
150
+ * @param {string} projectDir
151
+ * @returns {{key: string, ttl: boolean}} `ttl: true` means "key is not
152
+ * session-scoped — apply the 6h time TTL instead of pure existence".
153
+ */
154
+ function resolveBannerKey(projectDir) {
155
+ try {
156
+ const raw = fs.readFileSync(path.join(projectDir, '.orchestrator', 'session.lock'), 'utf8');
157
+ const id = JSON.parse(raw)?.session_id;
158
+ if (typeof id === 'string' && id.length > 0) {
159
+ return { key: id.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 64), ttl: false };
160
+ }
161
+ } catch {
162
+ /* no lock, unreadable, or malformed — fall through to the time TTL */
163
+ }
164
+ return { key: 'ttl', ttl: true };
165
+ }
166
+
167
+ /** Per-project marker scope — the digest half of the marker file name. */
168
+ function markerScope(projectDir) {
169
+ return crypto.createHash('sha256').update(projectDir).digest('hex').slice(0, 12);
170
+ }
171
+
172
+ /**
173
+ * Coarse boot epoch (unix seconds, bucketed), used to invalidate markers left
174
+ * behind by a previous boot in a persistent `/tmp`. Bucketing absorbs the
175
+ * sub-second jitter between two `os.uptime()` reads; the reader additionally
176
+ * accepts ±1 bucket, so a call straddling a bucket edge is not a false miss.
177
+ */
178
+ function bootEpochBucket() {
179
+ return Math.round((Date.now() / 1000 - os.uptime()) / BOOT_BUCKET_S);
180
+ }
181
+
182
+ /**
183
+ * Absolute path of the marker file that makes the banner once-per-session.
184
+ *
185
+ * Lives in the OS temp dir, NOT in the repo: the error path must not presuppose
186
+ * repo write access, and a marker inside the repo would be another deletable
187
+ * trust anchor. Built with `path.join(os.tmpdir(), …)` rather than string
188
+ * concatenation on `$TMPDIR` — that env var carries a trailing slash on macOS
189
+ * and is unset on a Linux container.
190
+ *
191
+ * The path is intentionally still derivable (it must be, across processes) —
192
+ * which is exactly why the path alone no longer decides anything: see
193
+ * `readMarker` for the payload the file has to carry.
194
+ *
195
+ * @param {string} scope - `markerScope(projectDir)`, so parallel repos (and
196
+ * per-test fixture dirs) never share a marker.
197
+ * @param {string} kind - banner class (`head-fallback`).
198
+ * @param {string} key
199
+ * @returns {string}
200
+ */
201
+ function bannerMarkerPath(scope, kind, key) {
202
+ return path.join(os.tmpdir(), `session-orchestrator-guard-${kind}-${scope}-${key}`);
203
+ }
204
+
205
+ /**
206
+ * Read + VALIDATE a marker. Returns its write time, or `null` for "no marker of
207
+ * ours here" — which makes the banner fire.
208
+ *
209
+ * Every rejection path is deliberately the fail-LOUD one. A file that exists but
210
+ * does not carry this exact payload (an empty `touch`, a foreign file, a marker
211
+ * from another project, kind, session, or boot) is NOT a suppression signal.
212
+ *
213
+ * `O_NOFOLLOW` matters on both halves of the marker lifecycle: without it the
214
+ * predictable path is an arbitrary-file-write primitive (aim a symlink at any
215
+ * file the session can write, and the marker write truncates it) and an
216
+ * arbitrary-file-READ oracle.
217
+ *
218
+ * @param {string} marker
219
+ * @param {{kind: string, key: string, scope: string}} expected
220
+ * @returns {{at: number}|null}
221
+ */
222
+ function readMarker(marker, expected) {
223
+ let fd;
224
+ try {
225
+ fd = fs.openSync(marker, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
226
+ const st = fs.fstatSync(fd);
227
+ // Regular file, single link, owned by us. A hard link or a foreign-uid file
228
+ // means somebody else controls this path — never honour it.
229
+ if (!st.isFile() || st.nlink !== 1) return null;
230
+ if (typeof process.getuid === 'function' && st.uid !== process.getuid()) return null;
231
+
232
+ const raw = fs.readFileSync(fd, 'utf8');
233
+ const rec = JSON.parse(raw);
234
+ if (rec?.magic !== MARKER_MAGIC || rec?.v !== MARKER_VERSION) return null;
235
+ if (rec.kind !== expected.kind || rec.key !== expected.key || rec.scope !== expected.scope) {
236
+ return null;
237
+ }
238
+ if (Math.abs(Number(rec.boot) - bootEpochBucket()) > 1) return null;
239
+
240
+ const at = Date.parse(rec.at);
241
+ if (!Number.isFinite(at) || at > Date.now() + 60_000) return null; // no future stamps
242
+ return { at };
243
+ } catch {
244
+ return null; // absent, symlinked (ELOOP), unreadable, or malformed
245
+ } finally {
246
+ if (fd !== undefined) {
247
+ try { fs.closeSync(fd); } catch { /* nothing to do on the error path */ }
248
+ }
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Create the marker exclusively. Never overwrites: `O_EXCL` fails when anything
254
+ * already sits at the path, and a pre-planted file is therefore left alone —
255
+ * the banner then simply repeats on every call, which is the safe direction.
256
+ *
257
+ * @param {string} marker
258
+ * @param {{kind: string, key: string, scope: string}} fields
259
+ */
260
+ function writeMarker(marker, fields) {
261
+ let fd;
262
+ try {
263
+ fd = fs.openSync(
264
+ marker,
265
+ fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW ?? 0),
266
+ 0o600
267
+ );
268
+ fs.writeSync(
269
+ fd,
270
+ `${JSON.stringify({
271
+ magic: MARKER_MAGIC,
272
+ v: MARKER_VERSION,
273
+ ...fields,
274
+ boot: bootEpochBucket(),
275
+ at: new Date().toISOString(),
276
+ })}\n`
277
+ );
278
+ } catch {
279
+ /* unwritable tmp / already present: emit anyway, repeatedly if need be */
280
+ } finally {
281
+ if (fd !== undefined) {
282
+ try { fs.closeSync(fd); } catch { /* nothing to do on the error path */ }
283
+ }
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Write stderr unconditionally. stdout is NEVER an option here: stdout is the
289
+ * decision channel and an allow REQUIRES an empty stdout (see
290
+ * `tests/_helpers/hook-decision.mjs`) — a banner there would corrupt every
291
+ * decision this hook makes.
292
+ *
293
+ * @param {string} message
294
+ */
295
+ function writeBanner(message) {
296
+ process.stderr.write(message.endsWith('\n') ? message : `${message}\n`);
297
+ }
298
+
299
+ /**
300
+ * Write a guard-DEGRADATION banner to stderr, at most once per session.
301
+ *
302
+ * Throttling is confined to the degraded class on purpose: there the guard is
303
+ * still armed, so a per-call banner is pure noise an operator learns to ignore.
304
+ * The total-outage banner does NOT come through here — see
305
+ * `emitGuardInactiveBanner`.
306
+ *
307
+ * @param {{projectDir: string, kind: string, message: string}} opts
308
+ * @returns {boolean} whether the banner was emitted this call.
309
+ */
310
+ function emitGuardBannerOnce({ projectDir, kind, message }) {
311
+ const { key, ttl } = resolveBannerKey(projectDir);
312
+ const scope = markerScope(projectDir);
313
+ const marker = bannerMarkerPath(scope, kind, key);
314
+ const fields = { kind, key, scope };
315
+
316
+ const existing = readMarker(marker, fields);
317
+ if (existing) {
318
+ if (!ttl) return false; // session-keyed: already bannered this session
319
+ if (Date.now() - existing.at < BANNER_TTL_MS) return false;
320
+ // Session-id-less TTL expiry: our own marker, verified above — refresh it.
321
+ try { fs.unlinkSync(marker); } catch { /* keep going; the write may still fail */ }
322
+ }
323
+
324
+ writeMarker(marker, fields);
325
+ writeBanner(message);
326
+ return true;
327
+ }
328
+
329
+ /**
330
+ * Read a repo file's committed content via `git show HEAD:<relPath>`.
331
+ *
332
+ * @param {string} repoRoot
333
+ * @param {string} relPath - POSIX, repo-relative.
334
+ * @returns {string} file content at HEAD.
335
+ * @throws when git is absent, the dir is not a repo, or the path is not at HEAD.
336
+ */
337
+ function readFromHead(repoRoot, relPath) {
338
+ return execFileSync('git', ['-C', repoRoot, 'show', `HEAD:${relPath}`], {
339
+ encoding: 'utf8',
340
+ stdio: ['ignore', 'pipe', 'ignore'],
341
+ maxBuffer: 8 * 1024 * 1024,
342
+ });
343
+ }
344
+
345
+ /**
346
+ * Import an ESM module from an in-memory source string.
347
+ *
348
+ * Only sound for a DEPENDENCY-FREE module (or one importing `node:*` only): a
349
+ * `data:` URL has no base for relative specifier resolution. `command-blocker.mjs`
350
+ * qualifies — its single import is `node:path`.
351
+ *
352
+ * @param {string} source
353
+ * @returns {Promise<object>} the module namespace.
354
+ */
355
+ function importFromSource(source) {
356
+ const b64 = Buffer.from(source, 'utf8').toString('base64');
357
+ return import(`data:text/javascript;base64,${b64}`);
358
+ }
359
+
360
+ /**
361
+ * Assert a loaded `command-blocker.mjs` namespace carries the COMPLETE API the
362
+ * guard calls through. A partial namespace is not a degraded guard — it is a
363
+ * guard that throws on the first command and fails open with an `internal
364
+ * error` line, so it must be treated as a load failure here, at the one place
365
+ * that can still fall back or banner.
366
+ *
367
+ * @param {object} mod
368
+ * @param {string} origin - human label for the banner ("working-tree copy" | "HEAD copy")
369
+ * @throws {Error} naming every missing export.
370
+ */
371
+ function assertBlockerShape(mod, origin) {
372
+ const missing = COMMAND_BLOCKER_EXPORTS.filter((name) => typeof mod?.[name] !== 'function');
373
+ if (missing.length > 0) {
374
+ throw new Error(
375
+ `${origin} of ${COMMAND_BLOCKER_REL} is missing required export(s): ${missing.join(', ')}`
376
+ );
377
+ }
378
+ }
379
+
380
+ /**
381
+ * Load `command-blocker.mjs`, falling back to its HEAD version, never silently.
382
+ *
383
+ * @param {{specifier: string, repoRoot: string, projectDir: string}} opts
384
+ * `specifier` is the import URL/path used in normal operation; `repoRoot` is
385
+ * where `git show` runs; `projectDir` keys the banner marker.
386
+ * @returns {Promise<{module: object, degraded: 'head'|null}>}
387
+ * @throws the ORIGINAL working-tree error when the HEAD fallback also fails, so
388
+ * the caller's catch can banner "guard INACTIVE" with the real cause.
389
+ */
390
+ export async function loadCommandBlocker({ specifier, repoRoot, projectDir }) {
391
+ try {
392
+ const module = await import(specifier);
393
+ // The shape check runs on BOTH paths. A working-tree copy that parses but
394
+ // lost an export is the same defect class as a stale HEAD copy: without
395
+ // this it would arm "successfully" and then fail open per command.
396
+ assertBlockerShape(module, 'working-tree copy');
397
+ return { module, degraded: null };
398
+ } catch (workingTreeError) {
399
+ let mod;
400
+ try {
401
+ mod = await importFromSource(readFromHead(repoRoot, COMMAND_BLOCKER_REL));
402
+ // A HEAD copy that parses but lost part of the API is NOT a usable
403
+ // fallback — treat it as a total failure rather than half-arming.
404
+ assertBlockerShape(mod, 'HEAD copy');
405
+ } catch (headError) {
406
+ workingTreeError.headFallbackError = headError;
407
+ throw workingTreeError;
408
+ }
409
+
410
+ emitGuardBannerOnce({
411
+ projectDir,
412
+ kind: 'head-fallback',
413
+ message: [
414
+ '',
415
+ '⚠️ pre-bash-destructive-guard: DEGRADED — running against HEAD, not your working tree.',
416
+ ` ${COMMAND_BLOCKER_REL} failed to load from the working tree:`,
417
+ ` ${String(workingTreeError?.message || workingTreeError).split('\n')[0]}`,
418
+ ' Consequence: destructive-command enforcement IS still armed, but it is evaluating the',
419
+ ' COMMITTED (HEAD) command lexer — any uncommitted change to that file is NOT in effect.',
420
+ ` Fix: repair ${COMMAND_BLOCKER_REL} (conflict markers? partial edit?) and re-run.`,
421
+ ' See: issue #992.',
422
+ '',
423
+ ].join('\n'),
424
+ });
425
+
426
+ return { module: mod, degraded: 'head' };
427
+ }
428
+ }
429
+
430
+ /**
431
+ * Banner for the total-failure case: no usable module, guard NOT armed.
432
+ *
433
+ * **Deliberately NOT throttled** — this is the one message that must survive
434
+ * every attempt to quiet it. Its predecessor went through the once-per-session
435
+ * marker, and because that marker was suppressible by a bare `touch` on a
436
+ * derivable path, the loudest signal in the system had the weakest lock on it:
437
+ * plant the file, break a module, and an unarmed guard said nothing at all
438
+ * while the decision channel said ALLOW. There is nothing to key on here
439
+ * anyway — every call after the first is equally unprotected, so every call
440
+ * has equal right to say so. Repetition is the point.
441
+ *
442
+ * `projectDir` is retained for signature symmetry with the degraded banner and
443
+ * for future scoping; it deliberately does not gate anything.
444
+ *
445
+ * @param {{projectDir?: string, error: unknown}} opts
446
+ */
447
+ export function emitGuardInactiveBanner({ error }) {
448
+ const primary = String(error?.message || error).split('\n')[0];
449
+ const secondary = error?.headFallbackError
450
+ ? String(error.headFallbackError.message || error.headFallbackError).split('\n')[0]
451
+ : null;
452
+
453
+ writeBanner(
454
+ [
455
+ '',
456
+ '🚨 pre-bash-destructive-guard: GUARD INACTIVE — this session is NOT protected.',
457
+ ` Module load failed: ${primary}`,
458
+ ...(secondary ? [` HEAD fallback also failed: ${secondary}`] : []),
459
+ ' Consequence: destructive Bash commands (git reset --hard, rm -rf, git push --force,',
460
+ ' git stash, redirect-truncate of protected artefacts) are NOT being blocked. This is a',
461
+ ' BROKEN GUARD, not a policy decision — do not route around it, repair it.',
462
+ ' Fix: repair the failing module under scripts/lib/, then re-run.',
463
+ ' See: issue #992, .claude/rules/parallel-sessions.md (PSA-003).',
464
+ '',
465
+ ].join('\n')
466
+ );
467
+ }
@@ -187,6 +187,27 @@ export async function bootstrapLock({
187
187
  }
188
188
  }
189
189
 
190
+ // Step 2b (#987 Part 1): persist the durable ownership proof at lock
191
+ // genesis. `enriched` is byte-identical to the on-disk lock at this point
192
+ // (the v2 overlay never touches pid/host/started_at), so the proof written
193
+ // here will verify via isLockOwnedByProof() against any later re-read.
194
+ // This single call covers BOTH the plain-acquire and the forceAcquire
195
+ // branch — both flow through the enriched write above. Best-effort like
196
+ // the surrounding breadcrumb writes: writeOwnerProof() is no-throw by
197
+ // contract and a failure never bails the bootstrap — but it is no longer
198
+ // SILENT (#987 Part 2 review finding): a failed proof write means this
199
+ // session's /close will degrade to the weaker proof-less release path, so
200
+ // a one-line stderr WARN gives the operator the only signal there is.
201
+ try {
202
+ const { writeOwnerProof } = await import('../../scripts/lib/session-lock.mjs');
203
+ const proofResult = writeOwnerProof({ repoRoot, lock: enriched });
204
+ if (proofResult && !proofResult.ok) {
205
+ process.stderr.write(
206
+ `⚠ lock-bootstrap: owner-proof write failed (${proofResult.reason ?? 'unknown'}) — /close degrades to proof-less release behaviour\n`,
207
+ );
208
+ }
209
+ } catch { /* best-effort — a missing proof never breaks session-start */ }
210
+
190
211
  // Step 3: best-effort observability breadcrumb. Failures are swallowed
191
212
  // so a missing events module never breaks the hook.
192
213
  try {
@@ -0,0 +1,119 @@
1
+ /**
2
+ * vcs-create-matcher.mjs — shared `gh` / `glab` create-command matcher for
3
+ * PreToolUse Bash hooks.
4
+ *
5
+ * Extracted from `hooks/pre-bash-templates-first.mjs` (#519) so the
6
+ * issue-budget hook (`hooks/pre-bash-issue-budget.mjs`) reuses the SAME
7
+ * regex + argument-boundary semantics instead of maintaining a second,
8
+ * silently-diverging copy. Both hooks share the PreToolUse Bash matcher and
9
+ * run sequentially, so a divergence here would mean one hook gates a command
10
+ * the other waves through.
11
+ *
12
+ * ZERO IMPORTS by design — a clean leaf so any hook can adopt it without
13
+ * pulling the scripts/lib layer in.
14
+ *
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.
21
+ */
22
+
23
+ /**
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`.
28
+ */
29
+ export const CREATE_REGEX = /^\s*(gh|glab)\s+(pr|mr|issue)\s+(create|new)\b/;
30
+
31
+ /**
32
+ * Parse a shell command into its VCS-create shape.
33
+ *
34
+ * @param {string} command
35
+ * @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
+ */
38
+ export function matchVcsCreate(command) {
39
+ if (typeof command !== 'string' || command.length === 0) return null;
40
+ const m = command.match(CREATE_REGEX);
41
+ if (!m) return null;
42
+ return {
43
+ host: m[1] === 'gh' ? 'github' : 'gitlab',
44
+ kind: /** @type {'pr'|'mr'|'issue'} */ (m[2]),
45
+ verb: /** @type {'create'|'new'} */ (m[3]),
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Determine which host the command targets. `gh` → "github", `glab` → "gitlab".
51
+ * Thin wrapper kept for call-site readability in pre-bash-templates-first.mjs.
52
+ *
53
+ * @param {string} command
54
+ * @returns {"github"|"gitlab"|null}
55
+ */
56
+ export function resolveHost(command) {
57
+ return matchVcsCreate(command)?.host ?? null;
58
+ }
59
+
60
+ /**
61
+ * True when the command creates an ISSUE specifically (not a PR/MR).
62
+ * The issue-budget cap counts issues only — PR/MR creation is not the
63
+ * runaway-volume problem the cap exists to bound.
64
+ *
65
+ * @param {string} command
66
+ * @returns {boolean}
67
+ */
68
+ export function isIssueCreate(command) {
69
+ return matchVcsCreate(command)?.kind === 'issue';
70
+ }
71
+
72
+ /**
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").
77
+ *
78
+ * @param {string} command
79
+ * @param {string[]} bypassPatterns
80
+ * @returns {boolean}
81
+ */
82
+ export function matchesBypass(command, bypassPatterns) {
83
+ if (typeof command !== 'string') return false;
84
+ if (!Array.isArray(bypassPatterns) || bypassPatterns.length === 0) {
85
+ return false;
86
+ }
87
+ const stripped = command.replace(/^\s+/, '');
88
+ for (const pat of bypassPatterns) {
89
+ 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;
95
+ }
96
+ return false;
97
+ }
98
+
99
+ /**
100
+ * Best-effort extraction of the `--title` value from a create command, for
101
+ * 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.
104
+ *
105
+ * Deliberately NOT a shell parser: the value is only ever used as a display
106
+ * label in an overflow record, never re-executed.
107
+ *
108
+ * @param {string} command
109
+ * @returns {string|null}
110
+ */
111
+ export function extractTitle(command) {
112
+ 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;
119
+ }
Binary file
@@ -13,10 +13,18 @@
13
13
  * G5 enforcement != "off"
14
14
  * G6 blocked pattern match against .blockedCommands[], or
15
15
  * fallback safety list when .blockedCommands is empty
16
- * G7 strict → deny (exit 2); warn → stderr + allow (exit 0); otherwise allow
16
+ * G7 strict → deny; warn → stderr + allow; otherwise allow
17
+ *
18
+ * DECISION CHANNEL (post-#906): a deny is signalled by the single nested
19
+ * PreToolUse JSON envelope emitDeny() writes to stdout, with exit **0** —
20
+ * NOT by exit 2. The docs forbid the mixed form ("Exit 2 … Claude Code
21
+ * ignores stdout and any JSON in it"), which silently discarded the reason
22
+ * and surfaced to the operator as a crash. Do not reintroduce `exit 2` here.
23
+ * Corollary: exit 0 alone no longer distinguishes allow from deny — the
24
+ * envelope's presence does, and a malformed envelope fails OPEN.
17
25
  *
18
26
  * SECURITY-REQ-01: try/catch on main(). emitDeny on any unhandled error —
19
- * exit 2, never exit 1. Null-guard readStdin() return.
27
+ * fail-closed, never a bare exit 1. Null-guard readStdin() return.
20
28
  * SECURITY-REQ-07: FALLBACK_BLOCKED includes 'git push -f' and 'drop table'
21
29
  * (short form + case variant gaps in the original Bash fallback list).
22
30
  * SECURITY-REQ-08: scope file read exactly once per invocation.
@@ -7,7 +7,7 @@
7
7
  "hooks": [
8
8
  {
9
9
  "type": "command",
10
- "command": "echo '🎯 Session Orchestrator v3.16.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
10
+ "command": "echo '🎯 Session Orchestrator v3.19.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
11
11
  "async": false
12
12
  },
13
13
  {
@@ -2,14 +2,23 @@
2
2
  "_comment": "Cursor IDE hook mapping reference. Configure hooks in Cursor Settings > Hooks.",
3
3
  "_note": "Cursor hooks fire at different lifecycle points than Claude Code. See docs/cursor-setup.md for details.",
4
4
  "_limitation": "SessionStart greeting (session-start skill) is NOT available on Cursor. Cursor has no conversation-start lifecycle event equivalent to Claude Code's UserPromptSubmit hook. Session initialisation must be triggered manually.",
5
+ "_enforcement": "reference-only",
6
+ "_enforcement_note": "MACHINE-READABLE STATUS (#919): every entry below is an intended mapping, NOT live enforcement. No Cursor payload adapter exists (Pi has scripts/lib/pi-hook-bridge.mjs; Cursor has nothing), so each handler short-circuits on a Cursor-shaped payload and writes 0 bytes with exit 0. Operator decision 2026-07-31: gap registered, not closed. The machine-readable counterpart of this key is DOCUMENTED_ASYMMETRIES.handlerAsymmetries.cursor in scripts/lib/validate/check-hooks-symmetry.mjs, which Check 6 counts per event; the projection that makes that comparison non-vacuous is DOCUMENTED_ASYMMETRIES.cursorEventMap.",
7
+ "_issues": ["#919", "#946"],
5
8
  "hooks": {
6
9
  "afterFileEdit": {
7
10
  "script": "hooks/enforce-scope.mjs",
8
- "note": "Post-hoc scope warning — Cursor fires AFTER the edit (not before). Cannot prevent out-of-scope edits, only warn."
11
+ "enforcement": "none",
12
+ "issue": "#919",
13
+ "mapsToClaudeEvent": "PostToolUse",
14
+ "note": "Post-hoc scope warning — Cursor fires AFTER the edit (not before), so this maps onto PostToolUse, never PreToolUse. Cannot prevent out-of-scope edits, only warn — and today not even that: on a Cursor afterFileEdit payload enforce-scope.mjs writes 0 bytes to stdout AND stderr with exit 0 (measured 2026-07-31), because it reads Claude Code's PreToolUse field names."
9
15
  },
10
16
  "beforeShellExecution": {
11
17
  "script": "hooks/enforce-commands.mjs",
12
- "note": "Blocks dangerous commands before shell execution. Direct equivalent of Claude Code's PreToolUse Bash hook."
18
+ "enforcement": "none",
19
+ "issue": "#919",
20
+ "mapsToClaudeEvent": "PreToolUse",
21
+ "note": "NOT WIRED (#919) — silent no-op on a Cursor payload, NOT a block. The handler reads Claude Code's PreToolUse field names (tool_name === 'Bash', tool_input.command) and emits a Claude Code PreToolUse envelope (hookSpecificOutput.permissionDecision). Fed a Cursor beforeShellExecution payload it short-circuits at gate G1 and writes 0 bytes to stdout AND stderr with exit 0 — the harness sees no decision, so the command runs. Cursor needs an input/output adapter like scripts/lib/pi-hook-bridge.mjs; none exists. Treat this entry as the intended mapping, not as live enforcement."
13
22
  }
14
23
  }
15
24
  }