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
@@ -186,8 +186,9 @@ export const CRITERIA = Object.freeze({
186
186
  appliesTo: 'block',
187
187
  hurdle: 'H1',
188
188
  measures:
189
- 'Zeichen der Kopfzeile. Über 12 schneidet das Tool selbst ab das ist keine ' +
190
- 'Stilfrage, sondern eine harte Grenze.',
189
+ 'Zeichen der Kopfzeile. 12 ist das von der Tool-Beschreibung genannte Budget ' +
190
+ '(`max 12 chars`) — in VORLAGEN eine harte Grenze, weil Kürzen dort nichts ' +
191
+ 'kostet; zur Laufzeit nur beratend (siehe `evidence` an HURDLES.H1).',
191
192
  }),
192
193
  K6: Object.freeze({
193
194
  id: 'K6',
@@ -251,7 +252,13 @@ export const HURDLES = Object.freeze({
251
252
  criterion: 'K5',
252
253
  evidence:
253
254
  'Gemessen 2026-08-22: 26 von 42 Kopfzeilen-Literalen reißen diese Grenze (62 %), ' +
254
- 'Spitzenwert 54 Zeichen. Das Tool schneidet ab der Operator sieht den Rest nie.',
255
+ 'Spitzenwert 54 Zeichen. Die 12 ist die Stilangabe der Tool-Beschreibung ' +
256
+ '(`max 12 chars`), KEINE erzwungene Grenze: im Bundle 2.1.268 steht die Zahl nur ' +
257
+ 'in ebendieser Beschreibung, es gibt kein `.max(12)` im Zod-Schema und keinen ' +
258
+ 'Render-Pfad, der sie liest — 125 längere Kopfzeilen wurden vom Tool angenommen ' +
259
+ 'und beantwortet (gemessen 2026-09-11). Deshalb gilt H1 nur für VORLAGEN, wo ein ' +
260
+ 'Autor kostenlos kürzen kann; zur Laufzeit meldet der Hook sie und blockt nicht ' +
261
+ '(siehe BLOCKING_HURDLES in hooks/pre-auq-clarity.mjs).',
255
262
  }),
256
263
  H2: Object.freeze({
257
264
  id: 'H2',
@@ -531,7 +531,9 @@ async function checkGitlab(repoRoot, now, deps = {}) {
531
531
  return null;
532
532
  }
533
533
 
534
- const currentSha = await getHeadSha(repoRoot, deps);
534
+ // #1332: an explicit `deps.sha` (validated full hex SHA, see checkCiStatus)
535
+ // replaces the local HEAD lookup — the caller asks about a NAMED commit.
536
+ const currentSha = deps.sha ?? (await getHeadSha(repoRoot, deps));
535
537
  const apiDeps = { ...deps, repoHost: project.host };
536
538
  const projectPath = `projects/${project.encodedProjectPath}`;
537
539
  // `'array'` is load-bearing, not decoration: before it, a `glab api` that
@@ -700,8 +702,11 @@ async function checkGitlab(repoRoot, now, deps = {}) {
700
702
  * (cross-family guard, unsafe-argv guard), and `normalizeGithubSpec` falls
701
703
  * back to the raw URL on an unrecognised remote shape.
702
704
  *
705
+ * `deps.sha` (#1332): when set, the check-runs query names THAT commit
706
+ * instead of the literal `HEAD` ref.
707
+ *
703
708
  * @param {string} repoRoot
704
- * @param {{ execFile?: Function, timeoutMs?: number, repoSpec?: string, repoHost?: string }} deps
709
+ * @param {{ execFile?: Function, timeoutMs?: number, repoSpec?: string, repoHost?: string, sha?: string }} deps
705
710
  * @returns {Promise<object|null>}
706
711
  */
707
712
  async function checkGithub(repoRoot, deps = {}) {
@@ -726,7 +731,7 @@ async function checkGithub(repoRoot, deps = {}) {
726
731
  );
727
732
 
728
733
  const data = await ghApi(
729
- `repos/${nameWithOwner}/commits/HEAD/check-runs`,
734
+ `repos/${nameWithOwner}/commits/${deps.sha ?? 'HEAD'}/check-runs`,
730
735
  repoRoot,
731
736
  deps,
732
737
  'object',
@@ -807,7 +812,12 @@ async function checkGithub(repoRoot, deps = {}) {
807
812
  * vcs?: 'gitlab'|'github',
808
813
  * timeoutMs?: number,
809
814
  * now?: number,
810
- * }} opts
815
+ * sha?: string,
816
+ * }} opts `sha` (#1332): query the verdict for THIS commit instead of the
817
+ * local HEAD. Must be a full hex object id (40 or 64 chars) — it is matched
818
+ * against GitLab's full pipeline SHAs and interpolated into a `gh api` path,
819
+ * so anything else is refused as `query-failed` before any spawn. Absent →
820
+ * behaviour identical to before the option existed.
811
821
  * @param {{
812
822
  * execFile?: Function,
813
823
  * resolveRepoSpec?: (opts: { repoRoot: string, vcs: 'gitlab'|'github' }) => string|undefined,
@@ -842,8 +852,21 @@ export async function checkCiStatus(opts = {}, deps = {}) {
842
852
  vcs: forcedVcs,
843
853
  timeoutMs = DEFAULT_TIMEOUT_MS,
844
854
  now = Date.now(),
855
+ sha: rawSha,
845
856
  } = opts;
846
857
 
858
+ // Validate at the boundary (#1332): the value reaches an API path and an
859
+ // equality match against full SHAs. A short or non-hex SHA would silently
860
+ // match nothing (GitLab) or re-route the request path (GitHub).
861
+ let sha;
862
+ if (rawSha !== undefined) {
863
+ const candidate = typeof rawSha === 'string' ? rawSha.trim().toLowerCase() : '';
864
+ if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(candidate)) {
865
+ return degradedResult('query-failed', 'sha must be a full hex commit id');
866
+ }
867
+ sha = candidate;
868
+ }
869
+
847
870
  const execFileDep = deps.execFile
848
871
  ? promisify(deps.execFile)
849
872
  : execFileAsync;
@@ -914,13 +937,13 @@ export async function checkCiStatus(opts = {}, deps = {}) {
914
937
  'a GitLab remote was detected but its host/project path could not be derived',
915
938
  );
916
939
  }
917
- return await checkGitlab(repoRoot, now, { ...depsWithExec, gitlabProject });
940
+ return await checkGitlab(repoRoot, now, { ...depsWithExec, gitlabProject, sha });
918
941
  }
919
942
 
920
943
  if (vcs === 'github') {
921
944
  const repoSpec = resolveRepoSpecDep({ repoRoot, vcs });
922
945
  const repoHost = resolveRepoHostDep({ repoRoot, vcs });
923
- return await checkGithub(repoRoot, { ...depsWithExec, repoSpec, repoHost });
946
+ return await checkGithub(repoRoot, { ...depsWithExec, repoSpec, repoHost, sha });
924
947
  }
925
948
 
926
949
  // Unknown VCS value — silent no-op.
@@ -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
  }
@@ -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,
@@ -30,6 +30,18 @@
30
30
  * - Overrides always go through writeJsonAtomicSync (tmp + renameSync).
31
31
  * - #1284 serializes acquisition and takeover: a stale observation must not
32
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`.
33
45
  *
34
46
  * No external dependencies — Node 20+ stdlib + io.mjs only.
35
47
  */
@@ -249,6 +261,25 @@ function serializeBody(body, indent) {
249
261
  return JSON.stringify(body, null, indent) + '\n';
250
262
  }
251
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
+
252
283
  // ---------------------------------------------------------------------------
253
284
  // Exported primitive
254
285
  // ---------------------------------------------------------------------------
@@ -264,13 +295,18 @@ function serializeBody(body, indent) {
264
295
  * Every acquisition pass owns the exclusive sibling `${lockPath}.acquire`
265
296
  * from before create/read through any takeover. This prevents a waiter from
266
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.
267
301
  * All contenders must use this guarded implementation; legacy writers that
268
302
  * ignore the sibling guard cannot participate safely in the same protocol.
269
303
  *
270
- * Crash-liveness tradeoff: the guard is held only for this synchronous pass,
271
- * not for the caller's critical section. If its owner dies during the pass or
272
- * cleanup fails, the guard remains and attempts return `held` immediately;
273
- * withFileLock's normal deadline bounds polling. Even a dead-PID or malformed
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
274
310
  * guard is NEVER stolen, because stale-guard replacement would repeat the same
275
311
  * race. Recovery requires quiescing every process that can acquire this lock,
276
312
  * verifying the guard is abandoned, then explicitly removing only that sibling.
@@ -303,12 +339,7 @@ function serializeBody(body, indent) {
303
339
  */
304
340
  export function tryAcquireFileLock(lockPath, opts = {}) {
305
341
  const guardPath = `${lockPath}.acquire`;
306
- const guard = createExclusive(guardPath, {
307
- pid: process.pid,
308
- host: os.hostname(),
309
- acquiredAt: new Date().toISOString(),
310
- kind: 'acquisition-guard',
311
- }, { indent: 2, tmpPrefix: `${opts.tmpPrefix ?? '.file.lock'}.acquire` });
342
+ const guard = createAcquireGuard(lockPath, opts.tmpPrefix ?? '.file.lock');
312
343
  if (!guard.ok) {
313
344
  return guard.reason === 'exists'
314
345
  ? { acquired: false, reason: 'held', existing: null }
@@ -418,21 +449,35 @@ function tryAcquireGuardedFileLock(lockPath, opts) {
418
449
  * match. This reproduces the agent-status / state-lock / staging-fence owner
419
450
  * guard (PSA-003: never delete a lock another holder owns).
420
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
+ *
421
463
  * With `ownerGuard: false` the file is unlinked unconditionally, ENOENT
422
464
  * ignored — reproducing memory-proposals/store.mjs's `releaseProposalsLock`.
465
+ * That path takes no guard (its callers never run a takeover).
423
466
  *
424
467
  * @param {string} lockPath
425
468
  * @param {object} [opts]
426
469
  * @param {string} [opts.holder] — expected holder for the owner guard.
427
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`.
428
473
  * @param {(errToken: string) => void} [opts.warn] — sink for unexpected fs
429
474
  * errors on the ownerGuard:false path. Receives the raw
430
475
  * `err.code ?? err.message` token; the call-site formats the message.
431
476
  * @returns {{ ok: true }
432
- * | { ok: false, reason: 'not-found'|'not-owner'|'fs-error', error?: string }}
477
+ * | { ok: false, reason: 'not-found'|'not-owner'|'busy'|'fs-error', error?: string }}
433
478
  */
434
479
  export function releaseFileLock(lockPath, opts = {}) {
435
- const { holder, ownerGuard = true, warn } = opts;
480
+ const { holder, ownerGuard = true, warn, guardTimeoutMs } = opts;
436
481
 
437
482
  if (ownerGuard === false) {
438
483
  // Unconditional unlink; ENOENT ignored. Other fs errors surfaced via warn.
@@ -448,6 +493,53 @@ export function releaseFileLock(lockPath, opts = {}) {
448
493
  }
449
494
  }
450
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) {
451
543
  let raw;
452
544
  try {
453
545
  raw = fs.readFileSync(lockPath, 'utf8');
@@ -523,6 +615,11 @@ function delay(ms) {
523
615
  * @param {boolean} [opts.sync=false] — true → synchronous busy-wait poll +
524
616
  * synchronous fn (agent-status variant). false → async poll.
525
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`.
526
623
  * @param {...*} [opts.acquireOpts] — remaining keys forwarded to tryAcquireFileLock.
527
624
  * @returns {Promise<{ ok: true, value: T }
528
625
  * | { ok: false, reason: 'timeout'|'fs-error', error?: string, existing?: object|null }>}
@@ -534,6 +631,7 @@ export async function withFileLock(lockPath, fn, opts = {}) {
534
631
  pollMs = 100,
535
632
  sync = false,
536
633
  ownerGuard = true,
634
+ onRelease,
537
635
  ...acquireOpts
538
636
  } = opts;
539
637
 
@@ -567,6 +665,9 @@ export async function withFileLock(lockPath, fn, opts = {}) {
567
665
  const value = await fn(acquired.body);
568
666
  return { ok: true, value };
569
667
  } finally {
570
- 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
+ }
571
672
  }
572
673
  }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * git-porcelain.mjs — the ONE parser for `git status --porcelain -z` output.
3
+ *
4
+ * Three consumers used to hand-roll this: `quality-gate.mjs` (hardened),
5
+ * `project-hygiene.mjs` (inline, R/C-aware) and `pre-dispatch-check.mjs`
6
+ * (non-`-z`, hand-unquoting only `\"` and `\\`, so any path carrying a TAB or a
7
+ * non-ASCII byte resolved to a NON-EXISTENT path — #1354). They now share this
8
+ * module.
9
+ *
10
+ * `-z` is not a convenience flag here — it is the only shape of this command
11
+ * whose paths are unambiguous. Measured 2026-08-23 (git 2.53.0) on a fixture
12
+ * carrying a space, a non-ASCII name, a literal `"` and a rename:
13
+ *
14
+ * ```
15
+ * git status --porcelain git status --porcelain -z
16
+ * ------------------------------ ---------------------------------
17
+ * M "scripts/lib/old name.mjs" M scripts/lib/old name.mjs
18
+ * M "scripts/lib/\303\274ml.mjs" M scripts/lib/üml.mjs
19
+ * ?? "scripts/lib/quo\"te.mjs" ?? scripts/lib/quo"te.mjs
20
+ * R old.mjs -> new.mjs R new.mjs \0 old.mjs
21
+ * ```
22
+ *
23
+ * The non-`-z` form C-quotes any path containing a space, a `"` or — under the
24
+ * default `core.quotePath=true` — a non-ASCII byte. `-c core.quotePath=false`
25
+ * repairs only the non-ASCII third of that (measured: the space and the `"`
26
+ * stayed quoted). A field-splitting parser over the non-`-z` form fails three
27
+ * separate ways on one input — measured `awk '{print $2}'` output for the four
28
+ * lines above: `"scripts/lib/old` (truncated at the space), the undecoded
29
+ * `\303\274` octal escape, and `old.mjs` (the PRE-rename path) for the `R`
30
+ * line. `-z` emits every path verbatim, so there is no unquoting step to get
31
+ * wrong.
32
+ *
33
+ * Rename/copy entries carry their ORIGINAL path as the NEXT NUL field, with NO
34
+ * `XY ` prefix. Consuming that extra field is mandatory, not optional: a naive
35
+ * per-field `slice(3)` would emit `.mjs`-suffixed garbage (`d.mjs` for
36
+ * `old.mjs`) as if it were a real path. `R`/`C` are checked in BOTH status
37
+ * columns because git-status(1) documents `R `/`C ` (renamed/copied in index)
38
+ * as well as ` R`/` C` (renamed/copied in work tree).
39
+ *
40
+ * Ceiling (BV-004): this parses **porcelain v1** (`--porcelain` / `--porcelain=v1`)
41
+ * with `-z`, as emitted by git ≥ 2.x — the `XY <path>` entry shape plus the
42
+ * bare original-path field for `R`/`C`. It also accepts the `!!` entries that
43
+ * `--ignored` adds and the individual-file entries `-uall` produces. It does
44
+ * NOT parse porcelain **v2** (`--porcelain=v2`), whose entries are
45
+ * space-delimited records beginning with `1`/`2`/`u`/`?`/`!`, and it does not
46
+ * decode C-quoting, because `-z` never emits any. Revisit trigger: the first
47
+ * caller that needs v2's per-entry metadata (mode bits, object ids, submodule
48
+ * state) or that must parse output produced without `-z`.
49
+ */
50
+
51
+ /**
52
+ * @typedef {Object} PorcelainEntry
53
+ * @property {string} x first status column (index)
54
+ * @property {string} y second status column (work tree)
55
+ * @property {string} status both columns, e.g. `??`, `!!`, `R `, ` M`
56
+ * @property {string} path path as git emitted it (verbatim, never quoted)
57
+ * @property {string|null} original source path for a rename/copy, else `null`
58
+ */
59
+
60
+ /**
61
+ * Parse `git status --porcelain -z` stdout into structured entries.
62
+ *
63
+ * Malformed fields are DROPPED rather than guessed at: `XY P` is the shortest
64
+ * well-formed entry, so anything shorter — including the empty trailing field
65
+ * `split` always produces — is not an entry header, and the `[2] === ' '`
66
+ * check rejects a stray original-path field that a malformed stream could
67
+ * leave unconsumed.
68
+ *
69
+ * @param {string} raw — raw stdout of `git status --porcelain -z …`.
70
+ * @returns {PorcelainEntry[]} entries, in git's emission order.
71
+ */
72
+ export function parsePorcelainEntries(raw) {
73
+ const fields = String(raw ?? '').split('\0');
74
+ /** @type {PorcelainEntry[]} */
75
+ const entries = [];
76
+ for (let i = 0; i < fields.length; i += 1) {
77
+ const entry = fields[i];
78
+ if (typeof entry !== 'string' || entry.length < 4 || entry[2] !== ' ') continue;
79
+ const x = entry[0];
80
+ const y = entry[1];
81
+ let original = null;
82
+ if (x === 'R' || x === 'C' || y === 'R' || y === 'C') {
83
+ i += 1;
84
+ const src = fields[i];
85
+ if (typeof src === 'string' && src) original = src;
86
+ }
87
+ entries.push({ x, y, status: `${x}${y}`, path: entry.slice(3), original });
88
+ }
89
+ return entries;
90
+ }
91
+
92
+ /**
93
+ * Parse `git status --porcelain -z` stdout into repo-root-relative paths.
94
+ *
95
+ * Both halves of a rename/copy are kept — a file moved OUT of a watched
96
+ * directory is as much a touch as one moved in, and a fixer needs the old path
97
+ * to make sense of the new one.
98
+ *
99
+ * Untracked DIRECTORIES are not a case this function has to handle when the
100
+ * caller passes `-uall`, which expands them to individual files (measured:
101
+ * `?? nd/` became `?? nd/a.mjs` + `?? nd/b.mjs`).
102
+ *
103
+ * @param {string} raw — raw stdout of `git status --porcelain -z …`.
104
+ * @returns {string[]} repo-root-relative paths, in git's emission order.
105
+ */
106
+ export function parsePorcelainZ(raw) {
107
+ const paths = [];
108
+ for (const entry of parsePorcelainEntries(raw)) {
109
+ if (entry.path) paths.push(entry.path);
110
+ if (entry.original) paths.push(entry.original);
111
+ }
112
+ return paths;
113
+ }