session-orchestrator 4.1.0 → 4.2.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 (137) hide show
  1. package/.agents/skills/session-plan/SKILL.md +1 -1
  2. package/.claude-plugin/marketplace.json +1 -1
  3. package/.claude-plugin/plugin.json +1 -1
  4. package/.codex-plugin/plugin.json +1 -1
  5. package/.codex-plugin/skills/session-plan/SKILL.md +1 -1
  6. package/.cursor/skills/session-plan/SKILL.md +1 -1
  7. package/.cursor-plugin/plugin.json +1 -1
  8. package/CHANGELOG.md +34 -0
  9. package/README.md +8 -8
  10. package/agents/ux-evaluator.md +1 -1
  11. package/commands/close.md +3 -3
  12. package/commands/go.md +2 -0
  13. package/commands/memory-cleanup.md +4 -3
  14. package/commands/persona-panel.md +1 -1
  15. package/commands/session.md +3 -2
  16. package/docs/README.md +4 -4
  17. package/docs/USER-GUIDE.md +115 -48
  18. package/docs/agent-authoring.md +2 -2
  19. package/docs/baseline.md +55 -1
  20. package/docs/ci-setup.md +1 -1
  21. package/docs/codex-setup.md +1 -0
  22. package/docs/components.md +2 -2
  23. package/docs/cursor-setup.md +1 -0
  24. package/docs/events-schema.md +4 -1
  25. package/docs/instruction-delivery.md +1 -1
  26. package/docs/memory-proposal-flow.md +3 -3
  27. package/docs/migration-v4.md +2 -2
  28. package/docs/owner-config-schema.md +74 -90
  29. package/docs/persona-panel.md +4 -4
  30. package/docs/pi-setup.md +1 -0
  31. package/docs/rule-authoring.md +13 -6
  32. package/docs/scope-collision-guard.md +2 -0
  33. package/docs/session-config-reference.md +55 -22
  34. package/docs/session-config-template.md +9 -5
  35. package/docs/vault-docs-architecture.md +4 -2
  36. package/hooks/_lib/hook-import-set.json +28 -3
  37. package/hooks/_lib/vcs-create-matcher.mjs +214 -16
  38. package/hooks/hooks-codex.json +1 -1
  39. package/hooks/hooks.json +1 -1
  40. package/hooks/pre-bash-issue-budget.mjs +123 -26
  41. package/hooks/subagent-telemetry.mjs +106 -20
  42. package/package.json +4 -4
  43. package/scripts/baseline-archetypes.mjs +28 -0
  44. package/scripts/lib/auto-dialectic.mjs +0 -68
  45. package/scripts/lib/baseline-archetypes.mjs +439 -0
  46. package/scripts/lib/build-live-signals.mjs +5 -6
  47. package/scripts/lib/config/issue-budget.mjs +68 -8
  48. package/scripts/lib/config/private-config-dir.mjs +3 -2
  49. package/scripts/lib/config/remote-hosts.mjs +2 -2
  50. package/scripts/lib/config-schema.mjs +79 -0
  51. package/scripts/lib/file-lock.mjs +47 -5
  52. package/scripts/lib/issue-budget-reconcile.mjs +392 -0
  53. package/scripts/lib/issue-budget.mjs +76 -3
  54. package/scripts/lib/learnings/evolve-telemetry.mjs +1 -2
  55. package/scripts/lib/maintenance-due-banner.mjs +440 -0
  56. package/scripts/lib/owner-config.example.yaml +29 -46
  57. package/scripts/lib/owner-yaml.mjs +14 -13
  58. package/scripts/lib/quality-gate.mjs +13 -6
  59. package/scripts/lib/resource-probe/evaluate.mjs +19 -21
  60. package/scripts/lib/rules-sync.mjs +34 -4
  61. package/scripts/lib/session-close-backfill.mjs +182 -40
  62. package/scripts/lib/session-end/phase-skip.mjs +85 -86
  63. package/scripts/lib/session-end/tail-runner.mjs +178 -0
  64. package/scripts/lib/session-schema/constants.mjs +6 -0
  65. package/scripts/lib/session-schema/validator.mjs +20 -0
  66. package/scripts/lib/session-shape.mjs +558 -0
  67. package/scripts/lib/session-start-probes.mjs +10 -3
  68. package/scripts/lib/session-token-rollup.mjs +95 -10
  69. package/scripts/lib/state-md/frontmatter-mutators.mjs +22 -34
  70. package/scripts/lib/state-md.mjs +1 -0
  71. package/scripts/lib/subagents-schema.mjs +77 -9
  72. package/scripts/lib/telemetry/pricing.mjs +197 -0
  73. package/scripts/lib/telemetry/sync.mjs +50 -1
  74. package/scripts/lib/validate/check-skill-script-paths.mjs +33 -10
  75. package/scripts/lib/validate/check-unwired-features.mjs +8 -7
  76. package/scripts/lib/vault-mirror/process.mjs +2 -1
  77. package/scripts/lib/vault-status/narrative-mirror.mjs +4 -4
  78. package/scripts/lib/wave-resource-gate.mjs +23 -27
  79. package/scripts/lib/wave-sizing.mjs +10 -3
  80. package/scripts/materialize-wave-scope.mjs +68 -14
  81. package/scripts/print-applicable-rules.mjs +7 -6
  82. package/scripts/print-learnings-index.mjs +3 -2
  83. package/scripts/session-shape.mjs +266 -0
  84. package/skills/_shared/config-reading.md +15 -9
  85. package/skills/_shared/private-capability-context.md +89 -0
  86. package/skills/bootstrap/SKILL.md +61 -13
  87. package/skills/bootstrap/_shared-template.md +99 -14
  88. package/skills/bootstrap/deep-template.md +36 -26
  89. package/skills/bootstrap/fast-template.md +44 -8
  90. package/skills/bootstrap/intensity-heuristic.md +10 -4
  91. package/skills/bootstrap/private-contract.md +119 -0
  92. package/skills/bootstrap/public-fallback.md +30 -18
  93. package/skills/bootstrap/standard-template.md +39 -24
  94. package/skills/discovery/probes-ui.md +1 -1
  95. package/skills/docs-orchestrator/audience-mapping.md +1 -1
  96. package/skills/evolve/SKILL.md +2 -2
  97. package/skills/gitlab-ops/SKILL.md +3 -3
  98. package/skills/grill/SKILL.md +1 -1
  99. package/skills/memory-cleanup/SKILL.md +2 -2
  100. package/skills/plan/mode-new.md +9 -0
  101. package/skills/reconcile/SKILL.md +1 -1
  102. package/skills/session-end/SKILL.md +3 -2
  103. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  104. package/skills/session-end/phase-3-6-tail.md +23 -65
  105. package/skills/session-end/phase-3-7a-recommendations.md +2 -2
  106. package/skills/session-end/references/phase-3-documentation-updates.md +8 -6
  107. package/skills/session-end/references/phase-5-issue-cleanup.md +26 -0
  108. package/skills/session-end/session-metrics-write.md +31 -12
  109. package/skills/session-plan/SKILL.md +56 -48
  110. package/skills/session-plan/wave-template.md +8 -15
  111. package/skills/session-start/SKILL.md +18 -2
  112. package/skills/session-start/phase-2-5-docs-planning.md +1 -1
  113. package/skills/session-start/phase-8-5-express-path.md +12 -9
  114. package/skills/session-start/references/phase-1-5-session-continuity.md +2 -0
  115. package/skills/session-start/references/phase-4-ssot-environment-check.md +21 -5
  116. package/skills/session-start/references/phase-6-7-memory-banner-telemetry-consent.md +3 -1
  117. package/skills/test-runner/rubric-v1.md +2 -2
  118. package/skills/wave-executor/SKILL.md +42 -12
  119. package/skills/wave-executor/circuit-breaker.md +3 -1
  120. package/skills/wave-executor/references/wave-loop-dispatch.md +4 -2
  121. package/skills/wave-executor/references/wave-loop-review.md +1 -1
  122. package/skills/wave-executor/references/wave-loop-scope-manifest.md +6 -2
  123. package/templates/nextjs-minimal/package.json +1 -1
  124. package/templates/node-minimal/package.json +1 -1
  125. package/scripts/lib/multi-provider-build/providers.mjs +0 -64
  126. package/scripts/lib/multi-provider-build/templating.mjs +0 -130
  127. package/scripts/lib/owner-config/coerce.mjs +0 -29
  128. package/scripts/lib/owner-config/constants.mjs +0 -21
  129. package/scripts/lib/owner-config/defaults.mjs +0 -50
  130. package/scripts/lib/owner-config/error.mjs +0 -19
  131. package/scripts/lib/owner-config/index.mjs +0 -13
  132. package/scripts/lib/owner-config/merge.mjs +0 -52
  133. package/scripts/lib/owner-config/validate.mjs +0 -259
  134. package/scripts/lib/owner-config-loader.mjs +0 -170
  135. package/scripts/lib/owner-config.mjs +0 -28
  136. package/scripts/lib/soul-resolve.mjs +0 -130
  137. package/scripts/lib/vault-mirror/render.mjs +0 -8
@@ -86,6 +86,8 @@ export function validateSessionConfig(config) {
86
86
  });
87
87
  }
88
88
 
89
+ validateIssueBudget(config['issue-budget'], errors);
90
+
89
91
  validateVaultIntegration(config['vault-integration'], errors);
90
92
  validateVaultSync(config['vault-sync'], errors);
91
93
 
@@ -124,6 +126,83 @@ export function validateSessionConfig(config) {
124
126
  return { ok: true, config, warnings };
125
127
  }
126
128
 
129
+ /**
130
+ * Validate the `issue-budget` block's cap, including the per-session-type
131
+ * override form (#session-shape) — `12 (feature: 6)` parses to
132
+ * `{ default: 12, feature: 6 }` and lands on `max-per-session-raw`, while
133
+ * `max-per-session` stays the resolved NUMBER every consumer reads.
134
+ *
135
+ * Mirrors `validateAgentsPerWave` in shape, with two deliberate differences:
136
+ * the floor is 0 (`max-per-session: 0` is a valid "no issue may be created"
137
+ * setting) and the override key set is free-form, because the session-type
138
+ * vocabulary is owned by session-start, not by this schema.
139
+ *
140
+ * @param {unknown} block — `config['issue-budget']`
141
+ * @param {Array<{path: string, rule: string, message: string}>} errors
142
+ */
143
+ function validateIssueBudget(block, errors) {
144
+ if (block === undefined || block === null) return;
145
+ if (typeof block !== 'object' || Array.isArray(block)) {
146
+ errors.push({
147
+ path: 'issue-budget',
148
+ rule: 'object',
149
+ message: `issue-budget must be an object (got ${JSON.stringify(block)})`,
150
+ });
151
+ return;
152
+ }
153
+
154
+ const resolved = block['max-per-session'];
155
+ if (resolved !== undefined && (!Number.isInteger(resolved) || resolved < 0)) {
156
+ errors.push({
157
+ path: 'issue-budget.max-per-session',
158
+ rule: 'integer>=0',
159
+ message: `issue-budget.max-per-session must be an integer >= 0 (got ${JSON.stringify(resolved)})`,
160
+ });
161
+ }
162
+
163
+ const raw = block['max-per-session-raw'];
164
+ if (raw === undefined || raw === null) return;
165
+
166
+ if (Number.isInteger(raw)) {
167
+ if (raw < 0) {
168
+ errors.push({
169
+ path: 'issue-budget.max-per-session-raw',
170
+ rule: 'integer>=0',
171
+ message: `issue-budget.max-per-session-raw must be an integer >= 0 (got ${raw})`,
172
+ });
173
+ }
174
+ return;
175
+ }
176
+
177
+ if (typeof raw === 'object' && !Array.isArray(raw)) {
178
+ const defaultVal = raw['default'];
179
+ if (!Number.isInteger(defaultVal) || defaultVal < 0) {
180
+ errors.push({
181
+ path: 'issue-budget.max-per-session-raw.default',
182
+ rule: 'integer>=0',
183
+ message: `issue-budget.max-per-session-raw.default must be an integer >= 0 (got ${JSON.stringify(defaultVal)})`,
184
+ });
185
+ }
186
+ for (const [k, v] of Object.entries(raw)) {
187
+ if (k === 'default') continue;
188
+ if (!Number.isInteger(v) || v < 0) {
189
+ errors.push({
190
+ path: `issue-budget.max-per-session-raw.${k}`,
191
+ rule: 'integer>=0',
192
+ message: `issue-budget.max-per-session-raw.${k} must be an integer >= 0 (got ${JSON.stringify(v)})`,
193
+ });
194
+ }
195
+ }
196
+ return;
197
+ }
198
+
199
+ errors.push({
200
+ path: 'issue-budget.max-per-session-raw',
201
+ rule: 'integer-or-object',
202
+ message: `issue-budget.max-per-session-raw must be an integer >= 0 or an object with numeric entries (got ${JSON.stringify(raw)})`,
203
+ });
204
+ }
205
+
127
206
  function validateAgentsPerWave(value, errors) {
128
207
  if (Number.isInteger(value)) {
129
208
  if (value < 2) {
@@ -6,11 +6,12 @@
6
6
  * and the state-lock / staging-fence / session-lock blocks of session-lock.mjs).
7
7
  * Each copy independently re-implemented the same skeleton:
8
8
  *
9
- * 1. atomic create-or-fail via `linkSync(tmp, lock)` (POSIX mutex);
10
- * 2. on EEXIST read + parse the existing body;
11
- * 3. same-host + dead-PID (or unparseable) atomic override + WARN;
12
- * 4. live holder OR cross-hostpoll until a deadline;
13
- * 5. owner-guarded release.
9
+ * 1. exclusive acquisition guard via `linkSync(tmp, lock + '.acquire')`;
10
+ * 2. atomic create-or-fail via `linkSync(tmp, lock)` (POSIX mutex);
11
+ * 3. on EEXIST read + parse the existing body;
12
+ * 4. same-host + dead-PID (or unparseable) guarded override + WARN;
13
+ * 5. release the acquisition guard; live/cross-host holders wait until a deadline;
14
+ * 6. owner-guarded primary-lock release.
14
15
  *
15
16
  * This module is the single home for that skeleton. It is a near-PURE primitive:
16
17
  * it imports ONLY scripts/lib/io.mjs (for writeJsonAtomicSync), the two host
@@ -27,6 +28,8 @@
27
28
  * opinion of its own — every divergence between the five copies is a knob.
28
29
  * - Cross-host locks are NEVER auto-overridden (PSA-003 hard invariant).
29
30
  * - Overrides always go through writeJsonAtomicSync (tmp + renameSync).
31
+ * - #1284 serializes acquisition and takeover: a stale observation must not
32
+ * replace a different process's newly acquired lock.
30
33
  *
31
34
  * No external dependencies — Node 20+ stdlib + io.mjs only.
32
35
  */
@@ -258,6 +261,21 @@ function serializeBody(body, indent) {
258
261
  * overridden via writeJsonAtomicSync and a WARN is emitted. A live holder or a
259
262
  * cross-host body returns `{ acquired: false, reason: 'held' }`.
260
263
  *
264
+ * Every acquisition pass owns the exclusive sibling `${lockPath}.acquire`
265
+ * from before create/read through any takeover. This prevents a waiter from
266
+ * reading an old holder, observing its exit, then replacing a newer holder.
267
+ * All contenders must use this guarded implementation; legacy writers that
268
+ * ignore the sibling guard cannot participate safely in the same protocol.
269
+ *
270
+ * Crash-liveness tradeoff: the guard is held only for this synchronous pass,
271
+ * not for the caller's critical section. If its owner dies during the pass or
272
+ * cleanup fails, the guard remains and attempts return `held` immediately;
273
+ * withFileLock's normal deadline bounds polling. Even a dead-PID or malformed
274
+ * guard is NEVER stolen, because stale-guard replacement would repeat the same
275
+ * race. Recovery requires quiescing every process that can acquire this lock,
276
+ * verifying the guard is abandoned, then explicitly removing only that sibling.
277
+ * The primary lock retains its existing stale/host/owner protections.
278
+ *
261
279
  * The `signalVanished` knob reproduces memory-proposals/store.mjs's distinct
262
280
  * third state: when the lock file disappears between the EEXIST and the read
263
281
  * (concurrent release race), `{ acquired: false, reason: 'vanished' }` is
@@ -284,6 +302,30 @@ function serializeBody(body, indent) {
284
302
  * | { acquired: false, reason: 'held'|'vanished'|'fs-error', existing?: object|null, error?: string }}
285
303
  */
286
304
  export function tryAcquireFileLock(lockPath, opts = {}) {
305
+ const guardPath = `${lockPath}.acquire`;
306
+ const guard = createExclusive(guardPath, {
307
+ pid: process.pid,
308
+ host: os.hostname(),
309
+ acquiredAt: new Date().toISOString(),
310
+ kind: 'acquisition-guard',
311
+ }, { indent: 2, tmpPrefix: `${opts.tmpPrefix ?? '.file.lock'}.acquire` });
312
+ if (!guard.ok) {
313
+ return guard.reason === 'exists'
314
+ ? { acquired: false, reason: 'held', existing: null }
315
+ : { acquired: false, reason: 'fs-error', error: guard.error };
316
+ }
317
+
318
+ try {
319
+ return tryAcquireGuardedFileLock(lockPath, opts);
320
+ } finally {
321
+ // Only this pass can own/remove this guard; no acquisition path replaces
322
+ // it. An unlink failure deliberately leaves subsequent attempts blocked.
323
+ try { fs.unlinkSync(guardPath); } catch { /* fail closed; see recovery above */ }
324
+ }
325
+ }
326
+
327
+ /** Caller must hold the acquisition guard throughout this synchronous pass. */
328
+ function tryAcquireGuardedFileLock(lockPath, opts) {
287
329
  const {
288
330
  staleCheck = 'pid',
289
331
  staleMs,
@@ -0,0 +1,392 @@
1
+ /**
2
+ * issue-budget-reconcile.mjs — close-time cross-check between what a session
3
+ * RECORDED as created and what the issue-budget ledger CHARGED (#1163).
4
+ *
5
+ * ## Why a cross-check exists at all
6
+ *
7
+ * The cap is enforced by a PreToolUse hook, and a hook only sees the routes it
8
+ * matches. Every unmatched route is a silent zero: nothing errors, no ledger
9
+ * line is written, and the absence is indistinguishable from a session that
10
+ * created nothing. Measured 2026-09-09 on a session record with **26** issues
11
+ * in `issues_created` and NO counter file under either candidate key — the
12
+ * hook had not run for a single one of those creations, and nothing said so.
13
+ *
14
+ * So this module compares two independently-produced numbers and names the
15
+ * disagreement:
16
+ *
17
+ * recorded — `record.issues_created.length` from the session record
18
+ * charged — `count` summed over the ledgers
19
+ * exempt — `exempt` summed over the ledgers
20
+ * escaped — `max(0, recorded - charged - exempt)`
21
+ *
22
+ * ## Why BOTH ledger keys are read
23
+ *
24
+ * The accounting key is SEMANTIC when `current-session.json` verified the raw
25
+ * id, and RAW otherwise (`resolveIssueBudgetSessionId`). Which of the two a
26
+ * given session's file is named after therefore depends on a condition that can
27
+ * change mid-session — measured in one consumer repo: 25 of 36 counter files
28
+ * keyed semantic, 11 keyed raw. Reading only one key reports a phantom
29
+ * "escaped" for every session that used the other.
30
+ *
31
+ * ## Why `found` is tracked explicitly
32
+ *
33
+ * `readBudgetState` returns a ZEROED state for a missing file — by design, so
34
+ * the cap never inherits a foreign session's spend. That makes "no ledger" and
35
+ * "a real zero" byte-identical in the return value, and reading the zero as
36
+ * "all good" is exactly the #1163 failure this module exists to surface. So the
37
+ * file's existence is measured separately, per key, before the read.
38
+ *
39
+ * FAIL-OPEN BY CONTRACT: nothing here throws. A close-time cross-check that
40
+ * aborts the close is strictly worse than one that reports `no-ledger`.
41
+ */
42
+
43
+ import { existsSync, readFileSync } from 'node:fs';
44
+
45
+ import {
46
+ readBudgetState,
47
+ budgetStatePath,
48
+ budgetStateRel,
49
+ loadIssueBudgetConfig,
50
+ } from './issue-budget.mjs';
51
+
52
+ /** Event name for the reconciliation record. Plain literal, greppable. */
53
+ export const ISSUE_BUDGET_RECONCILED_EVENT = 'orchestrator.issue_budget.reconciled';
54
+
55
+ /**
56
+ * Read one candidate ledger, recording whether its file was actually there.
57
+ *
58
+ * ## Why the path is carried TWICE
59
+ *
60
+ * `abs_path` is the operator-facing answer to "which file did you look at?" and
61
+ * belongs in the local WARN text. `path` is repo-RELATIVE and is the only half
62
+ * that may travel: this record is copied verbatim into the
63
+ * `orchestrator.issue_budget.reconciled` event, which the optional Clank webhook
64
+ * ships off-host. An absolute ledger path names the operator's home directory
65
+ * and the repo's private slug, neither of which the receiving side needs to
66
+ * interpret the verdict. The split is in the FIELD NAMES so a future consumer
67
+ * cannot pick the leaking one by accident.
68
+ *
69
+ * ## Why `corrupt` is separate from `found`
70
+ *
71
+ * `readBudgetState` normalises a present-but-unreadable file to a ZEROED state
72
+ * (fail-open by contract). So `{"count":"3"}` — an existing ledger whose shape
73
+ * is wrong — reads as `found: true, charged: 0`, which the verdict logic would
74
+ * otherwise call `escaped` and answer with the escape-route list. That is the
75
+ * wrong diagnosis for a merely corrupt file, so the raw shape is inspected here
76
+ * and reported on its own flag.
77
+ *
78
+ * @param {string} repoRoot
79
+ * @param {string|null|undefined} sessionId
80
+ * @param {'semantic'|'raw'} key
81
+ * @returns {{ key: string, sessionId: string|null, path: string|null, abs_path: string|null,
82
+ * found: boolean, corrupt: boolean, charged: number, exempt: number,
83
+ * overflow: number }}
84
+ */
85
+ function _readLedger(repoRoot, sessionId, key) {
86
+ const id = typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : null;
87
+ if (id === null) {
88
+ return {
89
+ key,
90
+ sessionId: null,
91
+ path: null,
92
+ abs_path: null,
93
+ found: false,
94
+ corrupt: false,
95
+ charged: 0,
96
+ exempt: 0,
97
+ overflow: 0,
98
+ };
99
+ }
100
+ let file = null;
101
+ let rel = null;
102
+ let found;
103
+ let corrupt = false;
104
+ let state = { count: 0, exempt: 0, overflow: [] };
105
+ try {
106
+ file = budgetStatePath(repoRoot, id);
107
+ rel = budgetStateRel(id);
108
+ found = existsSync(file);
109
+ state = readBudgetState(repoRoot, id);
110
+ if (found) corrupt = !_ledgerShapeIsReadable(file);
111
+ } catch {
112
+ // Unreadable ledger — reported as not found rather than thrown (fail-open).
113
+ found = false;
114
+ }
115
+ return {
116
+ key,
117
+ sessionId: id,
118
+ path: rel,
119
+ abs_path: file,
120
+ found,
121
+ corrupt,
122
+ charged: Number.isInteger(state?.count) ? state.count : 0,
123
+ exempt: Number.isInteger(state?.exempt) ? state.exempt : 0,
124
+ overflow: Array.isArray(state?.overflow) ? state.overflow.length : 0,
125
+ };
126
+ }
127
+
128
+ /**
129
+ * Is the ledger file on disk a readable counter shape?
130
+ *
131
+ * Read directly rather than through `readBudgetState`, whose normalisation is
132
+ * exactly what hides this. Fail-open: an unreadable file is reported as corrupt,
133
+ * never thrown.
134
+ *
135
+ * @param {string} file
136
+ * @returns {boolean}
137
+ */
138
+ function _ledgerShapeIsReadable(file) {
139
+ try {
140
+ const raw = JSON.parse(readFileSync(file, 'utf8'));
141
+ return Number.isInteger(raw?.count);
142
+ } catch {
143
+ return false;
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Cross-check a closing session's recorded issue creations against the
149
+ * issue-budget ledger(s).
150
+ *
151
+ * The two ledgers are SUMMED rather than preferred one over the other: a
152
+ * session whose key flipped mid-session legitimately has spend under both, and
153
+ * preferring one would under-report exactly that case. When both keys resolve
154
+ * to the same string, only one is read (no double count).
155
+ *
156
+ * Verdicts:
157
+ * `no-ledger` — `recorded > 0` and NO file existed under either key. The
158
+ * hook never ran for a single create; the count is not
159
+ * merely low, it is absent.
160
+ * `corrupt-ledger` — a ledger file EXISTS but its shape is unreadable (bad
161
+ * JSON, or a `count` that is not an integer). Its `charged`
162
+ * is therefore unknown, not zero — so no escape claim may be
163
+ * made from it, and the fix is the file, not the matcher.
164
+ * `escaped` — a ledger exists but `recorded > charged + exempt`. Some
165
+ * creations took a route the matcher does not see.
166
+ * `stale-record` — a ledger exists with spend, and the record claims none.
167
+ * The record, not the ledger, is the suspect half.
168
+ * `match` — everything the record claims is accounted for.
169
+ *
170
+ * @param {{ repoRoot: string,
171
+ * record?: { issues_created?: unknown } | null,
172
+ * sessionId?: string|null,
173
+ * rawSessionId?: string|null,
174
+ * config?: { "max-per-session": number, mode: string, overflow: string },
175
+ * now?: string }} opts
176
+ * @returns {{ recorded: number, charged: number, exempt: number, overflow: number,
177
+ * escaped: number, sources: object[], verdict: string,
178
+ * max: number|null, mode: string|null }}
179
+ */
180
+ export function reconcileIssueBudget({
181
+ repoRoot,
182
+ record = null,
183
+ sessionId = null,
184
+ rawSessionId = null,
185
+ config = null,
186
+ now = new Date().toISOString(),
187
+ } = {}) {
188
+ const empty = {
189
+ recorded: 0,
190
+ charged: 0,
191
+ exempt: 0,
192
+ overflow: 0,
193
+ escaped: 0,
194
+ sources: [],
195
+ verdict: 'match',
196
+ max: null,
197
+ mode: null,
198
+ at: now,
199
+ };
200
+ if (typeof repoRoot !== 'string' || repoRoot.trim() === '') return empty;
201
+
202
+ try {
203
+ const created = record?.issues_created;
204
+ const recorded = Array.isArray(created) ? created.length : 0;
205
+
206
+ const sources = [_readLedger(repoRoot, sessionId, 'semantic')];
207
+ if (
208
+ typeof rawSessionId === 'string' &&
209
+ rawSessionId.length > 0 &&
210
+ rawSessionId !== sessionId
211
+ ) {
212
+ sources.push(_readLedger(repoRoot, rawSessionId, 'raw'));
213
+ }
214
+
215
+ const charged = sources.reduce((n, s) => n + s.charged, 0);
216
+ const exempt = sources.reduce((n, s) => n + s.exempt, 0);
217
+ const overflow = sources.reduce((n, s) => n + s.overflow, 0);
218
+ const anyFound = sources.some((s) => s.found);
219
+ const escaped = Math.max(0, recorded - charged - exempt);
220
+
221
+ const anyCorrupt = sources.some((s) => s.corrupt);
222
+
223
+ let verdict = 'match';
224
+ if (recorded > 0 && !anyFound) verdict = 'no-ledger';
225
+ // Precedence over `escaped`: a corrupt ledger's `charged` is UNKNOWN, so the
226
+ // escape arithmetic that would otherwise fire is built on a zero nobody
227
+ // measured. Naming the corruption first sends the operator at the file.
228
+ else if (anyCorrupt) verdict = 'corrupt-ledger';
229
+ else if (escaped > 0) verdict = 'escaped';
230
+ else if (anyFound && recorded === 0 && charged > 0) verdict = 'stale-record';
231
+
232
+ let cfg = config;
233
+ if (!cfg) {
234
+ try {
235
+ cfg = loadIssueBudgetConfig(repoRoot);
236
+ } catch {
237
+ cfg = null;
238
+ }
239
+ }
240
+
241
+ return {
242
+ recorded,
243
+ charged,
244
+ exempt,
245
+ overflow,
246
+ escaped,
247
+ sources,
248
+ verdict,
249
+ max: typeof cfg?.['max-per-session'] === 'number' ? cfg['max-per-session'] : null,
250
+ mode: typeof cfg?.mode === 'string' ? cfg.mode : null,
251
+ at: now,
252
+ };
253
+ } catch {
254
+ // Never throws — a close-time cross-check that aborts the close is worse
255
+ // than one that reports nothing.
256
+ return empty;
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Emit the reconciliation record to the repo's event ledger.
262
+ *
263
+ * Follows `_emitEvaluated` in `scripts/lib/express-path.mjs`: `events.mjs` is
264
+ * imported LAZILY (a static import would pull `platform.mjs`, which walks the
265
+ * filesystem at module load, into every consumer of this module), `repoRoot` is
266
+ * passed EXPLICITLY and a missing one SKIPS the emit rather than falling
267
+ * through to the ambient `SO_PROJECT_DIR` — writing a synthetic record into the
268
+ * operator's real fleet ledger is unrecoverable, a skipped record is not — and
269
+ * the whole thing is best-effort: the verdict above is authoritative whether or
270
+ * not the ledger accepted the line.
271
+ *
272
+ * @param {string} repoRoot
273
+ * @param {ReturnType<typeof reconcileIssueBudget>} result
274
+ * @returns {Promise<void>}
275
+ */
276
+ export async function emitIssueBudgetReconciled(repoRoot, result) {
277
+ if (typeof repoRoot !== 'string' || repoRoot.trim() === '') {
278
+ process.stderr.write(
279
+ `issue-budget-reconcile: skipped ${ISSUE_BUDGET_RECONCILED_EVENT} — no repoRoot given; ` +
280
+ 'refusing the ambient SO_PROJECT_DIR destination (#941).\n',
281
+ );
282
+ return;
283
+ }
284
+ try {
285
+ const { emitEvent, sessionAttribution } = await import('./events.mjs');
286
+ await emitEvent(
287
+ ISSUE_BUDGET_RECONCILED_EVENT,
288
+ {
289
+ verdict: result.verdict,
290
+ recorded: result.recorded,
291
+ charged: result.charged,
292
+ exempt: result.exempt,
293
+ overflow: result.overflow,
294
+ escaped: result.escaped,
295
+ // REPO-RELATIVE `path` only (`s.path`, from `budgetStateRel`). The
296
+ // absolute path stays in `result.sources[].abs_path` for the local WARN
297
+ // text: this payload travels verbatim over the optional Clank webhook,
298
+ // where `/Users/<operator>/Projects/<private-slug>/…` is owner data the
299
+ // receiver has no use for.
300
+ ledgers: (result.sources ?? []).map((s) => ({
301
+ key: s.key,
302
+ path: s.path,
303
+ found: s.found,
304
+ corrupt: s.corrupt === true,
305
+ charged: s.charged,
306
+ exempt: s.exempt,
307
+ })),
308
+ ...sessionAttribution(repoRoot),
309
+ },
310
+ { repoRoot },
311
+ );
312
+ } catch {
313
+ // Best-effort telemetry.
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Human-readable line(s) for session-end's Final Report.
319
+ *
320
+ * `match` gets ONE info line — a cross-check that prints a paragraph when it
321
+ * found nothing trains the operator to skip it. Every other verdict has to say
322
+ * what is missing AND where it looked, because the first question on reading
323
+ * "no ledger" is always "which path did you check?".
324
+ *
325
+ * @param {ReturnType<typeof reconcileIssueBudget>} result
326
+ * @returns {string}
327
+ */
328
+ export function formatIssueBudgetReconcileWarn(result) {
329
+ const r = result ?? {};
330
+ // ABSOLUTE paths here on purpose: this text is local (session-end's Final
331
+ // Report), and the first question on reading it is always "which file?" —
332
+ // which a repo-relative path answers only after the reader guesses the root.
333
+ // The travelling copy (the event payload) carries the relative form instead.
334
+ const paths = (r.sources ?? [])
335
+ .map(
336
+ (s) =>
337
+ ` ${s.key}: ${s.abs_path ?? '(no session key resolved)'}` +
338
+ `${s.found ? '' : ' — MISSING'}${s.corrupt ? ' — CORRUPT' : ''}`,
339
+ )
340
+ .join('\n');
341
+
342
+ if (r.verdict === 'match') {
343
+ return (
344
+ `ℹ issue-budget: ${r.recorded} recorded / ${r.charged} charged / ${r.exempt} exempt — reconciled.`
345
+ );
346
+ }
347
+
348
+ if (r.verdict === 'no-ledger') {
349
+ return [
350
+ `⚠ issue-budget: ${r.recorded} issue(s) recorded for this session and NO counter file exists.`,
351
+ 'A missing ledger does not mean the cap was under-used — it means the hook never ran for a',
352
+ 'single one of those creations, so the cap was silently OFF for this session.',
353
+ 'Looked up (both accounting keys — semantic and raw):',
354
+ paths,
355
+ 'Known routes that reach issue creation WITHOUT the hook charging it:',
356
+ ' - `glab api` / `gh api` POST to an `/issues` path — MATCHED since #1163, so a fresh',
357
+ ' no-ledger verdict is no longer explained by this one.',
358
+ ' - `bash -c "…"` and other interpreter payloads (the matcher does not recurse into them).',
359
+ ' - a create inside a command substitution `$( … )` (never becomes its own statement).',
360
+ ' - `xargs`-driven creation (the operand list is expanded after the hook runs).',
361
+ ' - foreign-channel agents (a `cursor:`/remote-dispatch role spawns a binary no hook sees).',
362
+ 'Verify with: ls -l the paths above, and re-check `issue-budget.mode` in the Session Config.',
363
+ ].join('\n');
364
+ }
365
+
366
+ if (r.verdict === 'corrupt-ledger') {
367
+ // ONE line by design: a corrupt file is a file problem, and printing the
368
+ // escape-route list here would send the operator hunting a matcher gap that
369
+ // this verdict has no evidence for.
370
+ return (
371
+ `⚠ issue-budget: a counter file exists but its shape is unreadable — the charged count is ` +
372
+ `UNKNOWN, not 0 (${r.recorded} recorded):\n${paths}`
373
+ );
374
+ }
375
+
376
+ if (r.verdict === 'escaped') {
377
+ return [
378
+ `⚠ issue-budget: ${r.escaped} issue(s) escaped the cap — ${r.recorded} recorded but only`,
379
+ `${r.charged} charged + ${r.exempt} exempt across the ledgers:`,
380
+ paths,
381
+ 'The counter is therefore an UNDERCOUNT for this session; the cap allowed more creations',
382
+ 'than it believes it did. Same escape routes as above (`bash -c`, `$( )`, `xargs`,',
383
+ 'foreign-channel agents).',
384
+ ].join('\n');
385
+ }
386
+
387
+ return [
388
+ `⚠ issue-budget: the ledger charged ${r.charged} (+${r.exempt} exempt) but the session record`,
389
+ 'lists no created issues — the RECORD is the suspect half here, not the counter:',
390
+ paths,
391
+ ].join('\n');
392
+ }
@@ -34,6 +34,8 @@ import path from 'node:path';
34
34
  import { writeJsonAtomicSync } from './io.mjs';
35
35
  import { resolveInstructionFile } from './common.mjs';
36
36
  import { _parseIssueBudget } from './config/issue-budget.mjs';
37
+ import { parseStateMd } from './state-md/yaml-parser.mjs';
38
+ import { resolveStateMdPath } from './state-md/frontmatter-mutators.mjs';
37
39
 
38
40
  /**
39
41
  * Legacy single-slot counter file, relative to the repo root.
@@ -148,24 +150,95 @@ export function classifyExemption(command) {
148
150
  return { exempt: false, reason: null };
149
151
  }
150
152
 
153
+ /**
154
+ * Resolve the effective cap for one session type out of a parsed config.
155
+ *
156
+ * Precedence is `raw[sessionType] ?? raw.default ?? cfg['max-per-session']` —
157
+ * an unknown or absent session type falls through to the default, never to a
158
+ * hard-coded number, and a config parsed before the override syntax existed
159
+ * (no `max-per-session-raw` key at all) still resolves via the last term.
160
+ *
161
+ * @param {{ "max-per-session"?: number, "max-per-session-raw"?: number|Record<string, number> }} cfg
162
+ * @param {string|null|undefined} sessionType — e.g. `feature`, `deep`, `housekeeping`
163
+ * @returns {number} non-negative integer cap
164
+ */
165
+ export function resolveMaxPerSession(cfg, sessionType) {
166
+ const fallback =
167
+ Number.isInteger(cfg?.['max-per-session']) && cfg['max-per-session'] >= 0
168
+ ? cfg['max-per-session']
169
+ : 12;
170
+ const raw = cfg?.['max-per-session-raw'];
171
+ if (Number.isInteger(raw) && raw >= 0) return raw;
172
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
173
+ const key = typeof sessionType === 'string' ? sessionType.trim().toLowerCase() : '';
174
+ const override = key === '' ? undefined : raw[key];
175
+ if (Number.isInteger(override) && override >= 0) return override;
176
+ if (Number.isInteger(raw.default) && raw.default >= 0) return raw.default;
177
+ }
178
+ return fallback;
179
+ }
180
+
181
+ /**
182
+ * Read `session-type:` from the active STATE.md frontmatter.
183
+ *
184
+ * Never throws: a missing STATE.md, unparseable frontmatter, or an absent /
185
+ * non-string `session-type` all yield `null`, which `resolveMaxPerSession`
186
+ * reads as "use the default". The hook path runs on every Bash call, so a
187
+ * broken STATE.md must degrade to today's behaviour rather than fail.
188
+ *
189
+ * @param {string} repoRoot
190
+ * @returns {string|null}
191
+ */
192
+ export function readSessionTypeFromStateMd(repoRoot) {
193
+ try {
194
+ const filePath = resolveStateMdPath(repoRoot);
195
+ if (!existsSync(filePath)) return null;
196
+ const parsed = parseStateMd(readFileSync(filePath, 'utf8'));
197
+ const value = parsed?.frontmatter?.['session-type'];
198
+ return typeof value === 'string' && value.trim() !== '' ? value.trim() : null;
199
+ } catch {
200
+ return null;
201
+ }
202
+ }
203
+
151
204
  /**
152
205
  * Load the `issue-budget` config from the repo's instruction file
153
206
  * (CLAUDE.md / AGENTS.md). Reads only that one file and only that one block —
154
207
  * `parseSessionConfig()` is deliberately NOT used here so the hook path does
155
208
  * not pay for host-path resolution and owner.yaml I/O on every Bash call.
156
209
  *
210
+ * `max-per-session` is returned ALREADY RESOLVED for the current session type
211
+ * (read from the active STATE.md frontmatter), so every existing consumer that
212
+ * treats it as a plain number keeps working and automatically gains the
213
+ * per-session-type override. The unresolved value stays available under
214
+ * `max-per-session-raw`, and `session-type-resolved` records which type the
215
+ * resolution used (`null` when STATE.md is absent or carries no session-type).
216
+ *
157
217
  * @param {string} repoRoot
158
- * @returns {{ "max-per-session": number, mode: string, overflow: string }}
218
+ * @returns {{ "max-per-session": number, "max-per-session-raw": number|Record<string, number>, "session-type-resolved": string|null, mode: string, overflow: string }}
159
219
  */
160
220
  export function loadIssueBudgetConfig(repoRoot) {
161
- const defaults = { 'max-per-session': 12, mode: 'strict', overflow: 'collect-issue' };
221
+ const defaults = {
222
+ 'max-per-session': 12,
223
+ 'max-per-session-raw': 12,
224
+ 'session-type-resolved': null,
225
+ mode: 'strict',
226
+ overflow: 'collect-issue',
227
+ };
228
+ let parsed;
162
229
  try {
163
230
  const resolved = resolveInstructionFile(repoRoot);
164
231
  if (!resolved) return defaults;
165
- return _parseIssueBudget(readFileSync(resolved.path, 'utf8'));
232
+ parsed = _parseIssueBudget(readFileSync(resolved.path, 'utf8'));
166
233
  } catch {
167
234
  return defaults;
168
235
  }
236
+ const sessionType = readSessionTypeFromStateMd(repoRoot);
237
+ return {
238
+ ...parsed,
239
+ 'max-per-session': resolveMaxPerSession(parsed, sessionType),
240
+ 'session-type-resolved': sessionType,
241
+ };
169
242
  }
170
243
 
171
244
  /**
@@ -20,8 +20,7 @@
20
20
  * pipeline).
21
21
  *
22
22
  * Both emitters are best-effort and try/catch-wrapped — same posture as
23
- * `emitReconcileCompleted` in `scripts/lib/reconcile/engine.mjs` and
24
- * `decideAndRecordAutoDialectic` in `scripts/lib/auto-dialectic.mjs` — because
23
+ * `emitReconcileCompleted` in `scripts/lib/reconcile/engine.mjs` — because
25
24
  * `emitEvent()` THROWS `EventValidationError` on a malformed record, and a
26
25
  * telemetry failure must never break the `/evolve` pipeline itself.
27
26
  *