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
@@ -18,11 +18,20 @@
18
18
  * G1 tool filter — only Bash is gated.
19
19
  * G2 command is a non-empty string.
20
20
  * G3 matcher — `gh|glab … issue create|new` only. PR/MR creation passes.
21
+ * Verb-resolved since #1145, so a wrapped (`nohup`), absolute-path or
22
+ * env-prefixed create is seen; a `--help` invocation is not (it creates
23
+ * nothing).
21
24
  * G4 config — `issue-budget` from CLAUDE.md/AGENTS.md. `mode: off` → allow.
25
+ * G3b bulk — a create inside a shell LOOP body creates an unknowable number
26
+ * of issues (#1145). `strict` → deny; `warn` → allow with an explicit
27
+ * undercount notice. See the block comment above formatLoopDenyReason for
28
+ * why not "charge 1".
22
29
  * G5 exemption — priority::critical / carryover class / broken-window /
23
30
  * the overflow collector itself bypass the cap unconditionally, keeping
24
31
  * the session-end promises at SKILL.md:319 and :1113 intact.
25
- * G6 charge the counter in .orchestrator/runtime/issue-budget.json.
32
+ * G6 charge the counter in .orchestrator/runtime/issue-budget/<hash>.json
33
+ * (one file per session since #1141 — see scripts/lib/issue-budget.mjs
34
+ * `budgetStateRel`).
26
35
  * under cap → allow; over cap + `warn` → allow with stderr notice;
27
36
  * over cap + `strict` → park in `overflow[]`, then deny via emitDeny.
28
37
  *
@@ -38,14 +47,15 @@
38
47
  * which would throw away the deny envelope entirely (#906).
39
48
  */
40
49
 
41
- import { readStdin, emitAllow, emitDeny } from '../scripts/lib/io.mjs';
50
+ import { readStdin, emitAllow, emitDeny, emitWarn } from '../scripts/lib/io.mjs';
42
51
  import { resolveProjectDir } from '../scripts/lib/platform.mjs';
43
52
  import { readJson } from '../scripts/lib/common.mjs';
44
- import { isIssueCreate, extractTitle } from './_lib/vcs-create-matcher.mjs';
53
+ import { isIssueCreate, isLoopedIssueCreate, extractTitle } from './_lib/vcs-create-matcher.mjs';
45
54
  import {
46
55
  loadIssueBudgetConfig,
47
56
  resolveIssueBudgetSessionId,
48
57
  chargeIssueBudget,
58
+ classifyExemption,
49
59
  formatBlockReason,
50
60
  } from '../scripts/lib/issue-budget.mjs';
51
61
 
@@ -66,12 +76,28 @@ if (!shouldRunHook('pre-bash-issue-budget')) process.exit(0);
66
76
  * semantic id can bridge repeated calls only when its recorded raw id exactly
67
77
  * matches the native stdin id; it never substitutes for a missing raw id.
68
78
  *
79
+ * ENV FALLBACK (#1141). A PreToolUse payload without `session_id` used to
80
+ * resolve to `null`, and an identity-less charge neither reads nor persists —
81
+ * so for that payload shape the cap was silently OFF. The harness also exports
82
+ * `CLAUDE_CODE_SESSION_ID` (measured: it equals the `session.lock` `session_id`
83
+ * and survives into subagents), which is a native id of the same kind as the
84
+ * stdin one, so it is a faithful substitute rather than a guess. Note the
85
+ * NAME: there is no `CLAUDE_SESSION_ID` — reading that spelling is what left
86
+ * the identical fallback in `scripts/lib/spiral-carryover.mjs` dead code.
87
+ *
88
+ * stdin still wins: it is the id of THIS tool call, whereas the env var is the
89
+ * id of the process tree, and the two differ in a nested harness.
90
+ *
69
91
  * @param {object|null} input
70
92
  * @param {string|null} projectDir
71
93
  * @returns {Promise<string|null>}
72
94
  */
73
95
  async function resolveSessionId(input, projectDir) {
74
- const nativeRawId = input?.session_id ?? input?.sessionId ?? null;
96
+ const stdinRawId = input?.session_id ?? input?.sessionId ?? null;
97
+ const nativeRawId =
98
+ typeof stdinRawId === 'string' && stdinRawId.length > 0
99
+ ? stdinRawId
100
+ : (process.env.CLAUDE_CODE_SESSION_ID ?? null);
75
101
  if (typeof nativeRawId !== 'string' || nativeRawId.length === 0) return null;
76
102
 
77
103
  let currentSession = null;
@@ -88,6 +114,68 @@ async function resolveSessionId(input, projectDir) {
88
114
  return resolveIssueBudgetSessionId(nativeRawId, currentSession);
89
115
  }
90
116
 
117
+ /**
118
+ * THE CHOICE (#1145) — stated once, so the loop behaviour is explicit rather
119
+ * than emergent.
120
+ *
121
+ * `for t in a b c; do glab issue create --title $t; done` is textually ONE
122
+ * create statement that files THREE issues. Three answers were available and
123
+ * none is obviously right:
124
+ *
125
+ * charge 1 — the ledger then carries a number it KNOWS is wrong.
126
+ * `for i in $(seq 1 50)` files 50 issues against a count of 1, so
127
+ * the cap stays nominally armed while actually uncapped. That is
128
+ * strictly worse than the pre-#1145 miss, because the pre-fix
129
+ * state at least did not LOOK accounted.
130
+ * charge N — not computable at hook time. The word list can be
131
+ * `$(cat backlog.txt)`, `"$@"`, or a glob; this hook runs BEFORE
132
+ * the shell expands any of them.
133
+ * deny — CHOSEN. A quantity gate that cannot count the quantity must
134
+ * refuse, not guess. It is fully recoverable: unrolling the loop
135
+ * into separate create calls charges each one correctly, and the
136
+ * deny reason says exactly that. Denying costs one round-trip;
137
+ * guessing costs the cap its credibility.
138
+ *
139
+ * The choice is mode-scoped, because `mode` is the operator's declared
140
+ * enforcement level and this gate has no standing to exceed it:
141
+ * strict → deny; warn → allow + an explicit undercount notice; off → allow.
142
+ * The exemption classes (priority::critical, carryover, broken-window) are
143
+ * checked FIRST and pass through untouched, so session-end's "those are never
144
+ * deferred" promise survives a looped carryover sweep.
145
+ *
146
+ * NAMED CEILING (BV-004): a loop is detected by `do`/`done` in command position
147
+ * (see `isLoopedIssueCreate`), so an UNROLLED bulk create — 50 create statements
148
+ * chained with `&&` — is not a "loop" and is charged 50, correctly. Revisit this
149
+ * choice if the overflow triage of a per-session counter file
150
+ * (`.orchestrator/runtime/issue-budget/<hash>.json`) shows operators routinely
151
+ * hitting this deny on loops over a KNOWN literal word list; the cheap answer
152
+ * then is to count that list, never to fall back to "charge 1".
153
+ *
154
+ * Deliberately NOT `formatBlockReason`: that text promises "parked as overflow
155
+ * entry #N … nothing is lost", which would be false here — an uncountable bulk
156
+ * request is not parked, it is handed back whole.
157
+ *
158
+ * @param {{ "max-per-session": number }} config
159
+ * @returns {string}
160
+ */
161
+ function formatLoopDenyReason(config) {
162
+ return [
163
+ 'issue-budget: this command creates an UNKNOWN number of issues — refusing to guess.',
164
+ 'The `issue create` call sits inside a shell loop body (`do … done`), so the cap cannot',
165
+ 'charge it honestly: the word list is expanded by the shell AFTER this hook runs, so',
166
+ '`for i in $(seq 1 50)` would file 50 issues against a count of 1.',
167
+ '',
168
+ 'Nothing was parked as overflow, because nothing is lost: re-issue the create calls as',
169
+ 'SEPARATE commands and each one is counted normally against the cap',
170
+ `(${config['max-per-session']} per session).`,
171
+ '',
172
+ 'Exempt from the cap even inside a loop: priority::critical, the carryover class',
173
+ '(SPIRAL/FAILED, [Carryover]), and broken-window closure issues.',
174
+ 'To change the enforcement level, edit `issue-budget.mode` in the Session Config:',
175
+ '`warn` reports without blocking, `off` disables the gate.',
176
+ ].join('\n');
177
+ }
178
+
91
179
  // ---------------------------------------------------------------------------
92
180
  // Main
93
181
  // ---------------------------------------------------------------------------
@@ -112,6 +200,17 @@ async function main() {
112
200
  const config = loadIssueBudgetConfig(projectDir);
113
201
  if (config.mode === 'off') return emitAllow();
114
202
 
203
+ // G3b — bulk creation whose multiplicity is not computable (#1145). The
204
+ // exemption is asked FIRST, through the same classifier chargeIssueBudget
205
+ // uses, so a looped carryover sweep keeps its unconditional pass.
206
+ const uncountableBulk =
207
+ isLoopedIssueCreate(command) && !classifyExemption(command).exempt;
208
+ if (uncountableBulk && config.mode === 'strict') {
209
+ // Nothing is charged and nothing is parked — the command is handed back
210
+ // whole, which is what makes unrolling it the correct next action.
211
+ return emitDeny(formatLoopDenyReason(config));
212
+ }
213
+
115
214
  const sessionId = await resolveSessionId(input, projectDir);
116
215
 
117
216
  // G5 + G6 — exemption check and counter charge live in the shared core so
@@ -153,6 +252,20 @@ async function main() {
153
252
  emitDeny(formatBlockReason(verdict));
154
253
  }
155
254
 
255
+ // A PERMITTED bulk create is charged ONCE, which is an undercount by
256
+ // construction. `mode: warn` means "report, do not block", so the report has
257
+ // to name the undercount out loud — a silent 1-for-N is the exact failure the
258
+ // deny above exists to prevent, and `warn` must not reintroduce it quietly.
259
+ // emitWarn, not stderr: under exit 0 stderr reaches only the debug log (#916).
260
+ if (uncountableBulk && verdict.decision === 'allow') {
261
+ return emitWarn(
262
+ `pre-bash-issue-budget: bulk create inside a loop body charged as 1 ` +
263
+ `(${verdict.count}/${verdict.max}) — the real number of issues this files is not ` +
264
+ `knowable before the shell expands the word list, so the count is an UNDERCOUNT. ` +
265
+ `Set \`issue-budget.mode: strict\` to deny this shape instead.`,
266
+ );
267
+ }
268
+
156
269
  // 'allow' / 'off'
157
270
  return emitAllow();
158
271
  }
@@ -40,6 +40,40 @@
40
40
  * sessions.jsonl` — piping the writer's `{"action":"appended"}` receipt INTO
41
41
  * the ledger. That is a bug, and denying it is correct.
42
42
  *
43
+ * ## The repair CLI (GitLab #385) — allowed by EXACT MATCH, not structurally
44
+ *
45
+ * `repair-invalid-sessions.mjs --apply` is a FIFTH sanctioned writer, but it
46
+ * is allowed by a different mechanism than the four above: it mutates the
47
+ * ledger through `repairRecord()` (`scripts/lib/session-record-repair.mjs`)
48
+ * from inside Node, so structurally it is identical to the four writers — no
49
+ * ledger write-intent for the matcher to see — EXCEPT that this hook's OWN
50
+ * matcher independently resolves its `node <script> --apply` argv shape as an
51
+ * ledger-mutating verb (`findWriteVerbTarget`'s `verb === 'node'` branch,
52
+ * #1005) and denies it on sight. Before this hook had any opinion on the
53
+ * subject at all, the guard was therefore denying its own validating writer —
54
+ * the fix-it CLI for a corrupted ledger record could never run, which is the
55
+ * #385 deadlock.
56
+ *
57
+ * The fix (`REPAIR_APPLY_CANONICAL_COMMAND`, see below) is an EXACT STRING
58
+ * comparison against the trimmed command — not a name check, and not
59
+ * equivalent to the spoofable name allowlist rejected above. The distinction
60
+ * that matters: a name allowlist matches a SUBSTRING anywhere in the command
61
+ * (a comment, an argument, a quoted payload), so an attacker can smuggle the
62
+ * name into a command that does something else entirely. An exact-command
63
+ * comparison has no such position to smuggle into — the command either IS,
64
+ * in its entirety, `node scripts/repair-invalid-sessions.mjs --apply`, or it
65
+ * is not, and ANY addition (a flag, a wrapper, a second command chained
66
+ * after it, a subshell) breaks the match and falls through to the existing
67
+ * deny logic unchanged (`sonst weiter DENY` — see the #385 tests). There is
68
+ * still nothing to spoof, for the same reason the four structural writers
69
+ * have nothing to spoof: allowing this ONE exact invocation grants nothing an
70
+ * attacker did not already have. `repairRecord()` validates every record it
71
+ * writes the same way `emit-session.mjs` does, so a caller who can already
72
+ * invoke arbitrary Bash could run this repair CLI directly, or edit
73
+ * `session-record-repair.mjs` itself, without ever touching this hook — the
74
+ * hook's protection scope is shell-redirect writes that BYPASS validation,
75
+ * and this command never does that.
76
+ *
43
77
  * ## Path scope
44
78
  *
45
79
  * A write target counts as the ledger when its BASENAME is `sessions.jsonl`,
@@ -172,6 +206,11 @@
172
206
  * verbs (a redirect inside an `awk` program string is quoted data here).
173
207
  * - The Write/Edit tools — a different PreToolUse matcher entirely
174
208
  * (`hooks/enforce-scope.mjs` territory), not this hook's surface.
209
+ * - The #385 repair-apply subshell/backtick deny (`unwrapSubshellLayers`)
210
+ * only unwraps a command that IS, in its entirety, one wrapped run —
211
+ * `x=$(node scripts/repair-invalid-sessions.mjs --apply)` (the wrapper is
212
+ * part of a larger assignment, not the whole command) still allows. Same
213
+ * class as the line above: a determined rewrite, not the accident shape.
175
214
  *
176
215
  * This is a guard against the accident that actually happened, not a
177
216
  * containment boundary. Determined circumvention is out of scope by design.
@@ -351,6 +390,29 @@ const LEDGER_BASENAME = 'sessions.jsonl';
351
390
  const REPAIR_SCRIPT_BASENAME = 'repair-invalid-sessions.mjs';
352
391
  const REPAIR_APPLY_MARKER = `${REPAIR_SCRIPT_BASENAME} --apply`;
353
392
 
393
+ /**
394
+ * The ONE command string this guard treats as a sanctioned, exact-match
395
+ * repair-apply invocation (GitLab #385).
396
+ *
397
+ * Without this exception the guard denied its OWN validating writer:
398
+ * `repair-invalid-sessions.mjs --apply` mutates the ledger through
399
+ * `repairRecord()` in `scripts/lib/session-record-repair.mjs` — the same
400
+ * validated-write discipline this hook exists to enforce (mandatory `.bak`,
401
+ * atomic rename, post-verification restore on failure) — yet the module
402
+ * docblock's own "ALLOWED STRUCTURALLY" section (see the top of this file)
403
+ * never named it, because the matcher below still resolves `node` as the verb
404
+ * and denies on sight. The fix is deliberately an EXACT STRING match, not a
405
+ * pattern: matching by verb/flags (as `findWriteVerbTarget` already does for
406
+ * the deny case) cannot distinguish "the operator ran the sanctioned
407
+ * runbook command" from "the operator ran something ADJACENT to it" — a
408
+ * wrapper, an extra flag, a second command chained after it. `sonst weiter
409
+ * DENY` (issue text): any deviation — chaining, piping, a wrapper, extra
410
+ * arguments, a subshell, backticks — falls through UNCHANGED to the existing
411
+ * deny logic below, which is why this constant is compared against the RAW,
412
+ * merely-trimmed command string before any tokenizing/unwrapping happens.
413
+ */
414
+ const REPAIR_APPLY_CANONICAL_COMMAND = `node scripts/${REPAIR_SCRIPT_BASENAME} --apply`;
415
+
354
416
  /**
355
417
  * How much of the offending target may appear in the deny reason.
356
418
  *
@@ -416,6 +478,68 @@ function refersToRepairScript(target) {
416
478
  return path.posix.basename(normalized) === REPAIR_SCRIPT_BASENAME;
417
479
  }
418
480
 
481
+ /**
482
+ * Strip exactly ONE layer of `$( … )`, backtick, or bare `( … )` wrapping from
483
+ * `s`, when `s` (already trimmed) is wholly that one wrapped run. Returns
484
+ * `null` when `s` is not wrapped, or when a bare-paren/`$(…)` wrapper's
485
+ * interior has unbalanced parens (ambiguous — leave it alone rather than
486
+ * unwrap something that was not really a single wrapped command).
487
+ *
488
+ * Deliberately narrow: this is NOT a general shell parser. It exists only to
489
+ * answer "is this string exactly the canonical repair-apply command sitting
490
+ * inside one layer of command-substitution/subshell syntax" (see
491
+ * {@link unwrapSubshellLayers} and GitLab #385 test (c)) — the general
492
+ * `blocker.tokenizeCommand` path glues a leading `$(`/`` ` ``/`(` onto the
493
+ * next word (#385 measured: `$(node …)` tokenizes as a single `$(node`
494
+ * token), which is why the existing verb-based matcher never resolves `node`
495
+ * as the verb for a wrapped command and silently ALLOWS it today.
496
+ *
497
+ * @param {string} s - already-trimmed candidate
498
+ * @returns {string|null} the unwrapped, trimmed interior, or null
499
+ */
500
+ function unwrapSingleSubshellLayer(s) {
501
+ const balanced = (inner) => {
502
+ let depth = 0;
503
+ for (const ch of inner) {
504
+ if (ch === '(') depth++;
505
+ else if (ch === ')') { depth--; if (depth < 0) return false; }
506
+ }
507
+ return depth === 0;
508
+ };
509
+ if (s.length >= 4 && s.startsWith('$(') && s.endsWith(')')) {
510
+ const inner = s.slice(2, -1);
511
+ return balanced(inner) ? inner.trim() : null;
512
+ }
513
+ if (s.length >= 2 && s.startsWith('`') && s.endsWith('`')) {
514
+ return s.slice(1, -1).trim();
515
+ }
516
+ if (s.length >= 2 && s.startsWith('(') && s.endsWith(')')) {
517
+ const inner = s.slice(1, -1);
518
+ return balanced(inner) ? inner.trim() : null;
519
+ }
520
+ return null;
521
+ }
522
+
523
+ /**
524
+ * Repeatedly apply {@link unwrapSingleSubshellLayer} up to `maxDepth` times.
525
+ * Bounded the same way payload recursion is bounded elsewhere in this file —
526
+ * no realistic hand-typed command nests this deep, and the outer command is
527
+ * still matched on its own terms regardless of how far this unwrapping gets.
528
+ *
529
+ * @param {string} command
530
+ * @param {number} [maxDepth]
531
+ * @returns {string}
532
+ */
533
+ function unwrapSubshellLayers(command, maxDepth = 3) {
534
+ let current = command.trim();
535
+ for (let n = 0; n < maxDepth; n++) {
536
+ const next = unwrapSingleSubshellLayer(current);
537
+ if (next === null) break;
538
+ current = next;
539
+ }
540
+ return current;
541
+ }
542
+
419
543
  /**
420
544
  * Node's own `--help` output is the source of truth for the runtime-option
421
545
  * grammar. A hand-maintained option set is necessarily stale as Node adds
@@ -974,6 +1098,41 @@ async function main() {
974
1098
  );
975
1099
  }
976
1100
 
1101
+ // #385 — the repair CLI's own sanctioned invocation is the ONE write this
1102
+ // guard must let through: it mutates the ledger via the SAME validated-write
1103
+ // discipline (`repairRecord()`, mandatory backup, atomic rename, post-verify
1104
+ // restore) this hook exists to enforce, not a hand-composed shell append.
1105
+ // Compared against the RAW, merely-trimmed command — before any
1106
+ // tokenizing/sanitizing — so the exception is exactly as narrow as the
1107
+ // string itself; "conservative" per the issue text means matching the
1108
+ // string, not a pattern that could also match something adjacent to it.
1109
+ const trimmedCommand = command.trim();
1110
+ if (trimmedCommand === REPAIR_APPLY_CANONICAL_COMMAND) {
1111
+ return flushNotices(notices);
1112
+ }
1113
+ // The SAME canonical command wrapped in one layer of command substitution /
1114
+ // backticks / a bare subshell is a DIFFERENT command — bash still executes
1115
+ // the repair mutation for the substitution's side effect, but it no longer
1116
+ // matches the exact sanctioned string, so `sonst weiter DENY` applies. This
1117
+ // also closes a real gap the exact-match alone would not: `blocker`'s
1118
+ // tokenizer glues a leading `$(`/`` ` ``/`(` onto `node`, so the EXISTING
1119
+ // verb-based matcher below never resolves verb `node` for a wrapped command
1120
+ // and silently allowed it before this hook had any opinion on the subject.
1121
+ if (unwrapSubshellLayers(trimmedCommand) === REPAIR_APPLY_CANONICAL_COMMAND) {
1122
+ emitDeny(
1123
+ [
1124
+ `Direct write to the sessions ledger blocked: '${REPAIR_APPLY_MARKER} (wrapped in a subshell/backticks — denied)'`,
1125
+ `The sanctioned repair invocation is allowed ONLY as the exact bare command:`,
1126
+ ` ${REPAIR_APPLY_CANONICAL_COMMAND}`,
1127
+ `Wrapping it in $(...), backticks, or a subshell is denied even though the`,
1128
+ `command is otherwise identical — see hooks/pre-bash-sessions-ledger-guard.mjs #385.`,
1129
+ `Override (intentional maintenance only): run the session with`,
1130
+ `SO_DISABLED_HOOKS=pre-bash-sessions-ledger-guard`,
1131
+ ].join('\n'),
1132
+ );
1133
+ return;
1134
+ }
1135
+
977
1136
  // G3 — matcher. No direct ledger write → allow.
978
1137
  const marks = [];
979
1138
  const target = findLedgerWrite(command, 0, marks);
@@ -64,6 +64,7 @@ import os from 'node:os';
64
64
  import crypto from 'node:crypto';
65
65
 
66
66
  import { shouldRunHook } from './_lib/profile-gate.mjs';
67
+ import { stableHostname } from '../scripts/lib/host-identity.mjs';
67
68
  if (!shouldRunHook('pre-bash-staging-fence')) process.exit(0);
68
69
 
69
70
  // ---------------------------------------------------------------------------
@@ -159,7 +160,10 @@ function appendIntent({ fenceFile, agentId, command }) {
159
160
  body = {
160
161
  agent_id: agentId,
161
162
  pid: process.pid,
163
+ // `host` raw + `host_id` normalised (#1072 — os.hostname() flips spelling
164
+ // on a single machine, so a raw value is not comparable across writes).
162
165
  host: os.hostname(),
166
+ host_id: stableHostname(),
163
167
  started_at: timestamp,
164
168
  staged_paths: [],
165
169
  };