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
@@ -248,21 +248,46 @@ export function writeStdoutLineSync(line) {
248
248
  // Exports
249
249
  // ---------------------------------------------------------------------------
250
250
 
251
+ /** Default stdin read timeout. Every production caller uses it. */
252
+ const READ_STDIN_TIMEOUT_MS = 5_000; // 5 s guard
253
+
251
254
  /**
252
255
  * Read process.stdin to EOF and parse as JSON.
256
+ *
257
+ * ## Why the timeout is a PARAMETER and not an env var
258
+ *
259
+ * The guard exists so a hook whose stdin never closes (the harness died, the
260
+ * pipe was inherited by a long-lived grandchild) fails instead of hanging the
261
+ * tool call forever. A 5-second stall is also what made the behaviour
262
+ * effectively untestable: the suite cannot afford to wait for it, which is why
263
+ * the timeout case sat as an empty `it.skip` for two years.
264
+ *
265
+ * Injecting the bound as an OPTION keeps the escape hatch out of the ambient
266
+ * environment: an env var would let any process that happens to export it
267
+ * shorten (or lengthen) the guard for every hook on the host, silently. A
268
+ * parameter can only be shortened by the caller that asks for it — today, only
269
+ * the test that proves the guard fires.
270
+ *
271
+ * @param {object} [opts]
272
+ * @param {number} [opts.timeoutMs=5000] Milliseconds before the read is
273
+ * abandoned. Non-finite or non-positive values fall back to the 5 s
274
+ * default rather than disabling the guard.
253
275
  * @returns {Promise<object|null>} Parsed JSON object, or null on empty stream.
254
276
  * @throws {SyntaxError} If stdin contains non-empty, non-JSON data.
255
- * @throws {Error} If the 1 MB size limit or 5 s timeout is exceeded.
277
+ * @throws {Error} If the 1 MB size limit or the timeout is exceeded.
256
278
  */
257
- export async function readStdin() {
279
+ export async function readStdin(opts = {}) {
258
280
  const MAX_BYTES = 1_048_576; // 1 MB guard
259
- const TIMEOUT_MS = 5_000; // 5 s guard
281
+ const requested = Number(opts?.timeoutMs);
282
+ const TIMEOUT_MS = Number.isFinite(requested) && requested > 0
283
+ ? requested
284
+ : READ_STDIN_TIMEOUT_MS;
260
285
 
261
286
  return new Promise((resolve, reject) => {
262
287
  const controller = new AbortController();
263
288
  const timer = setTimeout(() => {
264
289
  controller.abort();
265
- reject(new Error('io.mjs: readStdin timed out after 5 s'));
290
+ reject(new Error(`io.mjs: readStdin timed out after ${TIMEOUT_MS / 1000} s`));
266
291
  }, TIMEOUT_MS);
267
292
 
268
293
  const chunks = [];
@@ -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
+ }