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
@@ -63,7 +63,8 @@
63
63
 
64
64
  import { readFileSync, existsSync, statSync } from 'node:fs';
65
65
  import { fileURLToPath } from 'node:url';
66
- import { basename, resolve } from 'node:path';
66
+ import { basename, resolve, sep } from 'node:path';
67
+ import { homedir } from 'node:os';
67
68
  import { resolveInstructionFile } from './common.mjs';
68
69
  import { isSessionConfigHeading } from './config/section-extractor.mjs';
69
70
 
@@ -317,6 +318,36 @@ export function lintClaudeMd(opts = {}) {
317
318
  * @param {number} [opts.maxLineChars] forwarded to lintClaudeMd (default DEFAULT_MAX_LINE_CHARS).
318
319
  * @returns {{ severity: 'warn', message: string } | null}
319
320
  */
321
+ /**
322
+ * Replace a leading home-directory prefix with the literal `$HOME`.
323
+ *
324
+ * Why `$HOME` and not `~`: the banner emits the path inside DOUBLE QUOTES, and
325
+ * no POSIX shell expands a tilde inside double quotes — `node "~/x.mjs"` fails,
326
+ * `node "$HOME/x.mjs"` works. Why not `path.relative(repoRoot, __filename)`
327
+ * (the first proposal): measured 2026-09-11, it only redacts when the repo and
328
+ * the plugin share a home ancestor —
329
+ * repoRoot `~/Projects/bewerbungs-assistent`, plugin under `~/.claude/plugins`
330
+ * → `../../.claude/plugins/…` (private, 98 chars)
331
+ * repoRoot on a tmp/other volume, plugin under the home dir
332
+ * → `../../../../Users/<name>/.claude/…` (LEAKS, and longer)
333
+ * and it is cwd-bound, which contradicts the `--repo-root` echo whose whole
334
+ * purpose is cwd-independence. `$HOME` collapse is unconditional, cwd-free, and
335
+ * leaks nothing in either case.
336
+ *
337
+ * A path OUTSIDE the home directory is returned unchanged — it carries no
338
+ * username to redact, and rewriting it would break the command.
339
+ *
340
+ * @param {string} p absolute path
341
+ * @returns {string} `p` with a leading `homedir()` replaced by `$HOME`
342
+ */
343
+ function homeCollapsed(p) {
344
+ const home = homedir();
345
+ if (typeof p !== 'string' || home.length === 0) return p;
346
+ if (p === home) return '$HOME';
347
+ if (p.startsWith(home + sep)) return '$HOME' + p.slice(home.length);
348
+ return p;
349
+ }
350
+
320
351
  export function checkClaudeMdBudgetLint(opts = {}) {
321
352
  const repoRoot = opts.repoRoot ?? process.cwd();
322
353
 
@@ -343,9 +374,28 @@ export function checkClaudeMdBudgetLint(opts = {}) {
343
374
  if (!result || result.violations.length === 0) return null;
344
375
 
345
376
  const ruleNames = [...new Set(result.violations.map((v) => v.rule))].join(', ');
377
+ // The hint MUST name a path the operator can actually run — `scripts/lib/…`
378
+ // repo-root-relative is only valid inside THIS repo's own checkout. A
379
+ // consumer repo has no such file: it either has no `scripts/lib/` at all,
380
+ // or a foreign one. `__filename` (module-scope, see the CLI section below)
381
+ // is the absolute path of the module that is EXECUTING RIGHT NOW — it is
382
+ // never wrong, because we could not be inside this function otherwise, and
383
+ // it needs no plugin-root lookup at all (no env var, no marketplace-cache
384
+ // scan, no "not resolvable" case to handle — see plugin-update-banner.mjs's
385
+ // module docstring for why guessing a plugin root from an env var is the
386
+ // WRONG move here: `$CLAUDE_PLUGIN_ROOT` can name a checkout that differs
387
+ // from the code that is actually loaded and running). `repoRoot` is echoed
388
+ // back too, so the copied command re-lints the exact file this banner
389
+ // reports on regardless of the operator's cwd when they paste it.
390
+ // PRIVACY (2026-09-11): the same message line already redacts `filePath` via
391
+ // `basename()`, while `__filename` and `repoRoot` went out verbatim — both
392
+ // CP1-shaped (`/Users/<name>/…`) on a personal host. The banner's route into
393
+ // public view is copy-paste into an issue or an agent report, the documented
394
+ // "agent reports carry private slugs" class. `homeCollapsed()` keeps the
395
+ // command EXECUTABLE (so #1302 stays closed) while dropping the username.
346
396
  const message =
347
397
  `⚠ CLAUDE.md budget lint: ${result.violations.length} violation(s) (${ruleNames}) in ${basename(filePath)} — ` +
348
- `run \`node scripts/lib/claude-md-budget-lint.mjs --mode warn\` for details.`;
398
+ `run \`node "${homeCollapsed(__filename)}" --repo-root "${homeCollapsed(repoRoot)}" --mode warn\` for details.`;
349
399
 
350
400
  return { severity: 'warn', message };
351
401
  }
@@ -1,5 +1,6 @@
1
1
  import { matchBlockHeader } from './block-header.mjs';
2
2
  import { preprocessBlockLines } from './block-preprocess.mjs';
3
+ import { _coerceInteger } from './coercers.mjs';
3
4
 
4
5
  /**
5
6
  * issue-budget.mjs — Parser for the `issue-budget:` block-style Session Config key.
@@ -17,10 +18,13 @@ import { preprocessBlockLines } from './block-preprocess.mjs';
17
18
  * `scripts/lib/spiral-carryover.mjs` `runCli()` (programmatic path), both via
18
19
  * `scripts/lib/issue-budget.mjs`.
19
20
  *
20
- * Returns `{ "max-per-session", mode, overflow }`.
21
- * Tolerant parser: malformed values silently fall back to defaults (the
21
+ * Returns `{ "max-per-session", "max-per-session-raw", mode, overflow }`.
22
+ * Tolerant parser: malformed values fall back to defaults (the
22
23
  * `reconcile.min-rule-days` posture, NOT the noisier handover-gate WARN) —
23
- * a hook must never spam stderr on every single Bash call.
24
+ * a hook must never spam stderr on every single Bash call. ONE exception, and
25
+ * it is a safety one: a malformed per-session OVERRIDE with a valid base number
26
+ * (`7 (feature: x)`) keeps the base 7 and WARNs on stderr, because falling back
27
+ * to the built-in 12 there would silently LOOSEN a cap the operator tightened.
24
28
  *
25
29
  * Consumers: `scripts/lib/config.mjs`, `scripts/lib/issue-budget.mjs`,
26
30
  * `hooks/pre-bash-issue-budget.mjs`, `skills/session-end/SKILL.md` Phase 5.
@@ -44,7 +48,9 @@ const OVERFLOW_SINKS = ['collect-issue', 'vault-note'];
44
48
  * Defaults:
45
49
  * max-per-session: 12 integer >= 0; 0 means "no issue may be created"
46
50
  * (a valid, deliberately harsh setting). Malformed
47
- * or negative input falls back to 12.
51
+ * or negative input falls back to 12 — EXCEPT a
52
+ * malformed override over a valid base (`7 (x: y)`),
53
+ * which keeps 7 and WARNs.
48
54
  * mode: strict strict | warn | off
49
55
  * overflow: collect-issue collect-issue | vault-note
50
56
  *
@@ -54,12 +60,29 @@ const OVERFLOW_SINKS = ['collect-issue', 'vault-note'];
54
60
  * mode: strict
55
61
  * overflow: collect-issue
56
62
  *
63
+ * NEW POLICY (not a restatement of an existing rule): `max-per-session` accepts
64
+ * the SAME per-session-type override syntax `agents-per-wave` already uses —
65
+ * `12 (feature: 6)` parses via `_coerceInteger` into
66
+ * `{ default: 12, feature: 6 }`. The key set is OPEN (any session-type label
67
+ * the operator writes), because the session-type vocabulary lives in
68
+ * session-start, not here.
69
+ *
70
+ * The parsed override object is returned under `"max-per-session-raw"`, while
71
+ * `"max-per-session"` stays STRICTLY NUMERIC (the `.default`). That split is
72
+ * load-bearing: three consumers read the key as a number
73
+ * (`scripts/lib/issue-budget.mjs` `chargeIssueBudget`,
74
+ * `hooks/pre-bash-issue-budget.mjs`, and the config-parity docs), and handing
75
+ * any of them an object would surface as `[object Object]` in a cap comparison
76
+ * rather than as an error. Resolution against the CURRENT session type is
77
+ * `resolveMaxPerSession()` in `scripts/lib/issue-budget.mjs`.
78
+ *
57
79
  * @param {string} content — full file contents
58
- * @returns {{ "max-per-session": number, mode: string, overflow: string }}
80
+ * @returns {{ "max-per-session": number, "max-per-session-raw": number|{default: number, [k: string]: number}, mode: string, overflow: string }}
59
81
  */
60
82
  export function _parseIssueBudget(content) {
61
83
  const defaults = {
62
84
  'max-per-session': 12,
85
+ 'max-per-session-raw': 12,
63
86
  mode: 'strict',
64
87
  overflow: 'collect-issue',
65
88
  };
@@ -84,6 +107,7 @@ export function _parseIssueBudget(content) {
84
107
  if (blockLines.length === 0) return { ...defaults };
85
108
 
86
109
  let maxPerSession = defaults['max-per-session'];
110
+ let maxPerSessionRaw = defaults['max-per-session-raw'];
87
111
  let mode = defaults.mode;
88
112
  let overflow = defaults.overflow;
89
113
 
@@ -101,10 +125,45 @@ export function _parseIssueBudget(content) {
101
125
  else if (v.startsWith("'") && v.endsWith("'") && v.length >= 2) v = v.slice(1, -1);
102
126
 
103
127
  switch (k) {
104
- case 'max-per-session':
105
- // Non-negative integer only; a leading '-' fails \d+ default.
106
- if (/^\d+$/.test(v)) maxPerSession = parseInt(v, 10);
128
+ case 'max-per-session': {
129
+ // Non-negative integer, or the `N (type: M)` override form. A leading
130
+ // '-' fails \d+ inside `_coerceInteger`, which throws → default.
131
+ // Tolerant by contract: this parser runs on every Bash call via the
132
+ // hook, so a malformed value must fall back silently, never throw.
133
+ let coerced;
134
+ try {
135
+ coerced = _coerceInteger(new Map([['max-per-session', v]]), 'max-per-session', 12);
136
+ } catch {
137
+ // A malformed OVERRIDE (`7 (feature: x)`) must never restore the
138
+ // built-in 12: the operator wrote a base cap of 7, and discarding the
139
+ // whole value because the parenthesised part is unparseable LOOSENS
140
+ // the cap by three on one typo — silently, in the direction nobody
141
+ // would choose. Keep the base number, drop the override, and say so
142
+ // once (this branch is reachable only on a malformed value, so it
143
+ // cannot become per-Bash-call stderr spam).
144
+ const base = v.match(/^(\d+)\s*\(/);
145
+ if (base) {
146
+ const n = Number.parseInt(base[1], 10);
147
+ if (Number.isInteger(n) && n >= 0) {
148
+ maxPerSession = n;
149
+ maxPerSessionRaw = n;
150
+ process.stderr.write(
151
+ `⚠ issue-budget: malformed per-session override in 'max-per-session': '${v}' — ` +
152
+ `kept the base cap ${n} and dropped the override.\n`,
153
+ );
154
+ }
155
+ }
156
+ break;
157
+ }
158
+ if (typeof coerced === 'number') {
159
+ maxPerSession = coerced;
160
+ maxPerSessionRaw = coerced;
161
+ } else if (Number.isInteger(coerced?.default) && coerced.default >= 0) {
162
+ maxPerSession = coerced.default;
163
+ maxPerSessionRaw = coerced;
164
+ }
107
165
  break;
166
+ }
108
167
 
109
168
  case 'mode':
110
169
  if (MODES.includes(v.toLowerCase())) mode = v.toLowerCase();
@@ -118,6 +177,7 @@ export function _parseIssueBudget(content) {
118
177
 
119
178
  return {
120
179
  'max-per-session': maxPerSession,
180
+ 'max-per-session-raw': maxPerSessionRaw,
121
181
  mode,
122
182
  overflow,
123
183
  };
@@ -5,8 +5,9 @@
5
5
  * `owner.yaml`, the self-alias ledger) moves with it, so the precedence has to
6
6
  * live in exactly ONE place. Before #1223 three copies with three different
7
7
  * precedences existed: `owner-yaml.mjs`'s import-time homedir-only
8
- * `OWNER_YAML_PATH`, `owner-config-loader.mjs`'s XDG-only + untrimmed
9
- * `resolveOwnerConfigPath()`, and `host-identity.mjs`'s `_privateDir()`. The
8
+ * `OWNER_YAML_PATH`, the now-removed `owner-config-loader.mjs`'s XDG-only +
9
+ * untrimmed `resolveOwnerConfigPath()`, and `host-identity.mjs`'s
10
+ * `_privateDir()`. The
10
11
  * live consequence: `SO_CONFIG_HOME=<sandbox>` moved the alias ledger but NOT
11
12
  * owner.yaml, which kept being read from the operator's REAL home — the
12
13
  * CLAUDE.md (Codex CLI alias: AGENTS.md) "vault-dir resolves HOST-LOCALLY"
@@ -40,8 +40,8 @@ export const REMOTE_HOST_DEFAULTS = Object.freeze({
40
40
  * scripts/lib/config.mjs — impl / db / security / compliance / docs stay local.
41
41
  *
42
42
  * NOT the wave-role enum ("Impl-Core", "Quality", …): those are two different
43
- * enums and conflating them is the documented trap (see resolveApwCap's docstring
44
- * in wave-resource-gate.mjs). The wave→agent-mapping-role translation is
43
+ * enums and conflating them is the documented trap (see the MODE_BLIND_SESSION_TYPE
44
+ * note in wave-resource-gate.mjs). The wave→agent-mapping-role translation is
45
45
  * `OFFLOADABLE_WAVE_ROLES` in that same module.
46
46
  *
47
47
  * @type {readonly string[]}
@@ -86,6 +86,8 @@ export function validateSessionConfig(config) {
86
86
  });
87
87
  }
88
88
 
89
+ validateIssueBudget(config['issue-budget'], errors);
90
+
89
91
  validateVaultIntegration(config['vault-integration'], errors);
90
92
  validateVaultSync(config['vault-sync'], errors);
91
93
 
@@ -124,6 +126,83 @@ export function validateSessionConfig(config) {
124
126
  return { ok: true, config, warnings };
125
127
  }
126
128
 
129
+ /**
130
+ * Validate the `issue-budget` block's cap, including the per-session-type
131
+ * override form (#session-shape) — `12 (feature: 6)` parses to
132
+ * `{ default: 12, feature: 6 }` and lands on `max-per-session-raw`, while
133
+ * `max-per-session` stays the resolved NUMBER every consumer reads.
134
+ *
135
+ * Mirrors `validateAgentsPerWave` in shape, with two deliberate differences:
136
+ * the floor is 0 (`max-per-session: 0` is a valid "no issue may be created"
137
+ * setting) and the override key set is free-form, because the session-type
138
+ * vocabulary is owned by session-start, not by this schema.
139
+ *
140
+ * @param {unknown} block — `config['issue-budget']`
141
+ * @param {Array<{path: string, rule: string, message: string}>} errors
142
+ */
143
+ function validateIssueBudget(block, errors) {
144
+ if (block === undefined || block === null) return;
145
+ if (typeof block !== 'object' || Array.isArray(block)) {
146
+ errors.push({
147
+ path: 'issue-budget',
148
+ rule: 'object',
149
+ message: `issue-budget must be an object (got ${JSON.stringify(block)})`,
150
+ });
151
+ return;
152
+ }
153
+
154
+ const resolved = block['max-per-session'];
155
+ if (resolved !== undefined && (!Number.isInteger(resolved) || resolved < 0)) {
156
+ errors.push({
157
+ path: 'issue-budget.max-per-session',
158
+ rule: 'integer>=0',
159
+ message: `issue-budget.max-per-session must be an integer >= 0 (got ${JSON.stringify(resolved)})`,
160
+ });
161
+ }
162
+
163
+ const raw = block['max-per-session-raw'];
164
+ if (raw === undefined || raw === null) return;
165
+
166
+ if (Number.isInteger(raw)) {
167
+ if (raw < 0) {
168
+ errors.push({
169
+ path: 'issue-budget.max-per-session-raw',
170
+ rule: 'integer>=0',
171
+ message: `issue-budget.max-per-session-raw must be an integer >= 0 (got ${raw})`,
172
+ });
173
+ }
174
+ return;
175
+ }
176
+
177
+ if (typeof raw === 'object' && !Array.isArray(raw)) {
178
+ const defaultVal = raw['default'];
179
+ if (!Number.isInteger(defaultVal) || defaultVal < 0) {
180
+ errors.push({
181
+ path: 'issue-budget.max-per-session-raw.default',
182
+ rule: 'integer>=0',
183
+ message: `issue-budget.max-per-session-raw.default must be an integer >= 0 (got ${JSON.stringify(defaultVal)})`,
184
+ });
185
+ }
186
+ for (const [k, v] of Object.entries(raw)) {
187
+ if (k === 'default') continue;
188
+ if (!Number.isInteger(v) || v < 0) {
189
+ errors.push({
190
+ path: `issue-budget.max-per-session-raw.${k}`,
191
+ rule: 'integer>=0',
192
+ message: `issue-budget.max-per-session-raw.${k} must be an integer >= 0 (got ${JSON.stringify(v)})`,
193
+ });
194
+ }
195
+ }
196
+ return;
197
+ }
198
+
199
+ errors.push({
200
+ path: 'issue-budget.max-per-session-raw',
201
+ rule: 'integer-or-object',
202
+ message: `issue-budget.max-per-session-raw must be an integer >= 0 or an object with numeric entries (got ${JSON.stringify(raw)})`,
203
+ });
204
+ }
205
+
127
206
  function validateAgentsPerWave(value, errors) {
128
207
  if (Number.isInteger(value)) {
129
208
  if (value < 2) {
@@ -208,7 +208,18 @@ export function parseSessionConfig(mdContent, { hostPaths } = {}) {
208
208
  _getVal(kv, 'ecosystem-health', undefined) !== undefined
209
209
  ? _coerceBoolean(kv, 'ecosystem-health', false)
210
210
  : (_parseEcosystemHealthBlockEnabled(mdContent) ?? false);
211
- const discoveryOnClose = _coerceBoolean(kv, 'discovery-on-close', false);
211
+ // discovery-on-close alone accepts `auto` (= true) and defaults to true when
212
+ // absent: the template recommends `auto` and the 2026-07-29 doc decision made
213
+ // close-time discovery the default. Other booleans stay strict (#1340).
214
+ // Ceiling (BV-004): a one-key tri-state kept inline; revisit (move into
215
+ // coercers.mjs as a shared auto-boolean coercer) when a second key accepts `auto`.
216
+ const discoveryOnCloseRaw = (_getVal(kv, 'discovery-on-close', 'auto')).toLowerCase();
217
+ if (!['true', 'false', 'auto'].includes(discoveryOnCloseRaw)) {
218
+ throw new Error(
219
+ `config.mjs: invalid boolean for 'discovery-on-close': '${_getVal(kv, 'discovery-on-close', '')}' (expected true, false or auto)`,
220
+ );
221
+ }
222
+ const discoveryOnClose = discoveryOnCloseRaw !== 'false';
212
223
  const reasoningOutput = _coerceBoolean(kv, 'reasoning-output', false);
213
224
  const groundingCheck = _coerceBoolean(kv, 'grounding-check', true);
214
225
  const allowDestructiveOps = _coerceBoolean(kv, 'allow-destructive-ops', false);
@@ -37,6 +37,7 @@ import path from 'node:path';
37
37
  import { resolvePluginRoot } from '../common.mjs';
38
38
  import { readJsonlFile } from '../io.mjs';
39
39
  import { readCanonicalSessions } from '../sessions-canonical.mjs';
40
+ import { isCoordinatorDirectHousekeeping } from '../session-schema/filters.mjs';
40
41
  import { buildRunId, CURRENT_STANDARD_VERSION, VALID_MODEL_SOURCES } from './schema.mjs';
41
42
  import { resolveSession, computeWindow, findPeerOverlap } from './session-resolve.mjs';
42
43
 
@@ -250,7 +251,12 @@ function scoreGateHealth(ctx) {
250
251
  const totalWaves = typeof ctx.record.total_waves === 'number' ? ctx.record.total_waves : null;
251
252
  const wavesEmpty =
252
253
  totalWaves === 0 || !Array.isArray(ctx.record.waves) || ctx.record.waves.length === 0;
253
- if (wavesEmpty) {
254
+ // Since the metrics-collection writer rule (#1321), a session with no
255
+ // dispatched waves is recorded as ONE coordinator-direct Housekeeping wave
256
+ // with total_waves 1 — still "no waves ran" per rubric-v1's clarification.
257
+ // Only that wave SHAPE counts, never session_type: a housekeeping session
258
+ // that ran real waves stays cannot-determine (the pre-registered formula).
259
+ if (wavesEmpty || isCoordinatorDirectHousekeeping(ctx.record)) {
254
260
  return {
255
261
  id,
256
262
  method,
@@ -6,11 +6,12 @@
6
6
  * and the state-lock / staging-fence / session-lock blocks of session-lock.mjs).
7
7
  * Each copy independently re-implemented the same skeleton:
8
8
  *
9
- * 1. atomic create-or-fail via `linkSync(tmp, lock)` (POSIX mutex);
10
- * 2. on EEXIST read + parse the existing body;
11
- * 3. same-host + dead-PID (or unparseable) atomic override + WARN;
12
- * 4. live holder OR cross-hostpoll until a deadline;
13
- * 5. owner-guarded release.
9
+ * 1. exclusive acquisition guard via `linkSync(tmp, lock + '.acquire')`;
10
+ * 2. atomic create-or-fail via `linkSync(tmp, lock)` (POSIX mutex);
11
+ * 3. on EEXIST read + parse the existing body;
12
+ * 4. same-host + dead-PID (or unparseable) guarded override + WARN;
13
+ * 5. release the acquisition guard; live/cross-host holders wait until a deadline;
14
+ * 6. owner-guarded primary-lock release.
14
15
  *
15
16
  * This module is the single home for that skeleton. It is a near-PURE primitive:
16
17
  * it imports ONLY scripts/lib/io.mjs (for writeJsonAtomicSync), the two host
@@ -27,6 +28,20 @@
27
28
  * opinion of its own — every divergence between the five copies is a knob.
28
29
  * - Cross-host locks are NEVER auto-overridden (PSA-003 hard invariant).
29
30
  * - Overrides always go through writeJsonAtomicSync (tmp + renameSync).
31
+ * - #1284 serializes acquisition and takeover: a stale observation must not
32
+ * replace a different process's newly acquired lock.
33
+ * - #1285 serializes owner-guarded release with takeover: release runs its
34
+ * read → owner-match → unlink under the same `${lock}.acquire` guard, so an
35
+ * old holder's delayed release can never delete the lock a successor took
36
+ * over after the old lease expired.
37
+ *
38
+ * Lease semantics (`staleCheck: 'mtime' | 'heartbeat'`): lease expiry prevents
39
+ * a STUCK lock; it does NOT protect the critical section. After `staleMs` the
40
+ * next acquirer takes over whether or not the old holder is still running, and
41
+ * neither mode renews the lease (`heartbeat` ages from `acquiredAt`, `mtime`
42
+ * from the file's last write). A `not-owner` release under a lease mode means
43
+ * YOUR lease expired during your critical section and a successor may have run
44
+ * concurrently — keep critical sections well below `staleMs`.
30
45
  *
31
46
  * No external dependencies — Node 20+ stdlib + io.mjs only.
32
47
  */
@@ -246,6 +261,25 @@ function serializeBody(body, indent) {
246
261
  return JSON.stringify(body, null, indent) + '\n';
247
262
  }
248
263
 
264
+ /**
265
+ * Create the exclusive `${lockPath}.acquire` sibling guard that serializes every
266
+ * acquisition, takeover and owner-guarded release pass on `lockPath` (#1284,
267
+ * #1285). The caller owns the guard only when `ok` is true and must unlink it
268
+ * in a `finally`; an existing guard is NEVER replaced (see tryAcquireFileLock).
269
+ *
270
+ * @param {string} lockPath
271
+ * @param {string} tmpPrefix — tmp-file prefix; `.acquire` is appended.
272
+ * @returns {{ ok: true } | { ok: false, reason: 'exists' } | { ok: false, reason: 'fs-error', error: string }}
273
+ */
274
+ function createAcquireGuard(lockPath, tmpPrefix) {
275
+ return createExclusive(`${lockPath}.acquire`, {
276
+ pid: process.pid,
277
+ host: os.hostname(),
278
+ acquiredAt: new Date().toISOString(),
279
+ kind: 'acquisition-guard',
280
+ }, { indent: 2, tmpPrefix: `${tmpPrefix}.acquire` });
281
+ }
282
+
249
283
  // ---------------------------------------------------------------------------
250
284
  // Exported primitive
251
285
  // ---------------------------------------------------------------------------
@@ -258,6 +292,26 @@ function serializeBody(body, indent) {
258
292
  * overridden via writeJsonAtomicSync and a WARN is emitted. A live holder or a
259
293
  * cross-host body returns `{ acquired: false, reason: 'held' }`.
260
294
  *
295
+ * Every acquisition pass owns the exclusive sibling `${lockPath}.acquire`
296
+ * from before create/read through any takeover. This prevents a waiter from
297
+ * reading an old holder, observing its exit, then replacing a newer holder.
298
+ * Owner-guarded releases (releaseFileLock) take the SAME guard for their
299
+ * read → owner-match → unlink pass (#1285), so a release can never unlink a
300
+ * lock that a takeover replaced after the releaser read its own body.
301
+ * All contenders must use this guarded implementation; legacy writers that
302
+ * ignore the sibling guard cannot participate safely in the same protocol.
303
+ *
304
+ * Crash-liveness tradeoff: the guard is held only for one synchronous pass
305
+ * (acquire or release), not for the caller's critical section. If its owner
306
+ * dies during a pass or cleanup fails, the guard remains: acquire attempts
307
+ * return `held` immediately (withFileLock's normal deadline bounds polling) and
308
+ * owner-guarded releases return `busy` after their bounded retry, leaving the
309
+ * primary lock to its stale policy. Even a dead-PID or malformed
310
+ * guard is NEVER stolen, because stale-guard replacement would repeat the same
311
+ * race. Recovery requires quiescing every process that can acquire this lock,
312
+ * verifying the guard is abandoned, then explicitly removing only that sibling.
313
+ * The primary lock retains its existing stale/host/owner protections.
314
+ *
261
315
  * The `signalVanished` knob reproduces memory-proposals/store.mjs's distinct
262
316
  * third state: when the lock file disappears between the EEXIST and the read
263
317
  * (concurrent release race), `{ acquired: false, reason: 'vanished' }` is
@@ -284,6 +338,25 @@ function serializeBody(body, indent) {
284
338
  * | { acquired: false, reason: 'held'|'vanished'|'fs-error', existing?: object|null, error?: string }}
285
339
  */
286
340
  export function tryAcquireFileLock(lockPath, opts = {}) {
341
+ const guardPath = `${lockPath}.acquire`;
342
+ const guard = createAcquireGuard(lockPath, opts.tmpPrefix ?? '.file.lock');
343
+ if (!guard.ok) {
344
+ return guard.reason === 'exists'
345
+ ? { acquired: false, reason: 'held', existing: null }
346
+ : { acquired: false, reason: 'fs-error', error: guard.error };
347
+ }
348
+
349
+ try {
350
+ return tryAcquireGuardedFileLock(lockPath, opts);
351
+ } finally {
352
+ // Only this pass can own/remove this guard; no acquisition path replaces
353
+ // it. An unlink failure deliberately leaves subsequent attempts blocked.
354
+ try { fs.unlinkSync(guardPath); } catch { /* fail closed; see recovery above */ }
355
+ }
356
+ }
357
+
358
+ /** Caller must hold the acquisition guard throughout this synchronous pass. */
359
+ function tryAcquireGuardedFileLock(lockPath, opts) {
287
360
  const {
288
361
  staleCheck = 'pid',
289
362
  staleMs,
@@ -376,21 +449,35 @@ export function tryAcquireFileLock(lockPath, opts = {}) {
376
449
  * match. This reproduces the agent-status / state-lock / staging-fence owner
377
450
  * guard (PSA-003: never delete a lock another holder owns).
378
451
  *
452
+ * Guaranteed (#1285): the owner-guarded read → owner-match → unlink runs under
453
+ * the same `${lockPath}.acquire` guard as acquisition and takeover, so an old
454
+ * holder's release can never delete a replacement's lock. Without the guard, a
455
+ * holder that read its own body, then paused while its lease expired and a
456
+ * successor took over, unlinked the successor's lock and let a third process
457
+ * acquire beside a live holder. Under `staleCheck: 'mtime' | 'heartbeat'`, a
458
+ * `not-owner` answer means your lease expired during your critical section —
459
+ * see the module header § Lease semantics. The guard is never stolen: when it
460
+ * stays taken past the budget the release returns `busy` and leaves the lock
461
+ * untouched for its stale policy to reclaim.
462
+ *
379
463
  * With `ownerGuard: false` the file is unlinked unconditionally, ENOENT
380
464
  * ignored — reproducing memory-proposals/store.mjs's `releaseProposalsLock`.
465
+ * That path takes no guard (its callers never run a takeover).
381
466
  *
382
467
  * @param {string} lockPath
383
468
  * @param {object} [opts]
384
469
  * @param {string} [opts.holder] — expected holder for the owner guard.
385
470
  * @param {boolean} [opts.ownerGuard=true]
471
+ * @param {number} [opts.guardTimeoutMs=1000] — owner-guarded path only: how long
472
+ * to retry for the `.acquire` guard before answering `busy`.
386
473
  * @param {(errToken: string) => void} [opts.warn] — sink for unexpected fs
387
474
  * errors on the ownerGuard:false path. Receives the raw
388
475
  * `err.code ?? err.message` token; the call-site formats the message.
389
476
  * @returns {{ ok: true }
390
- * | { ok: false, reason: 'not-found'|'not-owner'|'fs-error', error?: string }}
477
+ * | { ok: false, reason: 'not-found'|'not-owner'|'busy'|'fs-error', error?: string }}
391
478
  */
392
479
  export function releaseFileLock(lockPath, opts = {}) {
393
- const { holder, ownerGuard = true, warn } = opts;
480
+ const { holder, ownerGuard = true, warn, guardTimeoutMs } = opts;
394
481
 
395
482
  if (ownerGuard === false) {
396
483
  // Unconditional unlink; ENOENT ignored. Other fs errors surfaced via warn.
@@ -406,6 +493,53 @@ export function releaseFileLock(lockPath, opts = {}) {
406
493
  }
407
494
  }
408
495
 
496
+ // Nothing to release → answer without the guard. Taking it would create the
497
+ // lock's directory (createExclusive mkdirs) and contend with live acquirers
498
+ // for a pass that can never unlink anything. Only ENOENT short-cuts; every
499
+ // other stat error falls through to the guarded read, which reports it.
500
+ try {
501
+ fs.statSync(lockPath);
502
+ } catch (err) {
503
+ if (err.code === 'ENOENT') return { ok: false, reason: 'not-found' };
504
+ }
505
+
506
+ const budget = typeof guardTimeoutMs === 'number' && guardTimeoutMs >= 0
507
+ ? guardTimeoutMs
508
+ : DEFAULT_RELEASE_GUARD_MS;
509
+ const deadline = Date.now() + budget;
510
+ for (;;) {
511
+ const guard = createAcquireGuard(lockPath, '.file.lock');
512
+ if (guard.ok) break;
513
+ if (guard.reason === 'fs-error') return { ok: false, reason: 'fs-error', error: guard.error };
514
+ // Never steal the guard (same rule as acquisition): wait, then give up.
515
+ if (Date.now() >= deadline) return { ok: false, reason: 'busy' };
516
+ sleepSync(RELEASE_GUARD_POLL_MS);
517
+ }
518
+
519
+ try {
520
+ return releaseGuardedFileLock(lockPath, holder);
521
+ } finally {
522
+ // Only this pass owns the guard; an unlink failure fails closed exactly
523
+ // like the acquisition path (see tryAcquireFileLock § recovery).
524
+ try { fs.unlinkSync(`${lockPath}.acquire`); } catch { /* fail closed */ }
525
+ }
526
+ }
527
+
528
+ /**
529
+ * Budget for an owner-guarded release to obtain the `.acquire` guard.
530
+ *
531
+ * CEILING (BV-004): a live contender holds the guard for one synchronous pass
532
+ * (a handful of fs syscalls), so the wait is normally sub-millisecond; the full
533
+ * 1000 ms is only spent on an ABANDONED guard (owner crashed mid-pass), where
534
+ * waiting longer cannot help. REVISIT if a `busy` release is ever observed on a
535
+ * host without a crash.
536
+ */
537
+ const DEFAULT_RELEASE_GUARD_MS = 1000;
538
+ /** Poll cadence while a release waits for the `.acquire` guard. */
539
+ const RELEASE_GUARD_POLL_MS = 5;
540
+
541
+ /** Caller must hold the acquisition guard throughout this synchronous pass. */
542
+ function releaseGuardedFileLock(lockPath, holder) {
409
543
  let raw;
410
544
  try {
411
545
  raw = fs.readFileSync(lockPath, 'utf8');
@@ -481,6 +615,11 @@ function delay(ms) {
481
615
  * @param {boolean} [opts.sync=false] — true → synchronous busy-wait poll +
482
616
  * synchronous fn (agent-status variant). false → async poll.
483
617
  * @param {boolean} [opts.ownerGuard=true] — passed to releaseFileLock.
618
+ * @param {(release: ReturnType<typeof releaseFileLock>) => void} [opts.onRelease]
619
+ * — receives the releaseFileLock result from the finally (#1336). The
620
+ * only reader of `busy` / `not-owner` (a lease that expired mid-`fn`);
621
+ * the return value of withFileLock is unchanged. A throwing callback is
622
+ * swallowed so it can mask neither `value` nor an error thrown by `fn`.
484
623
  * @param {...*} [opts.acquireOpts] — remaining keys forwarded to tryAcquireFileLock.
485
624
  * @returns {Promise<{ ok: true, value: T }
486
625
  * | { ok: false, reason: 'timeout'|'fs-error', error?: string, existing?: object|null }>}
@@ -492,6 +631,7 @@ export async function withFileLock(lockPath, fn, opts = {}) {
492
631
  pollMs = 100,
493
632
  sync = false,
494
633
  ownerGuard = true,
634
+ onRelease,
495
635
  ...acquireOpts
496
636
  } = opts;
497
637
 
@@ -525,6 +665,9 @@ export async function withFileLock(lockPath, fn, opts = {}) {
525
665
  const value = await fn(acquired.body);
526
666
  return { ok: true, value };
527
667
  } finally {
528
- releaseFileLock(lockPath, { holder: releaseHolder, ownerGuard });
668
+ const released = releaseFileLock(lockPath, { holder: releaseHolder, ownerGuard });
669
+ if (typeof onRelease === 'function') {
670
+ try { onRelease(released); } catch { /* diagnostic sink — never masks fn */ }
671
+ }
529
672
  }
530
673
  }