session-orchestrator 4.2.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 (129) hide show
  1. package/.agents/skills/session-start/SKILL.md +1 -1
  2. package/.agents/skills/ux-grill/SKILL.md +22 -0
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/.claude-plugin/plugin.json +3 -2
  5. package/.codex-plugin/plugin.json +1 -1
  6. package/.codex-plugin/skills/session-start/SKILL.md +1 -1
  7. package/.codex-plugin/skills/ux-grill/SKILL.md +21 -0
  8. package/.codex-plugin/skills/ux-grill/agents/openai.yaml +5 -0
  9. package/.cursor/commands/ux-grill.md +14 -0
  10. package/.cursor/skills/session-start/SKILL.md +1 -1
  11. package/.cursor/skills/ux-grill/SKILL.md +13 -0
  12. package/.cursor-plugin/plugin.json +1 -1
  13. package/AGENTS.md +2 -1
  14. package/CHANGELOG.md +94 -1
  15. package/README.md +98 -86
  16. package/agents/dialectic-deriver.md +11 -0
  17. package/commands/ux-grill.md +51 -0
  18. package/docs/USER-GUIDE.md +2 -2
  19. package/docs/codex-setup.md +8 -0
  20. package/docs/components.md +7 -7
  21. package/docs/events-schema.md +9 -5
  22. package/docs/github-mirror-protection.md +61 -20
  23. package/docs/migration-v5.md +62 -0
  24. package/docs/scope-collision-guard.md +14 -0
  25. package/hooks/_lib/hook-import-set.json +44 -2
  26. package/hooks/_lib/lock-bootstrap.mjs +84 -1
  27. package/hooks/_lib/vcs-create-matcher.mjs +190 -3
  28. package/hooks/enforce-scope.mjs +201 -0
  29. package/hooks/hooks-codex.json +1 -1
  30. package/hooks/hooks-cursor.json +5 -0
  31. package/hooks/hooks.json +7 -2
  32. package/hooks/on-session-start.mjs +171 -49
  33. package/hooks/post-bash-issue-budget-refund.mjs +375 -0
  34. package/hooks/pre-auq-clarity.mjs +70 -18
  35. package/hooks/pre-bash-issue-budget.mjs +51 -4
  36. package/package.json +2 -1
  37. package/pi/prompts/ux-grill.md +12 -0
  38. package/scripts/ci/assert-vitest-green.mjs +4 -2
  39. package/scripts/dialectic-deriver.mjs +32 -8
  40. package/scripts/emit-session.mjs +72 -1
  41. package/scripts/lib/agent-status.mjs +441 -9
  42. package/scripts/lib/auq/schema.mjs +10 -3
  43. package/scripts/lib/ci-status-banner.mjs +29 -6
  44. package/scripts/lib/claude-md-budget-lint.mjs +52 -2
  45. package/scripts/lib/config.mjs +12 -1
  46. package/scripts/lib/eval/engine.mjs +7 -1
  47. package/scripts/lib/file-lock.mjs +114 -13
  48. package/scripts/lib/git-porcelain.mjs +113 -0
  49. package/scripts/lib/instruction-budget-guard.mjs +415 -47
  50. package/scripts/lib/io.mjs +29 -4
  51. package/scripts/lib/issue-budget.mjs +336 -6
  52. package/scripts/lib/learnings/sizing-subject.mjs +44 -0
  53. package/scripts/lib/locks/staging-fence-lock.mjs +19 -38
  54. package/scripts/lib/locks/state-md-lock.mjs +19 -41
  55. package/scripts/lib/maintenance-due-banner.mjs +11 -1
  56. package/scripts/lib/peer-cards/merger.mjs +143 -0
  57. package/scripts/lib/pre-dispatch-check.mjs +20 -14
  58. package/scripts/lib/project-hygiene.mjs +81 -30
  59. package/scripts/lib/quality-gate.mjs +14 -65
  60. package/scripts/lib/reconcile/engine.mjs +19 -1
  61. package/scripts/lib/reconcile/writer.mjs +278 -11
  62. package/scripts/lib/scope-echo.mjs +346 -0
  63. package/scripts/lib/session-lock.mjs +62 -2
  64. package/scripts/lib/session-record-repair.mjs +91 -0
  65. package/scripts/lib/session-schema/filters.mjs +26 -1
  66. package/scripts/lib/session-start-probes.mjs +419 -53
  67. package/scripts/lib/test-runner/artifact-paths.mjs +30 -5
  68. package/scripts/lib/test-runner/issue-reconcile.mjs +45 -8
  69. package/scripts/lib/tmux-layout/layouts.mjs +62 -4
  70. package/scripts/lib/ux-grill/collect.mjs +1163 -0
  71. package/scripts/lib/ux-grill/compare.mjs +285 -0
  72. package/scripts/lib/ux-grill/manifest.mjs +618 -0
  73. package/scripts/lib/ux-grill/measures.mjs +431 -0
  74. package/scripts/lib/ux-grill/paths.mjs +224 -0
  75. package/scripts/lib/ux-grill/pencil-coverage.mjs +284 -0
  76. package/scripts/lib/ux-grill/reconcile.mjs +344 -0
  77. package/scripts/lib/ux-grill/run-record.mjs +316 -0
  78. package/scripts/lib/ux-grill/schema.mjs +321 -0
  79. package/scripts/lib/validate/check-untracked-test-deps.mjs +33 -19
  80. package/scripts/lib/validate/check-unwired-features.mjs +48 -20
  81. package/scripts/lib/vault-status/board-lock.mjs +18 -0
  82. package/scripts/lib/vault-status/board-writer.mjs +8 -0
  83. package/scripts/mcp-server.sh +16 -1
  84. package/scripts/release.mjs +7 -2
  85. package/skills/bootstrap/SKILL.md +12 -209
  86. package/skills/bootstrap/references/bootstrap-ecosystem-health-flow.md +48 -0
  87. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +37 -0
  88. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +108 -0
  89. package/skills/bootstrap/references/bootstrap-rules-fetch-bridge.md +64 -0
  90. package/skills/claude-md-drift-check/SKILL.md +9 -2
  91. package/skills/claude-md-drift-check/checker.mjs +213 -21
  92. package/skills/discovery/SKILL.md +6 -173
  93. package/skills/discovery/probes/vault-staleness.mjs +35 -5
  94. package/skills/discovery/probes-docs.md +8 -4
  95. package/skills/discovery/probes-supply-chain.md +4 -2
  96. package/skills/discovery/probes-ui.md +7 -3
  97. package/skills/discovery/probes-vault.md +12 -4
  98. package/skills/discovery/references/discovery-interactive-triage.md +139 -0
  99. package/skills/discovery/references/discovery-triage-state.md +54 -0
  100. package/skills/eval/rubric-v1.md +13 -0
  101. package/skills/evolve/SKILL.md +2 -458
  102. package/skills/evolve/references/evolve-analyze-mode.md +360 -0
  103. package/skills/evolve/references/evolve-dialectic-mode.md +139 -0
  104. package/skills/plan/mode-retro.md +4 -3
  105. package/skills/reconcile/SKILL.md +10 -0
  106. package/skills/session-end/drift-operations.md +20 -5
  107. package/skills/session-end/metrics-collection.md +1 -0
  108. package/skills/session-end/phase-3-6-tail.md +4 -2
  109. package/skills/session-end/references/phase-2-quality-gate.md +3 -3
  110. package/skills/session-end/references/phase-5-issue-cleanup.md +6 -1
  111. package/skills/session-end/session-metrics-write.md +2 -0
  112. package/skills/session-plan/SKILL.md +2 -144
  113. package/skills/session-plan/references/session-plan-task-classification.md +152 -0
  114. package/skills/session-start/SKILL.md +24 -6
  115. package/skills/session-start/references/operations-contract.md +114 -0
  116. package/skills/session-start/references/phase-4-ssot-environment-check.md +22 -20
  117. package/skills/session-start/soul.md +2 -2
  118. package/skills/test-runner/SKILL.md +1 -1
  119. package/skills/tmux-layout/SKILL.md +3 -1
  120. package/skills/ux-grill/SKILL.md +211 -0
  121. package/skills/ux-grill/rubric-v2.md +201 -0
  122. package/skills/ux-grill/soul.md +76 -0
  123. package/skills/wave-executor/SKILL.md +3 -128
  124. package/skills/wave-executor/references/wave-executor-quality-gate.md +61 -0
  125. package/skills/wave-executor/references/wave-executor-state-init.md +86 -0
  126. package/skills/wave-executor/references/wave-loop-dispatch.md +8 -0
  127. package/skills/wave-executor/references/wave-loop-review.md +18 -5
  128. package/templates/_shared/ux-manifest.template.md +149 -0
  129. 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 = [];
@@ -29,6 +29,7 @@
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';
@@ -253,6 +254,27 @@ export function budgetStatePath(repoRoot, sessionId = null) {
253
254
  return path.join(repoRoot, budgetStateRel(sessionId));
254
255
  }
255
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
+
256
278
  /**
257
279
  * Coerce a parsed counter file into a state object, or `null` when it does not
258
280
  * belong to `accountingSessionId`.
@@ -273,6 +295,10 @@ function _coerceState(data, accountingSessionId) {
273
295
  count: Number.isInteger(data.count) && data.count >= 0 ? data.count : 0,
274
296
  exempt: Number.isInteger(data.exempt) && data.exempt >= 0 ? data.exempt : 0,
275
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) : [],
276
302
  };
277
303
  }
278
304
 
@@ -323,7 +349,7 @@ function _readStateFile(file, accountingSessionId) {
323
349
  export function readBudgetState(repoRoot, sessionId) {
324
350
  const accountingSessionId =
325
351
  typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : null;
326
- const fresh = { sessionId: accountingSessionId, count: 0, exempt: 0, overflow: [] };
352
+ const fresh = { sessionId: accountingSessionId, count: 0, exempt: 0, overflow: [], charged: [] };
327
353
  if (accountingSessionId === null) return fresh;
328
354
 
329
355
  const ownFile = budgetStatePath(repoRoot, accountingSessionId);
@@ -358,6 +384,100 @@ export function writeBudgetState(repoRoot, state) {
358
384
  return res.ok === true;
359
385
  }
360
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
+
361
481
  /**
362
482
  * Charge one issue creation against the session budget and return the verdict.
363
483
  *
@@ -377,6 +497,11 @@ export function writeBudgetState(repoRoot, state) {
377
497
  * sessionId?: string|null,
378
498
  * command: string,
379
499
  * title?: string|null,
500
+ * description?: string|null,
501
+ * descriptionFile?: string|null,
502
+ * repo?: string|null,
503
+ * cwd?: string|null,
504
+ * cwdChanged?: boolean,
380
505
  * config?: { "max-per-session": number, mode: string, overflow: string },
381
506
  * now?: string,
382
507
  * }} opts
@@ -396,6 +521,13 @@ export function chargeIssueBudget({
396
521
  sessionId = null,
397
522
  command,
398
523
  title = null,
524
+ description = null,
525
+ descriptionFile = null,
526
+ repo = null,
527
+ cwd = null,
528
+ cwdChanged = false,
529
+ toolCallId = null,
530
+ statementIndex = 0,
399
531
  config,
400
532
  now = new Date().toISOString(),
401
533
  }) {
@@ -427,9 +559,26 @@ export function chargeIssueBudget({
427
559
  const persist = (next) =>
428
560
  accountingSessionId === null ? false : writeBudgetState(repoRoot, next);
429
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
+
430
578
  const { exempt, reason } = classifyExemption(command);
431
579
  if (exempt) {
432
580
  state.exempt += 1;
581
+ record('exempt');
433
582
  persist(state);
434
583
  return {
435
584
  ...base,
@@ -442,22 +591,24 @@ export function chargeIssueBudget({
442
591
 
443
592
  if (state.count < max) {
444
593
  state.count += 1;
594
+ record('count');
445
595
  persist(state);
446
596
  return { ...base, decision: 'allow', count: state.count, overflowCount: state.overflow.length, reason: null };
447
597
  }
448
598
 
449
599
  if (mode === 'warn') {
450
600
  state.count += 1;
601
+ record('count');
451
602
  persist(state);
452
603
  return { ...base, decision: 'warn', count: state.count, overflowCount: state.overflow.length, reason: null };
453
604
  }
454
605
 
455
606
  // strict — park the request, do not count it.
456
- state.overflow.push({
457
- title: title ?? null,
458
- command: String(command).slice(0, 500),
459
- at: now,
460
- });
607
+ state.overflow.push(
608
+ buildOverflowRecord({
609
+ repoRoot, title, description, descriptionFile, repo, cwd, cwdChanged, command, at: now,
610
+ }),
611
+ );
461
612
  persist(state);
462
613
  return {
463
614
  ...base,
@@ -468,6 +619,185 @@ export function chargeIssueBudget({
468
619
  };
469
620
  }
470
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
+
471
801
  /**
472
802
  * Human-readable block message. Shared by the hook (stderr + deny JSON) and the
473
803
  * programmatic path so an agent sees the same instruction either way.
@@ -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