session-orchestrator 4.1.0 → 5.0.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 (230) hide show
  1. package/.agents/skills/session-plan/SKILL.md +1 -1
  2. package/.agents/skills/session-start/SKILL.md +1 -1
  3. package/.agents/skills/ux-grill/SKILL.md +22 -0
  4. package/.claude-plugin/marketplace.json +1 -1
  5. package/.claude-plugin/plugin.json +3 -2
  6. package/.codex-plugin/plugin.json +1 -1
  7. package/.codex-plugin/skills/session-plan/SKILL.md +1 -1
  8. package/.codex-plugin/skills/session-start/SKILL.md +1 -1
  9. package/.codex-plugin/skills/ux-grill/SKILL.md +21 -0
  10. package/.codex-plugin/skills/ux-grill/agents/openai.yaml +5 -0
  11. package/.cursor/commands/ux-grill.md +14 -0
  12. package/.cursor/skills/session-plan/SKILL.md +1 -1
  13. package/.cursor/skills/session-start/SKILL.md +1 -1
  14. package/.cursor/skills/ux-grill/SKILL.md +13 -0
  15. package/.cursor-plugin/plugin.json +1 -1
  16. package/AGENTS.md +2 -1
  17. package/CHANGELOG.md +128 -1
  18. package/README.md +98 -86
  19. package/agents/dialectic-deriver.md +11 -0
  20. package/agents/ux-evaluator.md +1 -1
  21. package/commands/close.md +3 -3
  22. package/commands/go.md +2 -0
  23. package/commands/memory-cleanup.md +4 -3
  24. package/commands/persona-panel.md +1 -1
  25. package/commands/session.md +3 -2
  26. package/commands/ux-grill.md +51 -0
  27. package/docs/README.md +4 -4
  28. package/docs/USER-GUIDE.md +117 -50
  29. package/docs/agent-authoring.md +2 -2
  30. package/docs/baseline.md +55 -1
  31. package/docs/ci-setup.md +1 -1
  32. package/docs/codex-setup.md +9 -0
  33. package/docs/components.md +9 -9
  34. package/docs/cursor-setup.md +1 -0
  35. package/docs/events-schema.md +13 -6
  36. package/docs/github-mirror-protection.md +61 -20
  37. package/docs/instruction-delivery.md +1 -1
  38. package/docs/memory-proposal-flow.md +3 -3
  39. package/docs/migration-v4.md +2 -2
  40. package/docs/migration-v5.md +62 -0
  41. package/docs/owner-config-schema.md +74 -90
  42. package/docs/persona-panel.md +4 -4
  43. package/docs/pi-setup.md +1 -0
  44. package/docs/rule-authoring.md +13 -6
  45. package/docs/scope-collision-guard.md +16 -0
  46. package/docs/session-config-reference.md +55 -22
  47. package/docs/session-config-template.md +9 -5
  48. package/docs/vault-docs-architecture.md +4 -2
  49. package/hooks/_lib/hook-import-set.json +70 -3
  50. package/hooks/_lib/lock-bootstrap.mjs +84 -1
  51. package/hooks/_lib/vcs-create-matcher.mjs +401 -16
  52. package/hooks/enforce-scope.mjs +201 -0
  53. package/hooks/hooks-codex.json +1 -1
  54. package/hooks/hooks-cursor.json +5 -0
  55. package/hooks/hooks.json +7 -2
  56. package/hooks/on-session-start.mjs +171 -49
  57. package/hooks/post-bash-issue-budget-refund.mjs +375 -0
  58. package/hooks/pre-auq-clarity.mjs +70 -18
  59. package/hooks/pre-bash-issue-budget.mjs +170 -26
  60. package/hooks/subagent-telemetry.mjs +106 -20
  61. package/package.json +5 -4
  62. package/pi/prompts/ux-grill.md +12 -0
  63. package/scripts/baseline-archetypes.mjs +28 -0
  64. package/scripts/ci/assert-vitest-green.mjs +4 -2
  65. package/scripts/dialectic-deriver.mjs +32 -8
  66. package/scripts/emit-session.mjs +72 -1
  67. package/scripts/lib/agent-status.mjs +441 -9
  68. package/scripts/lib/auq/schema.mjs +10 -3
  69. package/scripts/lib/auto-dialectic.mjs +0 -68
  70. package/scripts/lib/baseline-archetypes.mjs +439 -0
  71. package/scripts/lib/build-live-signals.mjs +5 -6
  72. package/scripts/lib/ci-status-banner.mjs +29 -6
  73. package/scripts/lib/claude-md-budget-lint.mjs +52 -2
  74. package/scripts/lib/config/issue-budget.mjs +68 -8
  75. package/scripts/lib/config/private-config-dir.mjs +3 -2
  76. package/scripts/lib/config/remote-hosts.mjs +2 -2
  77. package/scripts/lib/config-schema.mjs +79 -0
  78. package/scripts/lib/config.mjs +12 -1
  79. package/scripts/lib/eval/engine.mjs +7 -1
  80. package/scripts/lib/file-lock.mjs +151 -8
  81. package/scripts/lib/git-porcelain.mjs +113 -0
  82. package/scripts/lib/instruction-budget-guard.mjs +415 -47
  83. package/scripts/lib/io.mjs +29 -4
  84. package/scripts/lib/issue-budget-reconcile.mjs +392 -0
  85. package/scripts/lib/issue-budget.mjs +412 -9
  86. package/scripts/lib/learnings/evolve-telemetry.mjs +1 -2
  87. package/scripts/lib/learnings/sizing-subject.mjs +44 -0
  88. package/scripts/lib/locks/staging-fence-lock.mjs +19 -38
  89. package/scripts/lib/locks/state-md-lock.mjs +19 -41
  90. package/scripts/lib/maintenance-due-banner.mjs +450 -0
  91. package/scripts/lib/owner-config.example.yaml +29 -46
  92. package/scripts/lib/owner-yaml.mjs +14 -13
  93. package/scripts/lib/peer-cards/merger.mjs +143 -0
  94. package/scripts/lib/pre-dispatch-check.mjs +20 -14
  95. package/scripts/lib/project-hygiene.mjs +81 -30
  96. package/scripts/lib/quality-gate.mjs +27 -71
  97. package/scripts/lib/reconcile/engine.mjs +19 -1
  98. package/scripts/lib/reconcile/writer.mjs +278 -11
  99. package/scripts/lib/resource-probe/evaluate.mjs +19 -21
  100. package/scripts/lib/rules-sync.mjs +34 -4
  101. package/scripts/lib/scope-echo.mjs +346 -0
  102. package/scripts/lib/session-close-backfill.mjs +182 -40
  103. package/scripts/lib/session-end/phase-skip.mjs +85 -86
  104. package/scripts/lib/session-end/tail-runner.mjs +178 -0
  105. package/scripts/lib/session-lock.mjs +62 -2
  106. package/scripts/lib/session-record-repair.mjs +91 -0
  107. package/scripts/lib/session-schema/constants.mjs +6 -0
  108. package/scripts/lib/session-schema/filters.mjs +26 -1
  109. package/scripts/lib/session-schema/validator.mjs +20 -0
  110. package/scripts/lib/session-shape.mjs +558 -0
  111. package/scripts/lib/session-start-probes.mjs +429 -56
  112. package/scripts/lib/session-token-rollup.mjs +95 -10
  113. package/scripts/lib/state-md/frontmatter-mutators.mjs +22 -34
  114. package/scripts/lib/state-md.mjs +1 -0
  115. package/scripts/lib/subagents-schema.mjs +77 -9
  116. package/scripts/lib/telemetry/pricing.mjs +197 -0
  117. package/scripts/lib/telemetry/sync.mjs +50 -1
  118. package/scripts/lib/test-runner/artifact-paths.mjs +30 -5
  119. package/scripts/lib/test-runner/issue-reconcile.mjs +45 -8
  120. package/scripts/lib/tmux-layout/layouts.mjs +62 -4
  121. package/scripts/lib/ux-grill/collect.mjs +1163 -0
  122. package/scripts/lib/ux-grill/compare.mjs +285 -0
  123. package/scripts/lib/ux-grill/manifest.mjs +618 -0
  124. package/scripts/lib/ux-grill/measures.mjs +431 -0
  125. package/scripts/lib/ux-grill/paths.mjs +224 -0
  126. package/scripts/lib/ux-grill/pencil-coverage.mjs +284 -0
  127. package/scripts/lib/ux-grill/reconcile.mjs +344 -0
  128. package/scripts/lib/ux-grill/run-record.mjs +316 -0
  129. package/scripts/lib/ux-grill/schema.mjs +321 -0
  130. package/scripts/lib/validate/check-skill-script-paths.mjs +33 -10
  131. package/scripts/lib/validate/check-untracked-test-deps.mjs +33 -19
  132. package/scripts/lib/validate/check-unwired-features.mjs +56 -27
  133. package/scripts/lib/vault-mirror/process.mjs +2 -1
  134. package/scripts/lib/vault-status/board-lock.mjs +18 -0
  135. package/scripts/lib/vault-status/board-writer.mjs +8 -0
  136. package/scripts/lib/vault-status/narrative-mirror.mjs +4 -4
  137. package/scripts/lib/wave-resource-gate.mjs +23 -27
  138. package/scripts/lib/wave-sizing.mjs +10 -3
  139. package/scripts/materialize-wave-scope.mjs +68 -14
  140. package/scripts/mcp-server.sh +16 -1
  141. package/scripts/print-applicable-rules.mjs +7 -6
  142. package/scripts/print-learnings-index.mjs +3 -2
  143. package/scripts/release.mjs +7 -2
  144. package/scripts/session-shape.mjs +266 -0
  145. package/skills/_shared/config-reading.md +15 -9
  146. package/skills/_shared/private-capability-context.md +89 -0
  147. package/skills/bootstrap/SKILL.md +60 -209
  148. package/skills/bootstrap/_shared-template.md +99 -14
  149. package/skills/bootstrap/deep-template.md +36 -26
  150. package/skills/bootstrap/fast-template.md +44 -8
  151. package/skills/bootstrap/intensity-heuristic.md +10 -4
  152. package/skills/bootstrap/private-contract.md +119 -0
  153. package/skills/bootstrap/public-fallback.md +30 -18
  154. package/skills/bootstrap/references/bootstrap-ecosystem-health-flow.md +48 -0
  155. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +37 -0
  156. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +108 -0
  157. package/skills/bootstrap/references/bootstrap-rules-fetch-bridge.md +64 -0
  158. package/skills/bootstrap/standard-template.md +39 -24
  159. package/skills/claude-md-drift-check/SKILL.md +9 -2
  160. package/skills/claude-md-drift-check/checker.mjs +213 -21
  161. package/skills/discovery/SKILL.md +6 -173
  162. package/skills/discovery/probes/vault-staleness.mjs +35 -5
  163. package/skills/discovery/probes-docs.md +8 -4
  164. package/skills/discovery/probes-supply-chain.md +4 -2
  165. package/skills/discovery/probes-ui.md +8 -4
  166. package/skills/discovery/probes-vault.md +12 -4
  167. package/skills/discovery/references/discovery-interactive-triage.md +139 -0
  168. package/skills/discovery/references/discovery-triage-state.md +54 -0
  169. package/skills/docs-orchestrator/audience-mapping.md +1 -1
  170. package/skills/eval/rubric-v1.md +13 -0
  171. package/skills/evolve/SKILL.md +2 -458
  172. package/skills/evolve/references/evolve-analyze-mode.md +360 -0
  173. package/skills/evolve/references/evolve-dialectic-mode.md +139 -0
  174. package/skills/gitlab-ops/SKILL.md +3 -3
  175. package/skills/grill/SKILL.md +1 -1
  176. package/skills/memory-cleanup/SKILL.md +2 -2
  177. package/skills/plan/mode-new.md +9 -0
  178. package/skills/plan/mode-retro.md +4 -3
  179. package/skills/reconcile/SKILL.md +11 -1
  180. package/skills/session-end/SKILL.md +3 -2
  181. package/skills/session-end/drift-operations.md +20 -5
  182. package/skills/session-end/metrics-collection.md +1 -0
  183. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  184. package/skills/session-end/phase-3-6-tail.md +27 -67
  185. package/skills/session-end/phase-3-7a-recommendations.md +2 -2
  186. package/skills/session-end/references/phase-2-quality-gate.md +3 -3
  187. package/skills/session-end/references/phase-3-documentation-updates.md +8 -6
  188. package/skills/session-end/references/phase-5-issue-cleanup.md +32 -1
  189. package/skills/session-end/session-metrics-write.md +33 -12
  190. package/skills/session-plan/SKILL.md +46 -180
  191. package/skills/session-plan/references/session-plan-task-classification.md +152 -0
  192. package/skills/session-plan/wave-template.md +8 -15
  193. package/skills/session-start/SKILL.md +41 -7
  194. package/skills/session-start/phase-2-5-docs-planning.md +1 -1
  195. package/skills/session-start/phase-8-5-express-path.md +12 -9
  196. package/skills/session-start/references/operations-contract.md +114 -0
  197. package/skills/session-start/references/phase-1-5-session-continuity.md +2 -0
  198. package/skills/session-start/references/phase-4-ssot-environment-check.md +42 -24
  199. package/skills/session-start/references/phase-6-7-memory-banner-telemetry-consent.md +3 -1
  200. package/skills/session-start/soul.md +2 -2
  201. package/skills/test-runner/SKILL.md +1 -1
  202. package/skills/test-runner/rubric-v1.md +2 -2
  203. package/skills/tmux-layout/SKILL.md +3 -1
  204. package/skills/ux-grill/SKILL.md +211 -0
  205. package/skills/ux-grill/rubric-v2.md +201 -0
  206. package/skills/ux-grill/soul.md +76 -0
  207. package/skills/wave-executor/SKILL.md +32 -127
  208. package/skills/wave-executor/circuit-breaker.md +3 -1
  209. package/skills/wave-executor/references/wave-executor-quality-gate.md +61 -0
  210. package/skills/wave-executor/references/wave-executor-state-init.md +86 -0
  211. package/skills/wave-executor/references/wave-loop-dispatch.md +12 -2
  212. package/skills/wave-executor/references/wave-loop-review.md +19 -6
  213. package/skills/wave-executor/references/wave-loop-scope-manifest.md +6 -2
  214. package/templates/_shared/ux-manifest.template.md +149 -0
  215. package/templates/nextjs-minimal/package.json +1 -1
  216. package/templates/node-minimal/package.json +1 -1
  217. package/scripts/lib/multi-provider-build/providers.mjs +0 -64
  218. package/scripts/lib/multi-provider-build/templating.mjs +0 -130
  219. package/scripts/lib/owner-config/coerce.mjs +0 -29
  220. package/scripts/lib/owner-config/constants.mjs +0 -21
  221. package/scripts/lib/owner-config/defaults.mjs +0 -50
  222. package/scripts/lib/owner-config/error.mjs +0 -19
  223. package/scripts/lib/owner-config/index.mjs +0 -13
  224. package/scripts/lib/owner-config/merge.mjs +0 -52
  225. package/scripts/lib/owner-config/validate.mjs +0 -259
  226. package/scripts/lib/owner-config-loader.mjs +0 -170
  227. package/scripts/lib/owner-config.mjs +0 -28
  228. package/scripts/lib/soul-resolve.mjs +0 -130
  229. package/scripts/lib/vault-mirror/render.mjs +0 -8
  230. package/templates/_shared/journey-manifest.md +0 -114
@@ -17,10 +17,12 @@
17
17
  * Decision flow:
18
18
  * G1 tool filter — only Bash is gated.
19
19
  * G2 command is a non-empty string.
20
- * G3 matcher — `gh|glab … issue create|new` only. PR/MR creation passes.
20
+ * G3 matcher — `gh|glab … issue create|new` plus the REST route
21
+ * (`gh|glab api … /issues`, #1163) only. PR/MR creation passes.
21
22
  * Verb-resolved since #1145, so a wrapped (`nohup`), absolute-path or
22
23
  * env-prefixed create is seen; a `--help` invocation is not (it creates
23
- * nothing).
24
+ * nothing). Since #1163 the matcher returns EVERY create statement of the
25
+ * chain, and the cap charges ONE unit per statement.
24
26
  * G4 config — `issue-budget` from CLAUDE.md/AGENTS.md. `mode: off` → allow.
25
27
  * G3b bulk — a create inside a shell LOOP body creates an unknowable number
26
28
  * of issues (#1145). `strict` → deny; `warn` → allow with an explicit
@@ -29,7 +31,8 @@
29
31
  * G5 exemption — priority::critical / carryover class / broken-window /
30
32
  * the overflow collector itself bypass the cap unconditionally, keeping
31
33
  * the session-end promises at SKILL.md:319 and :1113 intact.
32
- * G6 charge the counter in .orchestrator/runtime/issue-budget/<hash>.json
34
+ * G6 charge the counter ONCE PER ISSUE-CREATE STATEMENT in
35
+ * .orchestrator/runtime/issue-budget/<hash>.json
33
36
  * (one file per session since #1141 — see scripts/lib/issue-budget.mjs
34
37
  * `budgetStateRel`).
35
38
  * under cap → allow; over cap + `warn` → allow with stderr notice;
@@ -50,13 +53,17 @@
50
53
  import { readStdin, emitAllow, emitDeny, emitWarn } from '../scripts/lib/io.mjs';
51
54
  import { resolveProjectDir } from '../scripts/lib/platform.mjs';
52
55
  import { readJson } from '../scripts/lib/common.mjs';
53
- import { isIssueCreate, isLoopedIssueCreate, extractTitle } from './_lib/vcs-create-matcher.mjs';
56
+ import { findIssueCreateStatements, isLoopedIssueCreate } from './_lib/vcs-create-matcher.mjs';
54
57
  import {
55
58
  loadIssueBudgetConfig,
56
59
  resolveIssueBudgetSessionId,
57
60
  chargeIssueBudget,
58
61
  classifyExemption,
59
62
  formatBlockReason,
63
+ readBudgetState,
64
+ writeBudgetState,
65
+ budgetStatePath,
66
+ buildOverflowRecord,
60
67
  } from '../scripts/lib/issue-budget.mjs';
61
68
 
62
69
  import { shouldRunHook } from './_lib/profile-gate.mjs';
@@ -114,6 +121,25 @@ async function resolveSessionId(input, projectDir) {
114
121
  return resolveIssueBudgetSessionId(nativeRawId, currentSession);
115
122
  }
116
123
 
124
+ /**
125
+ * The harness's id for THIS tool call, when it publishes one — the first half of
126
+ * a charge record's identity (see `chargeIssueBudget`). Several spellings are
127
+ * accepted because the payload key is the harness's to choose, and a missed id
128
+ * silently degrades to the deterministic command key rather than failing.
129
+ *
130
+ * Kept byte-identical in shape to the refund hook's copy: the two must resolve
131
+ * the same id from the same payload or the refund cannot find the charge.
132
+ *
133
+ * @param {object} input
134
+ * @returns {string|null}
135
+ */
136
+ function resolveToolCallId(input) {
137
+ for (const value of [input?.tool_use_id, input?.toolUseId, input?.tool_call_id, input?.tool_id]) {
138
+ if (typeof value === 'string' && value.length > 0) return value;
139
+ }
140
+ return null;
141
+ }
142
+
117
143
  /**
118
144
  * THE CHOICE (#1145) — stated once, so the loop behaviour is explicit rather
119
145
  * than emergent.
@@ -145,7 +171,13 @@ async function resolveSessionId(input, projectDir) {
145
171
  *
146
172
  * NAMED CEILING (BV-004): a loop is detected by `do`/`done` in command position
147
173
  * (see `isLoopedIssueCreate`), so an UNROLLED bulk create — 50 create statements
148
- * chained with `&&` — is not a "loop" and is charged 50, correctly. Revisit this
174
+ * chained with `&&` — is not a "loop". Since #1163 it is charged 50, once per
175
+ * issue-create STATEMENT: until then this comment CLAIMED that behaviour while
176
+ * the code called `chargeIssueBudget` exactly once per Bash tool call with the
177
+ * whole command string, so `glab issue create --title A && glab issue create
178
+ * --title B` charged 1 for 2 (measured 2026-09-09). The claim is now true
179
+ * because `findIssueCreateStatements` supplies the per-statement units and the
180
+ * exemption is classified per statement too. Revisit this
149
181
  * choice if the overflow triage of a per-session counter file
150
182
  * (`.orchestrator/runtime/issue-budget/<hash>.json`) shows operators routinely
151
183
  * hitting this deny on loops over a KNOWN literal word list; the cheap answer
@@ -176,6 +208,73 @@ function formatLoopDenyReason(config) {
176
208
  ].join('\n');
177
209
  }
178
210
 
211
+ /**
212
+ * Park every chargeable statement of a command that does NOT fit under the cap,
213
+ * and return a `formatBlockReason`-shaped verdict for the deny envelope.
214
+ *
215
+ * ## Why this is not `chargeIssueBudget`
216
+ *
217
+ * `chargeIssueBudget` decides ONE creation against the current count, and its
218
+ * strict branch parks only when the count is ALREADY at the cap. A chain of
219
+ * statements that straddles the cap (count 11, max 12, three creates) has no
220
+ * single call shape in that API: the first statement would be ALLOWED and
221
+ * counted, and the deny that follows would leave that count standing for an
222
+ * issue nobody created. So the fit is judged for the chain as a whole and the
223
+ * whole chain is parked — count and exempt untouched, because nothing ran.
224
+ *
225
+ * The exemption CLASSIFICATION still comes from the shared core
226
+ * (`classifyExemption`, applied by the caller); what is local here is only the
227
+ * bookkeeping write, through the module's own public `writeBudgetState`.
228
+ *
229
+ * An identity-less invocation (no session key) must not write at all — the
230
+ * legacy flat path is shared across sessions and writing it would reset a live
231
+ * session's count and drop its parked overflow. Same rule `chargeIssueBudget`'s
232
+ * `persist` applies; the deny still happens, only unrecorded.
233
+ *
234
+ * @param {{ projectDir: string, sessionId: string|null,
235
+ * state: { count: number, exempt: number, overflow: object[], sessionId: string|null },
236
+ * chargeable: Array<{ text: string, title: string|null, description: string|null,
237
+ * descriptionFile: string|null, repo: string|null,
238
+ * cwdChanged: boolean }>,
239
+ * cwd?: string|null,
240
+ * config: { "max-per-session": number, mode: string, overflow: string },
241
+ * now?: string }} opts
242
+ * @returns {{ count: number, max: number, overflowPath: string,
243
+ * overflowSink: string, overflowCount: number }}
244
+ */
245
+ function parkOverflow({
246
+ projectDir,
247
+ sessionId,
248
+ state,
249
+ chargeable,
250
+ config,
251
+ cwd = null,
252
+ now = new Date().toISOString(),
253
+ }) {
254
+ state.sessionId = sessionId;
255
+ for (const s of chargeable) {
256
+ state.overflow.push(buildOverflowRecord({
257
+ repoRoot: projectDir,
258
+ title: s.title,
259
+ description: s.description,
260
+ descriptionFile: s.descriptionFile,
261
+ repo: s.repo,
262
+ cwd,
263
+ cwdChanged: s.cwdChanged,
264
+ command: s.text,
265
+ at: now,
266
+ }));
267
+ }
268
+ if (sessionId !== null) writeBudgetState(projectDir, state);
269
+ return {
270
+ count: state.count,
271
+ max: config['max-per-session'],
272
+ overflowPath: budgetStatePath(projectDir, sessionId),
273
+ overflowSink: config.overflow,
274
+ overflowCount: state.overflow.length,
275
+ };
276
+ }
277
+
179
278
  // ---------------------------------------------------------------------------
180
279
  // Main
181
280
  // ---------------------------------------------------------------------------
@@ -191,8 +290,11 @@ async function main() {
191
290
  const command = input?.tool_input?.command;
192
291
  if (typeof command !== 'string' || command.length === 0) return emitAllow();
193
292
 
194
- // G3 — shared matcher. Only ISSUE creation is capped; `pr`/`mr` create pass.
195
- if (!isIssueCreate(command)) return emitAllow();
293
+ // G3 — shared matcher, PER STATEMENT (#1163). Only ISSUE creation is capped;
294
+ // `pr`/`mr` create pass. An empty list is the old `!isIssueCreate(command)`
295
+ // short-circuit, unchanged.
296
+ const statements = findIssueCreateStatements(command);
297
+ if (statements.length === 0) return emitAllow();
196
298
 
197
299
  const projectDir = resolveProjectDir() || process.cwd();
198
300
 
@@ -202,9 +304,12 @@ async function main() {
202
304
 
203
305
  // G3b — bulk creation whose multiplicity is not computable (#1145). The
204
306
  // exemption is asked FIRST, through the same classifier chargeIssueBudget
205
- // uses, so a looped carryover sweep keeps its unconditional pass.
307
+ // uses, so a looped carryover sweep keeps its unconditional pass. It is asked
308
+ // on the FIRST issue-create statement's text, which is the very statement
309
+ // `isLoopedIssueCreate` judges — classifying it on the whole command would
310
+ // let an exempt NEIGHBOUR statement lift the loop deny.
206
311
  const uncountableBulk =
207
- isLoopedIssueCreate(command) && !classifyExemption(command).exempt;
312
+ isLoopedIssueCreate(command) && !classifyExemption(statements[0].text).exempt;
208
313
  if (uncountableBulk && config.mode === 'strict') {
209
314
  // Nothing is charged and nothing is parked — the command is handed back
210
315
  // whole, which is what makes unrolling it the correct next action.
@@ -213,26 +318,63 @@ async function main() {
213
318
 
214
319
  const sessionId = await resolveSessionId(input, projectDir);
215
320
 
216
- // G5 + G6 exemption check and counter charge live in the shared core so
217
- // the programmatic path (scripts/lib/spiral-carryover.mjs runCli) decides
218
- // identically.
219
- const verdict = chargeIssueBudget({
220
- repoRoot: projectDir,
221
- sessionId,
222
- command,
223
- title: extractTitle(command),
224
- config,
225
- });
321
+ // G5 pre-flighta Bash call is ATOMIC from this hook's point of view: a
322
+ // deny refuses the WHOLE command, so not one of its statements runs. Charging
323
+ // statement-by-statement until one blocks would therefore count creations
324
+ // that never happened (and double-count them when the operator re-issues the
325
+ // command unrolled). So the fit is decided BEFORE any charge, and a command
326
+ // that does not fit parks every chargeable statement without counting any.
327
+ const chargeable = statements.filter((s) => !classifyExemption(s.text).exempt);
328
+ if (config.mode === 'strict' && chargeable.length > 0) {
329
+ const state = readBudgetState(projectDir, sessionId);
330
+ if (state.count + chargeable.length > config['max-per-session']) {
331
+ return emitDeny(formatBlockReason(parkOverflow({
332
+ projectDir, sessionId, state, chargeable, config, cwd: input.cwd,
333
+ })));
334
+ }
335
+ }
336
+
337
+ // G6 — charge ONE unit per issue-create STATEMENT. The decision itself stays
338
+ // in the shared core (scripts/lib/issue-budget.mjs), so the programmatic path
339
+ // (scripts/lib/spiral-carryover.mjs runCli) decides identically; what changed
340
+ // in #1163 is only HOW MANY times it is asked. Each statement is judged on
341
+ // its OWN text: `glab issue create --title REAL && glab issue create
342
+ // --label carryover --title X` is 1 charge + 1 exemption, never 2 exemptions.
343
+ // `toolCallId` + the statement index are the CHARGE RECORD's identity (#1347):
344
+ // the refund hook may only give back a slot it can find in `charged[]`, so the
345
+ // charge has to be recorded under the same pair the PostToolUseFailure payload
346
+ // for this very call will present. Absent an id, the deterministic
347
+ // session+command+index key carries it.
348
+ const toolCallId = resolveToolCallId(input);
349
+ const verdicts = statements.map((s, i) =>
350
+ chargeIssueBudget({
351
+ repoRoot: projectDir,
352
+ sessionId,
353
+ command: s.text,
354
+ title: s.title,
355
+ description: s.description,
356
+ descriptionFile: s.descriptionFile,
357
+ repo: s.repo,
358
+ cwd: input.cwd,
359
+ cwdChanged: s.cwdChanged,
360
+ toolCallId,
361
+ statementIndex: i,
362
+ config,
363
+ }),
364
+ );
365
+ const verdict = verdicts[verdicts.length - 1];
366
+ const exemptions = verdicts.filter((v) => v.decision === 'exempt');
367
+ const blocked = verdicts.find((v) => v.decision === 'block');
226
368
 
227
- if (verdict.decision === 'exempt') {
369
+ if (exemptions.length > 0) {
370
+ const reasons = [...new Set(exemptions.map((v) => v.reason))].join(', ');
228
371
  process.stderr.write(
229
- `ℹ pre-bash-issue-budget: exempt (${verdict.reason}) — cap not charged ` +
230
- `(${verdict.count}/${verdict.max})\n`,
372
+ `ℹ pre-bash-issue-budget: ${exemptions.length} exempt statement(s) (${reasons}) — ` +
373
+ `cap not charged (${verdict.count}/${verdict.max})\n`,
231
374
  );
232
- return emitAllow();
233
375
  }
234
376
 
235
- if (verdict.decision === 'warn') {
377
+ if (verdicts.some((v) => v.decision === 'warn')) {
236
378
  process.stderr.write(
237
379
  `⚠ pre-bash-issue-budget: session cap exceeded — ${verdict.count}/${verdict.max} ` +
238
380
  `issues created (mode: warn — allowing). Set \`issue-budget.mode: strict\` to enforce.\n`,
@@ -240,7 +382,9 @@ async function main() {
240
382
  return emitAllow();
241
383
  }
242
384
 
243
- if (verdict.decision === 'block') {
385
+ if (exemptions.length === statements.length) return emitAllow();
386
+
387
+ if (blocked) {
244
388
  // Single channel (#906). formatBlockReason's multi-line text — overflow
245
389
  // store path, the [Backlog-Sammel] fold-in promise, the exemption list and
246
390
  // the cap-raising hint — used to go to stderr AND to a duplicated `exit 2`
@@ -249,7 +393,7 @@ async function main() {
249
393
  // to Claude (the actor that must re-file or defer the issue), while the
250
394
  // operator gets the first line as the systemMessage headline. Under exit 0
251
395
  // a stderr write would only reach the debug log — dead, but alive-looking.
252
- emitDeny(formatBlockReason(verdict));
396
+ return emitDeny(formatBlockReason(blocked));
253
397
  }
254
398
 
255
399
  // A PERMITTED bulk create is charged ONCE, which is an undercount by
@@ -64,6 +64,33 @@
64
64
  * `subagent_transcript_found: true` as token-bearing; summing across the
65
65
  * history double-counts the parent once per subagent.
66
66
  *
67
+ * TOKEN-DATA PROVENANCE — SERIES BREAK 2026-09-09 (schema_version 2, #1244).
68
+ * Until this fix `token_input` was the raw `usage.input_tokens` sum only, and
69
+ * `cache_read_input_tokens` / `cache_creation_input_tokens` were read nowhere in
70
+ * the repo. Under prompt caching virtually the whole prompt is cache traffic, so
71
+ * that number described a rounding error rather than the run: measured
72
+ * 2026-09-09 on agent af50d1eda37099e17, the ledger recorded token_input 56
73
+ * where its transcript holds 56 + 3,528,536 cache_read + 147,587 cache_creation
74
+ * = 3,676,179 — an understatement of 65,646×; session-wide 1,518 vs 85,271,214.
75
+ * (token_output was within ~11% and is unaffected.)
76
+ *
77
+ * From schema_version 2 onward:
78
+ * token_input = uncached + cache_read + cache_creation
79
+ * (BILLABLE PROMPT VOLUME — a redefinition, not a
80
+ * widening: v1 and v2 values are not comparable)
81
+ * token_input_uncached = raw usage.input_tokens (additive)
82
+ * token_cache_read = usage.cache_read_input_tokens (additive)
83
+ * token_cache_creation = usage.cache_creation_input_tokens (additive)
84
+ * model = message.model, or null (additive; enables pricing)
85
+ * gen_ai.usage.input_tokens stays the RAW UNCACHED value (OTel semantic), so
86
+ * it is deliberately ≠ token_input under v2. Two new OTel aliases carry the
87
+ * cache buckets.
88
+ *
89
+ * FORWARD-ONLY. Nothing recomputes the v1 history, so consumers MUST gate any
90
+ * v2 sum on `schema_version >= 2` — `scripts/lib/session-token-rollup.mjs` does
91
+ * exactly that and reports the excluded v1 records as `legacy_v1_records`
92
+ * rather than silently folding them in.
93
+ *
67
94
  * A SECOND, independent defect rode along until #950: the requestId dedup kept
68
95
  * the FIRST usage block per id, which on a streaming transcript is a partial
69
96
  * snapshot (typically `output_tokens: 1`). Any record written before #950 —
@@ -391,13 +418,27 @@ function readStdinJson() {
391
418
  * so the present side still sums and the aggregate stays valid.
392
419
  *
393
420
  * NEVER throws. Any failure (missing/unreadable path, 0 assistant turns, parse
394
- * error) yields { tokenInput: null, tokenOutput: null } so the hook still exits 0.
421
+ * error) yields an all-null result so the hook still exits 0.
422
+ *
423
+ * Four buckets, not two (#1244 / schema_version 2): `input_tokens`,
424
+ * `cache_read_input_tokens`, `cache_creation_input_tokens` and `output_tokens`
425
+ * are accumulated SEPARATELY per deduped turn, because they are billed at three
426
+ * different rates. `model` is captured from `message.model` of the last kept
427
+ * block that carries one (null when absent) — it is what makes a cost estimate
428
+ * possible at all downstream.
395
429
  *
396
430
  * @param {string|undefined|null} transcriptPath — absolute path from stdin
397
- * @returns {{ tokenInput: number|null, tokenOutput: number|null }}
431
+ * @returns {{ tokenInputUncached: number|null, tokenCacheRead: number|null,
432
+ * tokenCacheCreation: number|null, tokenOutput: number|null, model: string|null }}
398
433
  */
399
434
  function extractTranscriptUsage(transcriptPath) {
400
- const nullResult = { tokenInput: null, tokenOutput: null };
435
+ const nullResult = {
436
+ tokenInputUncached: null,
437
+ tokenCacheRead: null,
438
+ tokenCacheCreation: null,
439
+ tokenOutput: null,
440
+ model: null,
441
+ };
401
442
  try {
402
443
  if (typeof transcriptPath !== 'string' || !transcriptPath.trim()) return nullResult;
403
444
  if (!fs.existsSync(transcriptPath)) return nullResult;
@@ -447,9 +488,15 @@ function extractTranscriptUsage(transcriptPath) {
447
488
  // Dedup by requestId — keep the LAST usage block per id (#950). The
448
489
  // repeats are cumulative streaming snapshots, so overwriting is what
449
490
  // promotes the partial first snapshot to the response's real total.
491
+ // Model id rides along with the usage block (#1244): it is per-turn data
492
+ // and the only thing that makes the record priceable downstream.
493
+ const turnModel = typeof obj.message?.model === 'string' && obj.message.model
494
+ ? obj.message.model
495
+ : null;
496
+
450
497
  const requestId = obj.requestId;
451
498
  if (typeof requestId === 'string' && requestId) {
452
- byRequestId.set(requestId, usage);
499
+ byRequestId.set(requestId, { usage, model: turnModel });
453
500
  continue;
454
501
  }
455
502
 
@@ -461,9 +508,9 @@ function extractTranscriptUsage(transcriptPath) {
461
508
  // usable identity; anything else falls through to the individual count.
462
509
  const messageId = obj.message?.id;
463
510
  if (typeof messageId === 'string' && messageId) {
464
- byMessageId.set(messageId, usage);
511
+ byMessageId.set(messageId, { usage, model: turnModel });
465
512
  } else {
466
- unkeyable.push(usage);
513
+ unkeyable.push({ usage, model: turnModel });
467
514
  }
468
515
  }
469
516
 
@@ -472,23 +519,34 @@ function extractTranscriptUsage(transcriptPath) {
472
519
  // No assistant turns with usage → leave fields null (forward-compat).
473
520
  if (kept.length === 0) return nullResult;
474
521
 
475
- let tokenInput = 0;
522
+ let tokenInputUncached = 0;
523
+ let tokenCacheRead = 0;
524
+ let tokenCacheCreation = 0;
476
525
  let tokenOutput = 0;
477
- for (const usage of kept) {
526
+ let model = null;
527
+ for (const { usage, model: turnModel } of kept) {
478
528
  // Per-turn clamp (#624): add a turn's value ONLY when it is a non-negative
479
529
  // integer. A poisoned value (negative, NaN, float like 10.5) is skipped so
480
530
  // the good turns survive. An absent side contributes 0, not null.
481
531
  const inTok = usage.input_tokens;
532
+ const cacheRead = usage.cache_read_input_tokens;
533
+ const cacheCreation = usage.cache_creation_input_tokens;
482
534
  const outTok = usage.output_tokens;
483
- if (Number.isInteger(inTok) && inTok >= 0) tokenInput += inTok;
535
+ if (Number.isInteger(inTok) && inTok >= 0) tokenInputUncached += inTok;
536
+ if (Number.isInteger(cacheRead) && cacheRead >= 0) tokenCacheRead += cacheRead;
537
+ if (Number.isInteger(cacheCreation) && cacheCreation >= 0) tokenCacheCreation += cacheCreation;
484
538
  if (Number.isInteger(outTok) && outTok >= 0) tokenOutput += outTok;
539
+ if (turnModel !== null) model = turnModel;
485
540
  }
486
541
 
487
- // The aggregate is guaranteed a non-negative integer by per-turn clamping
488
- // above (Σ of non-negative integers), so emit it directly.
542
+ // The aggregates are guaranteed non-negative integers by per-turn clamping
543
+ // above (Σ of non-negative integers), so emit them directly.
489
544
  return {
490
- tokenInput,
545
+ tokenInputUncached,
546
+ tokenCacheRead,
547
+ tokenCacheCreation,
491
548
  tokenOutput,
549
+ model,
492
550
  };
493
551
  } catch {
494
552
  return nullResult;
@@ -657,7 +715,7 @@ async function main() {
657
715
  timestamp: new Date().toISOString(),
658
716
  event,
659
717
  agent_id: agentId,
660
- schema_version: 1,
718
+ schema_version: 2,
661
719
  ...(agentType !== null ? { agent_type: agentType } : {}),
662
720
  ...(parentSessionId !== null ? { parent_session_id: parentSessionId } : {}),
663
721
  };
@@ -705,23 +763,51 @@ async function main() {
705
763
  // NO fallback to input.transcript_path: that path is the parent session
706
764
  // transcript, and reading it is the #949 defect (every stop inherited the
707
765
  // parent's running totals). A phantom stop gets null — the honest value.
708
- const { tokenInput, tokenOutput } = subagentTranscriptFound
709
- ? extractTranscriptUsage(subagentTranscriptPath)
710
- : { tokenInput: null, tokenOutput: null };
711
- if (tokenInput !== null) record.token_input = tokenInput;
766
+ const { tokenInputUncached, tokenCacheRead, tokenCacheCreation, tokenOutput, model } =
767
+ subagentTranscriptFound
768
+ ? extractTranscriptUsage(subagentTranscriptPath)
769
+ : {
770
+ tokenInputUncached: null,
771
+ tokenCacheRead: null,
772
+ tokenCacheCreation: null,
773
+ tokenOutput: null,
774
+ model: null,
775
+ };
776
+
777
+ // schema_version 2 (#1244): `token_input` is now BILLABLE PROMPT VOLUME —
778
+ // uncached + cache_read + cache_creation — and the three components are
779
+ // written additively beside it. See the file header § TOKEN-DATA PROVENANCE
780
+ // for the 2026-09-09 series break this creates.
781
+ if (tokenInputUncached !== null) {
782
+ record.token_input =
783
+ tokenInputUncached + (tokenCacheRead ?? 0) + (tokenCacheCreation ?? 0);
784
+ record.token_input_uncached = tokenInputUncached;
785
+ record.token_cache_read = tokenCacheRead;
786
+ record.token_cache_creation = tokenCacheCreation;
787
+ }
712
788
  if (tokenOutput !== null) record.token_output = tokenOutput;
713
789
 
790
+ // Model id (#1244) — null when the transcript exposes none. Cost is NOT
791
+ // computed here: pricing lives in scripts/lib/telemetry/pricing.mjs and is
792
+ // applied by the session rollup, so this hot-path hook keeps its import
793
+ // graph unchanged.
794
+ record.model = model;
795
+
714
796
  // Cost is best-effort / forward-compat (#624): the native transcript does
715
797
  // NOT expose total_cost_usd today, so this is null in practice. No rate
716
- // table — use the native cost only, default null when absent.
798
+ // table is applied HERE — use the native cost only, default null when absent.
717
799
  const totalCostUsd =
718
800
  typeof input.total_cost_usd === 'number' && Number.isFinite(input.total_cost_usd) && input.total_cost_usd >= 0
719
801
  ? input.total_cost_usd
720
802
  : null;
721
803
  record.total_cost_usd = totalCostUsd;
722
804
 
723
- // OTel alias — #411 additive, schema_version=1 backwards-compat
724
- record['gen_ai.usage.input_tokens'] = tokenInput;
805
+ // OTel alias — #411 additive. `gen_ai.usage.input_tokens` stays the RAW
806
+ // UNCACHED value (OTel semantic), which is why it is deliberately NOT equal
807
+ // to `token_input` under schema_version 2.
808
+ record['gen_ai.usage.input_tokens'] = tokenInputUncached;
809
+ record['gen_ai.usage.cache_read_input_tokens'] = tokenCacheRead;
810
+ record['gen_ai.usage.cache_creation_input_tokens'] = tokenCacheCreation;
725
811
  record['gen_ai.usage.output_tokens'] = tokenOutput;
726
812
  record['gen_ai.system'] = 'anthropic';
727
813
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "session-orchestrator",
3
- "version": "4.1.0",
3
+ "version": "5.0.0",
4
4
  "description": "Loop engineering for AI coding agents — turn ad-hoc sessions into a repeatable research → plan → wave-execute → close loop with verification gates. Runs on Claude Code, Codex CLI, Cursor, and Pi.",
5
5
  "type": "module",
6
6
  "homepage": "https://session-orchestrator.com",
@@ -36,6 +36,7 @@
36
36
  "output-styles/",
37
37
  "monitors/",
38
38
  "assets/",
39
+ "!assets/marketing/**",
39
40
  ".claude-plugin/",
40
41
  ".codex-plugin/",
41
42
  ".cursor-plugin/",
@@ -69,7 +70,7 @@
69
70
  "dependencies": {
70
71
  "@babel/parser": "^7.26.0",
71
72
  "ajv": "^8.17.1",
72
- "js-yaml": "^4.3.1",
73
+ "js-yaml": "^4.3.2",
73
74
  "mdast-util-to-string": "^4.0.0",
74
75
  "picomatch": "^4.0.4",
75
76
  "remark": "^15.0.0",
@@ -83,12 +84,12 @@
83
84
  "@commitlint/cli": "^19.6.0",
84
85
  "@commitlint/config-conventional": "^19.6.0",
85
86
  "@eslint/js": "^10.0.1",
86
- "@vitest/coverage-v8": "^4.1.5",
87
+ "@vitest/coverage-v8": "^4.1.11",
87
88
  "eslint": "^10.2.1",
88
89
  "husky": "^9.1.7",
89
90
  "lint-staged": "^15.2.10",
90
91
  "prettier": "^3.0.0",
91
- "vitest": "^4.1.5"
92
+ "vitest": "^4.1.11"
92
93
  },
93
94
  "overrides": {
94
95
  "vite": "^8.0.16",
@@ -0,0 +1,12 @@
1
+ ---
2
+ description: Grill a running web app's UX — a deterministic mechanical pass (axe, target size, overflow, journeys) followed by a screenshot-grounded interrogation of the operator.
3
+ argument-hint: "[url | manifest-path]"
4
+ ---
5
+
6
+ # /ux-grill
7
+
8
+ Use the Session Orchestrator command definition at `commands/ux-grill.md`.
9
+
10
+ Arguments: $@
11
+
12
+ Read that command file and follow it exactly. When it references `$ARGUMENTS`, substitute the arguments above. Keep all Session Orchestrator platform fallbacks intact.
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env node
2
+ /** Data-only lookup for the configured local bootstrap baseline. */
3
+ import path from 'node:path';
4
+ import { realpathSync } from 'node:fs';
5
+ import { pathToFileURL } from 'node:url';
6
+ import { loadBaselineArchetypes } from './lib/baseline-archetypes.mjs';
7
+
8
+ export async function main(argv = process.argv.slice(2)) {
9
+ const options = {};
10
+ for (let index = 0; index < argv.length; index++) {
11
+ const arg = argv[index];
12
+ if (arg === '--help' || arg === '-h') {
13
+ process.stdout.write('Usage: node scripts/baseline-archetypes.mjs [--repo PATH] [--archetype ID]\nOffline, read-only JSON lookup. Exit 0: public/private; exit 2: invalid configuration or contract.\n');
14
+ return 0;
15
+ }
16
+ if (!['--repo', '--archetype'].includes(arg) || !argv[index + 1] || argv[index + 1].startsWith('--')) {
17
+ process.stdout.write(`${JSON.stringify({ status: 'error', reason: 'invalid-arguments', archetypes: [], selected: null })}\n`);
18
+ return 2;
19
+ }
20
+ options[arg === '--repo' ? 'repoRoot' : 'archetype'] = argv[++index];
21
+ }
22
+ if (options.repoRoot) options.repoRoot = path.resolve(options.repoRoot);
23
+ const result = await loadBaselineArchetypes(options);
24
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
25
+ return result.status === 'error' ? 2 : 0;
26
+ }
27
+
28
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) process.exitCode = await main();
@@ -258,8 +258,10 @@ if (isMain) {
258
258
  const inFlight = inFlightFilesFromLogFile(logPath);
259
259
  if (inFlight.length > 0) {
260
260
  console.error(
261
- `[ci] hint: ${inFlight.length} test file(s) in-flight when killed ` +
262
- `(likely hang/slowdown): ${inFlight.join(', ')}`,
261
+ `[ci] KILL ARTEFACT the shard timed out or was killed before vitest wrote its ` +
262
+ `result file (NOT a test failure). ${inFlight.length} file(s) were merely in-flight ` +
263
+ `at the kill: ${inFlight.join(', ')}. Re-run the shard; if it repeats, check runner ` +
264
+ `load/sharding before suspecting these files.`,
263
265
  );
264
266
  }
265
267
  }
@@ -182,13 +182,21 @@ function countContentLines(body) {
182
182
  * dry-run mode, where there is no `mergePeerCard()` stats object to read).
183
183
  * Pure function — no I/O.
184
184
  *
185
- * Counts `<!-- BEGIN MANAGED: <name> -->` sentinels (the same grammar
186
- * `scripts/lib/peer-cards/merger.mjs` parses). A proposed body that carries
187
- * none the LLM emits a bare full-body replacement per the current prompt
188
- * contract in {@link buildPrompt}, not per-section sentinels still counts
189
- * as ONE delta: a non-empty full-body replacement is a real change to the
190
- * target, and an empty proposed body counts as zero (nothing to report for a
191
- * target the response never touched).
185
+ * Resolution order:
186
+ * 1. non-string or empty body 0 (the response never touched the target);
187
+ * 2. `<!-- BEGIN MANAGED: <name> -->` sentinels present (the grammar
188
+ * `scripts/lib/peer-cards/merger.mjs` parses) their count;
189
+ * 3. otherwise the count of level-2 `## ` headings the deriver's actual
190
+ * output contract per {@link buildPrompt} is a bare full-body
191
+ * replacement structured by `## ` headings, not sentinels (#1319: the
192
+ * old sentinel-or-1 rule reported 1 for a 12-section body). Headings
193
+ * inside ``` / ~~~ code fences are NOT counted;
194
+ * 4. a non-empty body with neither → 1 (a full-body replacement is still a
195
+ * real change to the target).
196
+ *
197
+ * Dry-run only. Apply mode counts `mergePeerCard()` stats instead
198
+ * (`replaced + appended`, i.e. merged sentinel sections) — the two modes
199
+ * measure different quantities on the same event field.
192
200
  *
193
201
  * @param {string | undefined} diffText — `diff.user` or `diff.agent` from
194
202
  * {@link parseResponse}; `undefined` when the target was not emitted.
@@ -197,7 +205,23 @@ function countContentLines(body) {
197
205
  export function countManagedSections(diffText) {
198
206
  if (typeof diffText !== 'string' || diffText.length === 0) return 0;
199
207
  const matches = diffText.match(/<!--\s*BEGIN\s+MANAGED:\s*[\w-]+\s*-->/g);
200
- return matches && matches.length > 0 ? matches.length : 1;
208
+ if (matches && matches.length > 0) return matches.length;
209
+ let headings = 0;
210
+ let fence = null; // { char, len } while inside a code fence
211
+ for (const line of diffText.split(/\r?\n/)) {
212
+ const f = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
213
+ if (fence) {
214
+ // CommonMark close: same char, at least as long, no info string.
215
+ if (f && f[1][0] === fence.char && f[1].length >= fence.len && f[2].trim() === '') fence = null;
216
+ continue;
217
+ }
218
+ if (f) {
219
+ fence = { char: f[1][0], len: f[1].length };
220
+ continue;
221
+ }
222
+ if (/^## /.test(line)) headings += 1;
223
+ }
224
+ return headings > 0 ? headings : 1;
201
225
  }
202
226
 
203
227
  /**