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
@@ -29,11 +29,14 @@
29
29
 
30
30
  import { digestSha256Short } from './crypto-digest-utils.mjs';
31
31
  import { existsSync, readFileSync, readdirSync, statSync, unlinkSync } from 'node:fs';
32
+ import { homedir } from 'node:os';
32
33
  import path from 'node:path';
33
34
 
34
35
  import { writeJsonAtomicSync } from './io.mjs';
35
36
  import { resolveInstructionFile } from './common.mjs';
36
37
  import { _parseIssueBudget } from './config/issue-budget.mjs';
38
+ import { parseStateMd } from './state-md/yaml-parser.mjs';
39
+ import { resolveStateMdPath } from './state-md/frontmatter-mutators.mjs';
37
40
 
38
41
  /**
39
42
  * Legacy single-slot counter file, relative to the repo root.
@@ -148,24 +151,95 @@ export function classifyExemption(command) {
148
151
  return { exempt: false, reason: null };
149
152
  }
150
153
 
154
+ /**
155
+ * Resolve the effective cap for one session type out of a parsed config.
156
+ *
157
+ * Precedence is `raw[sessionType] ?? raw.default ?? cfg['max-per-session']` —
158
+ * an unknown or absent session type falls through to the default, never to a
159
+ * hard-coded number, and a config parsed before the override syntax existed
160
+ * (no `max-per-session-raw` key at all) still resolves via the last term.
161
+ *
162
+ * @param {{ "max-per-session"?: number, "max-per-session-raw"?: number|Record<string, number> }} cfg
163
+ * @param {string|null|undefined} sessionType — e.g. `feature`, `deep`, `housekeeping`
164
+ * @returns {number} non-negative integer cap
165
+ */
166
+ export function resolveMaxPerSession(cfg, sessionType) {
167
+ const fallback =
168
+ Number.isInteger(cfg?.['max-per-session']) && cfg['max-per-session'] >= 0
169
+ ? cfg['max-per-session']
170
+ : 12;
171
+ const raw = cfg?.['max-per-session-raw'];
172
+ if (Number.isInteger(raw) && raw >= 0) return raw;
173
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
174
+ const key = typeof sessionType === 'string' ? sessionType.trim().toLowerCase() : '';
175
+ const override = key === '' ? undefined : raw[key];
176
+ if (Number.isInteger(override) && override >= 0) return override;
177
+ if (Number.isInteger(raw.default) && raw.default >= 0) return raw.default;
178
+ }
179
+ return fallback;
180
+ }
181
+
182
+ /**
183
+ * Read `session-type:` from the active STATE.md frontmatter.
184
+ *
185
+ * Never throws: a missing STATE.md, unparseable frontmatter, or an absent /
186
+ * non-string `session-type` all yield `null`, which `resolveMaxPerSession`
187
+ * reads as "use the default". The hook path runs on every Bash call, so a
188
+ * broken STATE.md must degrade to today's behaviour rather than fail.
189
+ *
190
+ * @param {string} repoRoot
191
+ * @returns {string|null}
192
+ */
193
+ export function readSessionTypeFromStateMd(repoRoot) {
194
+ try {
195
+ const filePath = resolveStateMdPath(repoRoot);
196
+ if (!existsSync(filePath)) return null;
197
+ const parsed = parseStateMd(readFileSync(filePath, 'utf8'));
198
+ const value = parsed?.frontmatter?.['session-type'];
199
+ return typeof value === 'string' && value.trim() !== '' ? value.trim() : null;
200
+ } catch {
201
+ return null;
202
+ }
203
+ }
204
+
151
205
  /**
152
206
  * Load the `issue-budget` config from the repo's instruction file
153
207
  * (CLAUDE.md / AGENTS.md). Reads only that one file and only that one block —
154
208
  * `parseSessionConfig()` is deliberately NOT used here so the hook path does
155
209
  * not pay for host-path resolution and owner.yaml I/O on every Bash call.
156
210
  *
211
+ * `max-per-session` is returned ALREADY RESOLVED for the current session type
212
+ * (read from the active STATE.md frontmatter), so every existing consumer that
213
+ * treats it as a plain number keeps working and automatically gains the
214
+ * per-session-type override. The unresolved value stays available under
215
+ * `max-per-session-raw`, and `session-type-resolved` records which type the
216
+ * resolution used (`null` when STATE.md is absent or carries no session-type).
217
+ *
157
218
  * @param {string} repoRoot
158
- * @returns {{ "max-per-session": number, mode: string, overflow: string }}
219
+ * @returns {{ "max-per-session": number, "max-per-session-raw": number|Record<string, number>, "session-type-resolved": string|null, mode: string, overflow: string }}
159
220
  */
160
221
  export function loadIssueBudgetConfig(repoRoot) {
161
- const defaults = { 'max-per-session': 12, mode: 'strict', overflow: 'collect-issue' };
222
+ const defaults = {
223
+ 'max-per-session': 12,
224
+ 'max-per-session-raw': 12,
225
+ 'session-type-resolved': null,
226
+ mode: 'strict',
227
+ overflow: 'collect-issue',
228
+ };
229
+ let parsed;
162
230
  try {
163
231
  const resolved = resolveInstructionFile(repoRoot);
164
232
  if (!resolved) return defaults;
165
- return _parseIssueBudget(readFileSync(resolved.path, 'utf8'));
233
+ parsed = _parseIssueBudget(readFileSync(resolved.path, 'utf8'));
166
234
  } catch {
167
235
  return defaults;
168
236
  }
237
+ const sessionType = readSessionTypeFromStateMd(repoRoot);
238
+ return {
239
+ ...parsed,
240
+ 'max-per-session': resolveMaxPerSession(parsed, sessionType),
241
+ 'session-type-resolved': sessionType,
242
+ };
169
243
  }
170
244
 
171
245
  /**
@@ -180,6 +254,27 @@ export function budgetStatePath(repoRoot, sessionId = null) {
180
254
  return path.join(repoRoot, budgetStateRel(sessionId));
181
255
  }
182
256
 
257
+ /**
258
+ * Is this a well-formed charge record (#1347)?
259
+ *
260
+ * A record that fails this test is DROPPED rather than repaired: it can no
261
+ * longer prove which unit it was charged to, and a refund against an
262
+ * unclassifiable record is exactly the drain this ledger exists to prevent.
263
+ *
264
+ * @param {unknown} r
265
+ * @returns {boolean}
266
+ */
267
+ function _isChargeRecord(r) {
268
+ return (
269
+ !!r &&
270
+ typeof r === 'object' &&
271
+ !Array.isArray(r) &&
272
+ typeof r.key === 'string' &&
273
+ r.key.length > 0 &&
274
+ (r.unit === 'count' || r.unit === 'exempt')
275
+ );
276
+ }
277
+
183
278
  /**
184
279
  * Coerce a parsed counter file into a state object, or `null` when it does not
185
280
  * belong to `accountingSessionId`.
@@ -200,6 +295,10 @@ function _coerceState(data, accountingSessionId) {
200
295
  count: Number.isInteger(data.count) && data.count >= 0 ? data.count : 0,
201
296
  exempt: Number.isInteger(data.exempt) && data.exempt >= 0 ? data.exempt : 0,
202
297
  overflow: Array.isArray(data.overflow) ? data.overflow : [],
298
+ // #1347 CHARGE ledger — PROOF OF CHARGE, the thing a refund must present.
299
+ // Coerced here and NOT elsewhere: this function is the only reader, so a key
300
+ // it drops is a key that does not survive a round trip.
301
+ charged: Array.isArray(data.charged) ? data.charged.filter(_isChargeRecord) : [],
203
302
  };
204
303
  }
205
304
 
@@ -250,7 +349,7 @@ function _readStateFile(file, accountingSessionId) {
250
349
  export function readBudgetState(repoRoot, sessionId) {
251
350
  const accountingSessionId =
252
351
  typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : null;
253
- const fresh = { sessionId: accountingSessionId, count: 0, exempt: 0, overflow: [] };
352
+ const fresh = { sessionId: accountingSessionId, count: 0, exempt: 0, overflow: [], charged: [] };
254
353
  if (accountingSessionId === null) return fresh;
255
354
 
256
355
  const ownFile = budgetStatePath(repoRoot, accountingSessionId);
@@ -285,6 +384,100 @@ export function writeBudgetState(repoRoot, state) {
285
384
  return res.ok === true;
286
385
  }
287
386
 
387
+ /**
388
+ * Named ceilings for a parked overflow record (#1314, BV-004). GitLab accepts
389
+ * descriptions up to ~1 MB; 16 KiB covers every real issue body this repo files,
390
+ * and the record lives in a per-session JSON file that session-end folds into
391
+ * ONE collector issue. Revisit if `truncated: true` shows up in a fold.
392
+ * Measured in UTF-16 code units (≈ bytes for ASCII bodies).
393
+ */
394
+ export const OVERFLOW_DESCRIPTION_MAX = 16 * 1024;
395
+ export const OVERFLOW_COMMAND_MAX = 2 * 1024;
396
+ /** A description file above GitLab's own ~1 MB limit is not read at all. */
397
+ const OVERFLOW_DESCRIPTION_FILE_MAX_BYTES = 1024 * 1024;
398
+
399
+ /**
400
+ * Build the structured overflow record BOTH park sites write (#1314) — the
401
+ * hook's chain pre-flight and `chargeIssueBudget`'s strict branch. Before this
402
+ * both parked only the raw command, so a body passed as `$(cat /tmp/x.md)` was
403
+ * lost once the temp file was gone. The description file is read NOW, at block
404
+ * time; any read failure yields `description: null`, silently — the park itself
405
+ * must never fail. Old records (`title/command/at` only) stay readable: every
406
+ * added key is optional.
407
+ *
408
+ * Path resolution: a leading `~` / `~/` expands to the home directory; an
409
+ * absolute path is used as is; a relative path resolves against `cwd` (the
410
+ * PreToolUse input's working directory) when that is absolute, else against
411
+ * `repoRoot`. When `cwdChanged` (a `cd` earlier in the same chain) a relative
412
+ * path is NOT resolved — the record says `descriptionUnresolved: 'cwd-changed'`
413
+ * instead of parking whichever file happens to sit at the guessed location.
414
+ * NAMED CEILING (BV-004): `~user/` is not expanded. Revisit if it shows up.
415
+ *
416
+ * @param {{ repoRoot: string, title?: string|null, description?: string|null,
417
+ * descriptionFile?: string|null, repo?: string|null,
418
+ * cwd?: string|null, cwdChanged?: boolean,
419
+ * command: string, at: string }} opts
420
+ * @returns {{ title: string|null, description: string|null, repo: string|null,
421
+ * command: string, at: string, truncated: boolean,
422
+ * descriptionUnresolved?: 'cwd-changed' }}
423
+ */
424
+ export function buildOverflowRecord({
425
+ repoRoot,
426
+ title = null,
427
+ description = null,
428
+ descriptionFile = null,
429
+ repo = null,
430
+ cwd = null,
431
+ cwdChanged = false,
432
+ command,
433
+ at,
434
+ }) {
435
+ let body = typeof description === 'string' ? description : null;
436
+ let truncated = false;
437
+ let unresolved = null;
438
+ if (body === null && typeof descriptionFile === 'string' && descriptionFile.length > 0) {
439
+ try {
440
+ let file = descriptionFile;
441
+ if (file === '~' || file.startsWith('~/')) file = path.join(homedir(), file.slice(1));
442
+ if (!path.isAbsolute(file)) {
443
+ if (cwdChanged) {
444
+ unresolved = 'cwd-changed';
445
+ throw new Error('cwd-changed');
446
+ }
447
+ const base = typeof cwd === 'string' && path.isAbsolute(cwd) ? cwd : repoRoot;
448
+ file = path.resolve(base, file);
449
+ }
450
+ const st = statSync(file);
451
+ if (st.isFile() && st.size <= OVERFLOW_DESCRIPTION_FILE_MAX_BYTES) {
452
+ body = readFileSync(file, 'utf8');
453
+ } else if (st.isFile()) {
454
+ truncated = true;
455
+ }
456
+ } catch {
457
+ body = null;
458
+ }
459
+ }
460
+ if (body !== null && body.length > OVERFLOW_DESCRIPTION_MAX) {
461
+ body = body.slice(0, OVERFLOW_DESCRIPTION_MAX);
462
+ // Never end on half a surrogate pair — a lone high surrogate is not text.
463
+ const last = body.charCodeAt(body.length - 1);
464
+ if (last >= 0xd800 && last <= 0xdbff) body = body.slice(0, -1);
465
+ truncated = true;
466
+ }
467
+ const raw = String(command);
468
+ if (raw.length > OVERFLOW_COMMAND_MAX) truncated = true;
469
+ const record = {
470
+ title: title ?? null,
471
+ description: body,
472
+ repo: typeof repo === 'string' && repo.length > 0 ? repo : null,
473
+ command: raw.slice(0, OVERFLOW_COMMAND_MAX),
474
+ at,
475
+ truncated,
476
+ };
477
+ if (unresolved !== null) record.descriptionUnresolved = unresolved;
478
+ return record;
479
+ }
480
+
288
481
  /**
289
482
  * Charge one issue creation against the session budget and return the verdict.
290
483
  *
@@ -304,6 +497,11 @@ export function writeBudgetState(repoRoot, state) {
304
497
  * sessionId?: string|null,
305
498
  * command: string,
306
499
  * title?: string|null,
500
+ * description?: string|null,
501
+ * descriptionFile?: string|null,
502
+ * repo?: string|null,
503
+ * cwd?: string|null,
504
+ * cwdChanged?: boolean,
307
505
  * config?: { "max-per-session": number, mode: string, overflow: string },
308
506
  * now?: string,
309
507
  * }} opts
@@ -323,6 +521,13 @@ export function chargeIssueBudget({
323
521
  sessionId = null,
324
522
  command,
325
523
  title = null,
524
+ description = null,
525
+ descriptionFile = null,
526
+ repo = null,
527
+ cwd = null,
528
+ cwdChanged = false,
529
+ toolCallId = null,
530
+ statementIndex = 0,
326
531
  config,
327
532
  now = new Date().toISOString(),
328
533
  }) {
@@ -354,9 +559,26 @@ export function chargeIssueBudget({
354
559
  const persist = (next) =>
355
560
  accountingSessionId === null ? false : writeBudgetState(repoRoot, next);
356
561
 
562
+ // The charge RECORD is the only thing a later refund may act on (#1347). It is
563
+ // written on every branch that actually increments a counter, and on NO branch
564
+ // that does not — which is what makes a parked (blocked) creation unrefundable
565
+ // by construction rather than by a heuristic in the refund hook.
566
+ const record = (unit) => {
567
+ state.charged = [
568
+ ...state.charged,
569
+ {
570
+ id: bookingId({ toolCallId, statementIndex }),
571
+ key: bookingKey({ sessionId: accountingSessionId, command: String(command ?? ''), statementIndex }),
572
+ unit,
573
+ at: now,
574
+ },
575
+ ].slice(-CHARGED_LEDGER_MAX);
576
+ };
577
+
357
578
  const { exempt, reason } = classifyExemption(command);
358
579
  if (exempt) {
359
580
  state.exempt += 1;
581
+ record('exempt');
360
582
  persist(state);
361
583
  return {
362
584
  ...base,
@@ -369,22 +591,24 @@ export function chargeIssueBudget({
369
591
 
370
592
  if (state.count < max) {
371
593
  state.count += 1;
594
+ record('count');
372
595
  persist(state);
373
596
  return { ...base, decision: 'allow', count: state.count, overflowCount: state.overflow.length, reason: null };
374
597
  }
375
598
 
376
599
  if (mode === 'warn') {
377
600
  state.count += 1;
601
+ record('count');
378
602
  persist(state);
379
603
  return { ...base, decision: 'warn', count: state.count, overflowCount: state.overflow.length, reason: null };
380
604
  }
381
605
 
382
606
  // strict — park the request, do not count it.
383
- state.overflow.push({
384
- title: title ?? null,
385
- command: String(command).slice(0, 500),
386
- at: now,
387
- });
607
+ state.overflow.push(
608
+ buildOverflowRecord({
609
+ repoRoot, title, description, descriptionFile, repo, cwd, cwdChanged, command, at: now,
610
+ }),
611
+ );
388
612
  persist(state);
389
613
  return {
390
614
  ...base,
@@ -395,6 +619,185 @@ export function chargeIssueBudget({
395
619
  };
396
620
  }
397
621
 
622
+ /**
623
+ * How many CHARGE records one session's counter file remembers (#1347, BV-004).
624
+ *
625
+ * A record is REMOVED only when its refund is honoured; the record of a create
626
+ * that SUCCEEDED stays for the rest of the session (nothing else prunes it), so
627
+ * the ledger holds every charge of the session, not only the outstanding ones.
628
+ * 64 covers a cap of 12 plus the uncapped exempt creates of a long deep
629
+ * session (measured this repo: ≤ 12 per session so far) and keeps the file in
630
+ * the low-kilobyte class. Eviction is fail-CLOSED: an evicted record
631
+ * makes its refund `not-charged`, i.e. the slot stays spent (the cap ends up
632
+ * slightly tighter), never refundable twice. Revisit if a session legitimately
633
+ * exceeds 64 outstanding charges — then the bound, not the direction, is wrong.
634
+ */
635
+ export const CHARGED_LEDGER_MAX = 64;
636
+
637
+ /**
638
+ * The EXACT id of one charged statement, when the harness publishes a tool-call
639
+ * id for the invocation.
640
+ *
641
+ * `<tool_use_id>#<statementIndex>` — instance-unique per statement, so a chain
642
+ * of two creates yields two ids and a re-delivered failure of the same call
643
+ * yields the same one.
644
+ *
645
+ * @param {{ toolCallId?: string|null, statementIndex?: number }} opts
646
+ * @returns {string|null} `null` when the harness published no id
647
+ */
648
+ export function bookingId({ toolCallId = null, statementIndex = 0 } = {}) {
649
+ if (typeof toolCallId !== 'string' || toolCallId.length === 0) return null;
650
+ return `${toolCallId}#${Number.isInteger(statementIndex) ? statementIndex : 0}`;
651
+ }
652
+
653
+ /**
654
+ * The deterministic fallback key of one charged statement.
655
+ *
656
+ * Deliberately session + command text + statement index and NOTHING ELSE — in
657
+ * particular NOT the counter value at charge time (`countBefore`, the #1347
658
+ * first-cut key). That value made every retry of the same create mint a FRESH
659
+ * key: measured against a cap of 3, three retries of a create that was PARKED
660
+ * (never charged) refunded three slots and drove `count` to 0 while the parked
661
+ * record still sat in `overflow[]` — i.e. `max-per-session` was unbounded by
662
+ * retrying a denied create. The key is now stable across retries, and proof of
663
+ * charge comes from the record's PRESENCE rather than from the key's shape.
664
+ *
665
+ * @param {{ sessionId: string|null, command: string, statementIndex?: number }} opts
666
+ * @returns {string}
667
+ */
668
+ export function bookingKey({ sessionId, command, statementIndex = 0 }) {
669
+ const idx = Number.isInteger(statementIndex) ? statementIndex : 0;
670
+ return digestSha256Short(`cmd:${sessionId ?? ''}|${command}|${idx}`, { length: 16 });
671
+ }
672
+
673
+ /**
674
+ * Give one booking back to the session budget (#1347) — ONLY against a recorded
675
+ * CHARGE.
676
+ *
677
+ * WHY THIS EXISTS: `chargeIssueBudget` books the slot in PreToolUse — BEFORE the
678
+ * command runs. When `glab issue create` then fails (network, rejected label,
679
+ * expired auth) and the coordinator retries, the SAME issue consumed two slots,
680
+ * and under `max-per-session: 12` + `mode: strict` that pushes a legitimate
681
+ * issue into overflow parking. This is the compensating half of the charge.
682
+ *
683
+ * WHY IT IS LEDGER-BASED AND NOT INFERRED: the first cut decremented whenever a
684
+ * failure signal arrived for an issue-create command. It could not tell a CHARGE
685
+ * from a NON-charge, and at `count === max` the pre-hook PARKS the create and
686
+ * denies WITHOUT charging while the failure event still fires — so retrying a
687
+ * DENIED create refunded a slot that was never spent. Presenting the charge
688
+ * record is the only form of proof that closes that class; every "was this
689
+ * really charged?" heuristic is a guess about state this module already knows.
690
+ *
691
+ * Matching: the exact `<tool_use_id>#<index>` id when both sides have one,
692
+ * otherwise the deterministic `bookingKey`. Both routes are consulted because
693
+ * the charging and refunding payloads are separate harness events and only one
694
+ * of them may carry a tool-call id.
695
+ *
696
+ * Decision table:
697
+ * mode `off` → `{ decision: 'off' }`, nothing written
698
+ * no accounting session id → `{ decision: 'no-session' }`, nothing written
699
+ * no matching charge record → `{ decision: 'noop', reason: 'not-charged' }`
700
+ * record with `unit: exempt` → `{ decision: 'refunded-exempt' }`, `exempt`--
701
+ * record with `unit: count` → `{ decision: 'refunded' }`, `count`--
702
+ *
703
+ * Three invariants:
704
+ * - The matched record is REMOVED, so a re-delivered failure of the same call
705
+ * finds nothing and is `not-charged`. Dedupe is the removal, not a second
706
+ * ledger.
707
+ * - The refund lands on the SAME counter the charge did (`count` vs `exempt`),
708
+ * read off the record — never re-classified from the command text, which a
709
+ * retry can have edited.
710
+ * - `overflow[]` is NEVER touched. A parked record means the creation was
711
+ * BLOCKED, so it was never charged and there is nothing to give back;
712
+ * removing it would drop the item session-end promises to file.
713
+ *
714
+ * Fail-open like every other function here: an unwritable ledger means the
715
+ * refund is lost (the cap stays a little tighter), never an exception.
716
+ *
717
+ * @param {{
718
+ * repoRoot: string,
719
+ * sessionId?: string|null,
720
+ * command: string,
721
+ * toolCallId?: string|null,
722
+ * statementIndex?: number,
723
+ * config?: { "max-per-session": number, mode: string, overflow: string },
724
+ * }} opts
725
+ * @returns {{
726
+ * decision: 'off'|'no-session'|'noop'|'refunded'|'refunded-exempt',
727
+ * count: number,
728
+ * exempt: number,
729
+ * max: number,
730
+ * mode: string,
731
+ * overflowCount: number,
732
+ * bookingId: string|null,
733
+ * reason: string|null,
734
+ * }}
735
+ */
736
+ export function refundBooking({
737
+ repoRoot,
738
+ sessionId = null,
739
+ command,
740
+ toolCallId = null,
741
+ statementIndex = 0,
742
+ config,
743
+ }) {
744
+ const cfg = config ?? loadIssueBudgetConfig(repoRoot);
745
+ const base = { max: cfg['max-per-session'], mode: cfg.mode };
746
+ const accountingSessionId =
747
+ typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : null;
748
+
749
+ if (cfg.mode === 'off') {
750
+ return { ...base, decision: 'off', count: 0, exempt: 0, overflowCount: 0, bookingId: null, reason: null };
751
+ }
752
+ // Identity-less: `chargeIssueBudget` never PERSISTED such a charge, so there
753
+ // is provably nothing to refund — and writing here would hit the shared legacy
754
+ // slot and reset a live session's count (the #1141 hazard).
755
+ if (accountingSessionId === null) {
756
+ return { ...base, decision: 'no-session', count: 0, exempt: 0, overflowCount: 0, bookingId: null, reason: null };
757
+ }
758
+
759
+ const state = readBudgetState(repoRoot, accountingSessionId);
760
+ state.sessionId = accountingSessionId;
761
+ const exactId = bookingId({ toolCallId, statementIndex });
762
+ const key = bookingKey({
763
+ sessionId: accountingSessionId,
764
+ command: String(command ?? ''),
765
+ statementIndex,
766
+ });
767
+ const verdict = (decision, reason = null, matchedId = null) => ({
768
+ ...base,
769
+ decision,
770
+ count: state.count,
771
+ exempt: state.exempt,
772
+ overflowCount: state.overflow.length,
773
+ bookingId: matchedId ?? exactId ?? key,
774
+ reason,
775
+ });
776
+
777
+ // Match order (review MED-1, measured): the deterministic key is shared by
778
+ // EVERY charge of the same command text in this session, so a refund that
779
+ // carries a tool_use_id must match by id first and may fall back to the key
780
+ // only among records charged WITHOUT an id (the id-less harness / a
781
+ // one-sided id). Falling back onto an id-bearing record would hand the
782
+ // refund to an OLDER charge that may have SUCCEEDED — two charges, one
783
+ // delivery each, and the second delivery drained the successful create's slot.
784
+ let at = exactId !== null ? state.charged.findIndex((r) => r.id === exactId) : -1;
785
+ if (at === -1) {
786
+ at = state.charged.findIndex((r) => r.key === key && (exactId === null || r.id === null || r.id === undefined));
787
+ }
788
+ if (at === -1) return verdict('noop', 'not-charged');
789
+
790
+ const rec = state.charged[at];
791
+ state.charged = [...state.charged.slice(0, at), ...state.charged.slice(at + 1)];
792
+ if (rec.unit === 'exempt') {
793
+ if (state.exempt > 0) state.exempt -= 1;
794
+ } else if (state.count > 0) {
795
+ state.count -= 1;
796
+ }
797
+ writeBudgetState(repoRoot, state);
798
+ return verdict(rec.unit === 'exempt' ? 'refunded-exempt' : 'refunded', null, rec.id ?? rec.key);
799
+ }
800
+
398
801
  /**
399
802
  * Human-readable block message. Shared by the hook (stderr + deny JSON) and the
400
803
  * programmatic path so an agent sees the same instruction either way.
@@ -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
  *
@@ -0,0 +1,44 @@
1
+ /**
2
+ * learnings/sizing-subject.mjs — the one derivation of an `effective-sizing`
3
+ * learning's `subject`, keyed on `(session_type, session_profile)`.
4
+ *
5
+ * GitLab #1247. `ultradeep` is a PROFILE layered on top of an unchanged
6
+ * `session_type: deep` (STATE.md frontmatter key `session-profile`; see
7
+ * `scripts/lib/state-md.mjs` `SESSION_PROFILE_FIELD`, PRD
8
+ * `docs/prd/2026-09-06-ultradeep-session-profile.md`) — 7 waves vs. deep's
9
+ * 4-5. Before this module, the `effective-sizing` analyzer
10
+ * (`skills/evolve/references/evolve-analyze-mode.md`) keyed its subject on
11
+ * `session_type` alone (e.g. `deep-session-sizing`), so a 7-wave ultradeep
12
+ * session and a 5-wave deep session landed on the SAME row — the over-delivery
13
+ * median for one profile silently absorbed the other's.
14
+ *
15
+ * Pure, stdlib-only, no imports, no clock, no fs.
16
+ *
17
+ * ## Subject literal (chosen form, justified)
18
+ *
19
+ * `${session_type}-session-sizing` when `session_profile` is absent/null —
20
+ * BYTE-IDENTICAL to the pre-#1247 literal (`deep-session-sizing`,
21
+ * `feature-session-sizing`, per the existing `skills/evolve/SKILL.md` example
22
+ * row), so learnings keyed before this change still match on re-run.
23
+ * `${session_type}-${session_profile}-session-sizing` when a profile is
24
+ * present — e.g. `deep-ultradeep-session-sizing`. The profile slots BETWEEN
25
+ * type and the `-session-sizing` suffix (rather than, say, prefixing or
26
+ * appending after the suffix) so every subject stays readable as
27
+ * `<type>[-<profile>]-session-sizing` and a reader can strip the trailing
28
+ * `-session-sizing` to recover `type[-profile]` unambiguously — the same
29
+ * shape `session-profile` frontmatter documents ("a wave-shape variant on
30
+ * top of an unchanged session_type").
31
+ *
32
+ * @param {{session_type?: unknown, session_profile?: unknown}} params
33
+ * @returns {string} The canonical `effective-sizing` subject.
34
+ */
35
+ export function sizingSubject({ session_type, session_profile } = {}) {
36
+ const type = typeof session_type === 'string' && session_type.trim().length > 0
37
+ ? session_type.trim()
38
+ : 'unknown';
39
+ const profile =
40
+ typeof session_profile === 'string' && session_profile.trim().length > 0
41
+ ? session_profile.trim()
42
+ : null;
43
+ return profile === null ? `${type}-session-sizing` : `${type}-${profile}-session-sizing`;
44
+ }
@@ -22,14 +22,13 @@
22
22
  * NEVER → session-lock.mjs, so there is no import cycle.
23
23
  */
24
24
 
25
- import fs from 'node:fs';
26
25
  import os from 'node:os';
27
26
  import path from 'node:path';
28
27
  import crypto from 'node:crypto';
29
28
 
30
- import { tryAcquireFileLock } from '../file-lock.mjs';
31
- import { hostnamesMatch, lockHostCandidate, stableHostname } from '../host-identity.mjs';
32
- import { nowIso, delay, parseLockBody } from './lock-body.mjs';
29
+ import { releaseFileLock, tryAcquireFileLock } from '../file-lock.mjs';
30
+ import { stableHostname } from '../host-identity.mjs';
31
+ import { nowIso, delay } from './lock-body.mjs';
33
32
 
34
33
  // ---------------------------------------------------------------------------
35
34
  // Constants
@@ -156,47 +155,23 @@ export async function acquireStagingFenceLock({
156
155
  /**
157
156
  * Release the staging-fence commit-lock IFF the holder matches.
158
157
  *
158
+ * `busy` (#1349) means the shared `${lockFile}.acquire` guard stayed taken past
159
+ * its budget; the lock file is left untouched for its stale policy.
160
+ *
159
161
  * @param {object} [opts]
160
162
  * @param {string} [opts.repoRoot]
161
163
  * @param {string} [opts.holder]
162
- * @returns {{ ok: true } | { ok: false, reason: 'not-found'|'not-owner'|'fs-error', error?: string }}
164
+ * @returns {{ ok: true } | { ok: false, reason: 'not-found'|'not-owner'|'busy'|'fs-error', error?: string }}
163
165
  */
164
166
  export function releaseStagingFenceLock({ repoRoot, holder } = {}) {
165
167
  const lockFile = stagingFenceLockPathFor(repoRoot);
166
168
 
167
- let raw;
168
- try {
169
- raw = fs.readFileSync(lockFile, 'utf8');
170
- } catch (err) {
171
- if (err.code === 'ENOENT') {
172
- return { ok: false, reason: 'not-found' };
173
- }
174
- return { ok: false, reason: 'fs-error', error: err.message };
175
- }
176
-
177
- const lock = parseLockBody(raw);
178
- if (lock === null) {
179
- return { ok: false, reason: 'not-owner' };
180
- }
181
-
182
- const ownerMatch = typeof holder === 'string' && holder.length > 0
183
- ? lock.holder === holder
184
- // #1072: alias-aware host identity, not a raw os.hostname() comparison.
185
- : lock.pid === process.pid && hostnamesMatch(lockHostCandidate(lock), os.hostname());
186
-
187
- if (!ownerMatch) {
188
- return { ok: false, reason: 'not-owner' };
189
- }
190
-
191
- try {
192
- fs.unlinkSync(lockFile);
193
- return { ok: true };
194
- } catch (err) {
195
- if (err.code === 'ENOENT') {
196
- return { ok: false, reason: 'not-found' };
197
- }
198
- return { ok: false, reason: 'fs-error', error: err.message };
199
- }
169
+ // Consolidated onto releaseFileLock (#1349); the `pid` staleCheck makes the
170
+ // takeover race unreachable today — if this lock ever moves to a lease mode,
171
+ // the shared guard is what keeps release safe.
172
+ // An empty holder falls through to releaseFileLock's PID + host owner check,
173
+ // exactly as the inline sequence did.
174
+ return releaseFileLock(lockFile, { holder });
200
175
  }
201
176
 
202
177
  /**
@@ -258,11 +233,17 @@ export async function withStagingFenceLock(repoRoot, fn, opts = {}) {
258
233
  } catch (err) {
259
234
  caughtError = err;
260
235
  } finally {
236
+ // WARN only where OUR lock is left behind: 'fs-error' and 'busy' (the
237
+ // shared guard stayed taken — #1349). 'not-found'/'not-owner' stay silent.
261
238
  const releaseResult = releaseStagingFenceLock({ repoRoot, holder });
262
239
  if (!releaseResult.ok && releaseResult.reason === 'fs-error') {
263
240
  console.warn(
264
241
  `withStagingFenceLock: release failed (fs-error: ${releaseResult.error ?? 'unknown'})`,
265
242
  );
243
+ } else if (!releaseResult.ok && releaseResult.reason === 'busy') {
244
+ console.warn(
245
+ 'withStagingFenceLock: release failed (busy: acquire guard held) — .commit.lock left for its stale policy',
246
+ );
266
247
  }
267
248
  }
268
249