@dzhechkov/harness-core 0.4.5 → 0.5.1

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 (141) hide show
  1. package/.dz-manifest.json +489 -105
  2. package/README.md +116 -5
  3. package/dist/agents-policy.d.ts +67 -0
  4. package/dist/agents-policy.d.ts.map +1 -0
  5. package/dist/agents-policy.js +258 -0
  6. package/dist/agents-policy.js.map +1 -0
  7. package/dist/codex-hooks-assets.d.ts +47 -0
  8. package/dist/codex-hooks-assets.d.ts.map +1 -0
  9. package/dist/codex-hooks-assets.js +324 -0
  10. package/dist/codex-hooks-assets.js.map +1 -0
  11. package/dist/codex-hooks-verify.d.ts +95 -0
  12. package/dist/codex-hooks-verify.d.ts.map +1 -0
  13. package/dist/codex-hooks-verify.js +169 -0
  14. package/dist/codex-hooks-verify.js.map +1 -0
  15. package/dist/codex-hooks.d.ts +341 -0
  16. package/dist/codex-hooks.d.ts.map +1 -0
  17. package/dist/codex-hooks.js +541 -0
  18. package/dist/codex-hooks.js.map +1 -0
  19. package/dist/discrimination-gate.d.ts +88 -15
  20. package/dist/discrimination-gate.d.ts.map +1 -1
  21. package/dist/discrimination-gate.js +343 -51
  22. package/dist/discrimination-gate.js.map +1 -1
  23. package/dist/feature-adr-checkpoints.d.ts +22 -0
  24. package/dist/feature-adr-checkpoints.d.ts.map +1 -1
  25. package/dist/feature-adr-checkpoints.js +42 -0
  26. package/dist/feature-adr-checkpoints.js.map +1 -1
  27. package/dist/feature-adr-routing.d.ts +218 -5
  28. package/dist/feature-adr-routing.d.ts.map +1 -1
  29. package/dist/feature-adr-routing.js +583 -54
  30. package/dist/feature-adr-routing.js.map +1 -1
  31. package/dist/guard.d.ts +13 -0
  32. package/dist/guard.d.ts.map +1 -1
  33. package/dist/guard.js +25 -1
  34. package/dist/guard.js.map +1 -1
  35. package/dist/index.d.ts +23 -7
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +37 -4
  38. package/dist/index.js.map +1 -1
  39. package/dist/loop-blobs.generated.d.ts +1 -1
  40. package/dist/loop-blobs.generated.d.ts.map +1 -1
  41. package/dist/loop-blobs.generated.js +12 -3
  42. package/dist/loop-blobs.generated.js.map +1 -1
  43. package/dist/loop-plan.d.ts +70 -0
  44. package/dist/loop-plan.d.ts.map +1 -1
  45. package/dist/loop-plan.js +103 -0
  46. package/dist/loop-plan.js.map +1 -1
  47. package/dist/loop-render.d.ts.map +1 -1
  48. package/dist/loop-render.js +37 -129
  49. package/dist/loop-render.js.map +1 -1
  50. package/dist/loop-run-semantics.d.ts +130 -0
  51. package/dist/loop-run-semantics.d.ts.map +1 -0
  52. package/dist/loop-run-semantics.js +257 -0
  53. package/dist/loop-run-semantics.js.map +1 -0
  54. package/dist/loop-trace.d.ts +31 -2
  55. package/dist/loop-trace.d.ts.map +1 -1
  56. package/dist/loop-trace.js +89 -12
  57. package/dist/loop-trace.js.map +1 -1
  58. package/dist/managed-hooks.d.ts +86 -0
  59. package/dist/managed-hooks.d.ts.map +1 -0
  60. package/dist/managed-hooks.js +101 -0
  61. package/dist/managed-hooks.js.map +1 -0
  62. package/dist/mutation-gate.d.ts +14 -0
  63. package/dist/mutation-gate.d.ts.map +1 -1
  64. package/dist/mutation-gate.js +25 -2
  65. package/dist/mutation-gate.js.map +1 -1
  66. package/dist/named-lock.d.ts +57 -0
  67. package/dist/named-lock.d.ts.map +1 -0
  68. package/dist/named-lock.js +247 -0
  69. package/dist/named-lock.js.map +1 -0
  70. package/dist/operations.d.ts +229 -0
  71. package/dist/operations.d.ts.map +1 -1
  72. package/dist/operations.js +880 -26
  73. package/dist/operations.js.map +1 -1
  74. package/dist/parity.d.ts +106 -1
  75. package/dist/parity.d.ts.map +1 -1
  76. package/dist/parity.js +209 -5
  77. package/dist/parity.js.map +1 -1
  78. package/dist/qe-bridge.d.ts +291 -0
  79. package/dist/qe-bridge.d.ts.map +1 -0
  80. package/dist/qe-bridge.js +538 -0
  81. package/dist/qe-bridge.js.map +1 -0
  82. package/dist/recall-usage.d.ts +53 -0
  83. package/dist/recall-usage.d.ts.map +1 -1
  84. package/dist/recall-usage.js +125 -2
  85. package/dist/recall-usage.js.map +1 -1
  86. package/dist/score.d.ts.map +1 -1
  87. package/dist/score.js +43 -9
  88. package/dist/score.js.map +1 -1
  89. package/dist/setup.d.ts.map +1 -1
  90. package/dist/setup.js +14 -26
  91. package/dist/setup.js.map +1 -1
  92. package/dist/shell-veto-policy.d.ts +53 -0
  93. package/dist/shell-veto-policy.d.ts.map +1 -0
  94. package/dist/shell-veto-policy.js +103 -0
  95. package/dist/shell-veto-policy.js.map +1 -0
  96. package/dist/skills.d.ts +86 -1
  97. package/dist/skills.d.ts.map +1 -1
  98. package/dist/skills.js +116 -1
  99. package/dist/skills.js.map +1 -1
  100. package/dist/targets.d.ts +75 -0
  101. package/dist/targets.d.ts.map +1 -1
  102. package/dist/targets.js +160 -0
  103. package/dist/targets.js.map +1 -1
  104. package/dist/workflow-run-dispatch.d.ts +230 -0
  105. package/dist/workflow-run-dispatch.d.ts.map +1 -0
  106. package/dist/workflow-run-dispatch.js +363 -0
  107. package/dist/workflow-run-dispatch.js.map +1 -0
  108. package/dist/workflow-run.d.ts +498 -0
  109. package/dist/workflow-run.d.ts.map +1 -0
  110. package/dist/workflow-run.js +1359 -0
  111. package/dist/workflow-run.js.map +1 -0
  112. package/package.json +6 -5
  113. package/sbom.json +1099 -139
  114. package/src/agents-policy.ts +338 -0
  115. package/src/codex-hooks-assets.ts +328 -0
  116. package/src/codex-hooks-verify.ts +237 -0
  117. package/src/codex-hooks.ts +723 -0
  118. package/src/discrimination-gate.ts +456 -58
  119. package/src/feature-adr-checkpoints.ts +38 -0
  120. package/src/feature-adr-routing.ts +697 -75
  121. package/src/guard.ts +36 -1
  122. package/src/index.ts +162 -2
  123. package/src/loop-blobs.generated.ts +12 -3
  124. package/src/loop-plan.ts +185 -0
  125. package/src/loop-render.ts +37 -127
  126. package/src/loop-run-semantics.ts +278 -0
  127. package/src/loop-trace.ts +84 -10
  128. package/src/managed-hooks.ts +150 -0
  129. package/src/mutation-gate.ts +24 -2
  130. package/src/named-lock.ts +277 -0
  131. package/src/operations.ts +1121 -29
  132. package/src/parity.ts +295 -6
  133. package/src/qe-bridge.ts +737 -0
  134. package/src/recall-usage.ts +184 -1
  135. package/src/score.ts +50 -9
  136. package/src/setup.ts +26 -27
  137. package/src/shell-veto-policy.ts +119 -0
  138. package/src/skills.ts +174 -1
  139. package/src/targets.ts +189 -0
  140. package/src/workflow-run-dispatch.ts +459 -0
  141. package/src/workflow-run.ts +1743 -0
@@ -0,0 +1,150 @@
1
+ /**
2
+ * The ONE event-level managed-hook merge (`crossrt-2-codex-hooks`, AM-3 / G-E).
3
+ *
4
+ * Both hook targets — Claude Code's `.claude/settings.json` and Codex's
5
+ * `$CODEX_HOME/hooks.json` — need the same operation: per event, KEEP every entry that is not ours
6
+ * (byte-identical, same order) and append the entries that are. Before this module the Claude half
7
+ * lived inline in `setup.ts` and the Codex half did not exist; a second dialect would have been the
8
+ * obvious way to write it and the wrong one.
9
+ *
10
+ * What differs between the targets is ONLY the attribution predicate, so that is the parameter:
11
+ * the Claude path passes its historical substring list verbatim (bytes must not move — AM-3), the
12
+ * Codex path passes sha-over-manifest (ADR-001 §3: dz never deletes what it cannot prove it wrote).
13
+ *
14
+ * The plan (AM-37) is explicit that the extracted block has **three** outputs, not one: the merged
15
+ * body, the REPORT tail string, and the no-write path (`changed === false`). A golden test on the
16
+ * merged bytes alone would pass while the report text and the no-write behaviour silently changed,
17
+ * so all three are part of {@link HookMergePlan}.
18
+ *
19
+ * Pure: no I/O, no clock, no environment.
20
+ *
21
+ * @packageDocumentation
22
+ */
23
+
24
+ /** One hook registry entry, in either the matcher-group or the legacy flat shape. */
25
+ export type ManagedHookEntry = unknown;
26
+
27
+ /**
28
+ * Commands of a hook entry in EITHER shape: the valid matcher-group form
29
+ * `{matcher?, hooks:[{type,command}]}` or the legacy flat `{type,command}`.
30
+ *
31
+ * Deliberately duplicated from `setup.ts`'s `commandsOf` rather than imported: `setup.ts` imports
32
+ * this module, and the reverse edge would be a cycle. The two are pinned equal by a test.
33
+ */
34
+ export function hookCommandsOf(entry: ManagedHookEntry): string[] {
35
+ const e = entry as { command?: unknown; hooks?: { command?: unknown }[] };
36
+ if (Array.isArray(e?.hooks)) return e.hooks.map((h) => String(h?.command ?? ''));
37
+ return [String(e?.command ?? '')];
38
+ }
39
+
40
+ export interface HookMergePlan {
41
+ /** The merged registry body, ready to serialize. */
42
+ readonly hooks: Record<string, ManagedHookEntry[]>;
43
+ /** False ⇒ the caller must NOT write (the `hooks already current` no-write path — AM-37). */
44
+ readonly changed: boolean;
45
+ /** The setup REPORT tail string. Part of the extracted block's contract, not decoration (AM-37). */
46
+ readonly report: string;
47
+ /** A legacy-shaped entry of ours was replaced (drives the Claude report wording). */
48
+ readonly replacedLegacy: boolean;
49
+ /** Entries kept because they are NOT ours, across the WHOLE registry. G-D's number. */
50
+ readonly foreignPreserved: number;
51
+ /**
52
+ * Entries that RESEMBLE ours but are not attributable to the manifest. They are KEPT and counted —
53
+ * never deleted (ADR-001 §3). Distinct from `replacedLegacy`, which is about our OWN old vintages.
54
+ */
55
+ readonly unattributable: number;
56
+ }
57
+
58
+ export interface MergeManagedHookOptions {
59
+ /** True ⇒ this entry is OURS and is replaced by the managed set for that event. */
60
+ readonly isManaged: (entry: ManagedHookEntry, event: string) => boolean;
61
+ /**
62
+ * True ⇒ this entry resembles ours but could not be attributed. Optional; when omitted nothing is
63
+ * counted. Never causes deletion — only counting and a warning upstream.
64
+ */
65
+ readonly looksLikeOurs?: (entry: ManagedHookEntry, event: string) => boolean;
66
+ /** True ⇒ this OURS entry was in a legacy shape/vintage (Claude path only). */
67
+ readonly isLegacy?: (entry: ManagedHookEntry, event: string) => boolean;
68
+ /**
69
+ * Per-HANDLER salvage for an entry `isManaged` claimed. Given an OWNED entry, return it rebuilt
70
+ * from only the handlers that are NOT ours, or `null` when every handler was ours.
71
+ *
72
+ * Optional, and absent means the historical whole-entry behaviour — the Claude path passes
73
+ * nothing and is byte-identical to before (AM-3). The Codex path passes it because attribution at
74
+ * matcher-group granularity deleted a foreign handler that merely shared a group with dz's
75
+ * (independent review, finding 6).
76
+ */
77
+ readonly retainForeign?: (entry: ManagedHookEntry, event: string) => ManagedHookEntry | null;
78
+ /** e.g. `'agentdb'` → `merged agentdb hooks (user hooks preserved)`. */
79
+ readonly reportLabel?: string;
80
+ /** Report text when nothing changed. */
81
+ readonly unchangedReport?: string;
82
+ }
83
+
84
+ /**
85
+ * Merge `managed` into `existingHooks` at EVENT level.
86
+ *
87
+ * - Events absent from `managed` are copied through untouched (including unknown ones), and their
88
+ * entries still count toward `foreignPreserved` — the number answers "how many of the user's
89
+ * entries did this registry hold and keep", not "how many survived the touched events".
90
+ * - Within a touched event, non-ours entries keep their relative order and object identity, then
91
+ * the managed entries are appended.
92
+ * - `merge(merge(x)) === merge(x)`.
93
+ */
94
+ export function mergeManagedHookEntries(
95
+ existingHooks: Record<string, ManagedHookEntry[]> | undefined,
96
+ managed: Record<string, ManagedHookEntry[]>,
97
+ options: MergeManagedHookOptions,
98
+ ): HookMergePlan {
99
+ const source = existingHooks ?? {};
100
+ const hooks: Record<string, ManagedHookEntry[]> = {};
101
+ for (const event of Object.keys(source)) {
102
+ hooks[event] = Array.isArray(source[event]) ? [...(source[event] as ManagedHookEntry[])] : [];
103
+ }
104
+
105
+ // Census FIRST, over the whole input, so the counts do not depend on which events are touched.
106
+ let foreignPreserved = 0;
107
+ let unattributable = 0;
108
+ for (const [event, entries] of Object.entries(source)) {
109
+ if (!Array.isArray(entries)) continue;
110
+ for (const entry of entries) {
111
+ if (options.isManaged(entry, event)) {
112
+ // A mixed group counts as a preserved foreign entry when something of the user's survives
113
+ // in it — the census must not report zero for a handler the merge actually keeps.
114
+ if (options.retainForeign?.(entry, event) != null) foreignPreserved += 1;
115
+ continue;
116
+ }
117
+ foreignPreserved += 1;
118
+ if (options.looksLikeOurs?.(entry, event) === true) unattributable += 1;
119
+ }
120
+ }
121
+
122
+ let changed = false;
123
+ let replacedLegacy = false;
124
+ for (const event of Object.keys(managed)) {
125
+ const current = Array.isArray(hooks[event]) ? (hooks[event] as ManagedHookEntry[]) : [];
126
+ const kept: ManagedHookEntry[] = [];
127
+ for (const entry of current) {
128
+ const ours = options.isManaged(entry, event);
129
+ if (!ours) {
130
+ kept.push(entry);
131
+ continue;
132
+ }
133
+ if (options.isLegacy?.(entry, event) === true) replacedLegacy = true;
134
+ const salvaged = options.retainForeign?.(entry, event) ?? null;
135
+ if (salvaged !== null) kept.push(salvaged);
136
+ }
137
+ const next = [...kept, ...(managed[event] ?? [])];
138
+ if (JSON.stringify(next) !== JSON.stringify(current)) changed = true;
139
+ hooks[event] = next;
140
+ }
141
+
142
+ const label = options.reportLabel ?? 'managed';
143
+ const report = !changed
144
+ ? (options.unchangedReport ?? 'hooks already current')
145
+ : replacedLegacy
146
+ ? `replaced legacy dz hooks with ${label} hooks`
147
+ : `merged ${label} hooks (user hooks preserved)`;
148
+
149
+ return { hooks, changed, report, replacedLegacy, foreignPreserved, unattributable };
150
+ }
@@ -317,6 +317,26 @@ function stripSgr(s: string): string {
317
317
 
318
318
  export type RunnerKind = 'node-test' | 'vitest' | 'unknown';
319
319
 
320
+ /**
321
+ * Which runner's output shape is this? Extracted VERBATIM from `classifyRunFailure`'s two shape
322
+ * checks so the discrimination gate's evidence model can identify the runner of a GREEN run too —
323
+ * `classifyRunFailure` classifies RED runs only (its green branch returns a red-worded
324
+ * 'unrecognised'), so it cannot answer "which runner produced this pass?".
325
+ *
326
+ * ONE regex family per package (the ADR-001 driver): every runner-shape regex in harness-core
327
+ * lives here, and discrimination-gate.ts consumes this function instead of growing a second copy.
328
+ * Detection is from the OUTPUT SHAPE, not the command (an `npm test` alias hides the runner):
329
+ * node --test needs the TAP header AND node's `# duration_ms` trailer (tape emits TAP + `# fail`
330
+ * but not `# duration_ms`); vitest needs its `RUN v<semver>` banner or `Test Files` summary line.
331
+ * Anything else is 'unknown' — the honest, narrowed gap, never a silent trust mint.
332
+ */
333
+ export function detectRunnerKind(rawOutput: string): RunnerKind {
334
+ const output = stripSgr(rawOutput);
335
+ if (/^TAP version \d+/m.test(output) && /^#\s*duration_ms\s+[\d.]+/m.test(output)) return 'node-test';
336
+ if (/^\s*RUN\s+v\d+\./m.test(output) || /^\s*Test Files\s/m.test(output)) return 'vitest';
337
+ return 'unknown';
338
+ }
339
+
320
340
  export interface RunFailureClassification {
321
341
  /** which runner's output shape was recognised. */
322
342
  readonly runner: RunnerKind;
@@ -373,9 +393,11 @@ export interface RunFailureClassification {
373
393
  */
374
394
  export function classifyRunFailure(rawOutput: string): RunFailureClassification {
375
395
  const output = stripSgr(rawOutput);
396
+ // shape detection is shared with the discrimination gate's evidence model (detectRunnerKind above).
397
+ const runner = detectRunnerKind(rawOutput);
376
398
 
377
399
  // node --test (flat TAP, node ≥ 20 shape measured on v22.22.0)
378
- if (/^TAP version \d+/m.test(output) && /^#\s*duration_ms\s+[\d.]+/m.test(output)) {
400
+ if (runner === 'node-test') {
379
401
  const lines = output.split('\n');
380
402
  let sawNotOk = false;
381
403
  for (let i = 0; i < lines.length; i++) {
@@ -407,7 +429,7 @@ export function classifyRunFailure(rawOutput: string): RunFailureClassification
407
429
  }
408
430
 
409
431
  // vitest (`vitest run`, shape measured on 3.2.4)
410
- if (/^\s*RUN\s+v\d+\./m.test(output) || /^\s*Test Files\s/m.test(output)) {
432
+ if (runner === 'vitest') {
411
433
  const failedSuites = /\bFailed Suites\s+(\d+)\b/.exec(output);
412
434
  const suiteFail = /^\s*FAIL\s+(\S+)\s+\[\s*\1\s*\]/m.exec(output);
413
435
  if (failedSuites !== null || suiteFail !== null) {
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Named advisory locks — `.dz/locks/<name>.lock` (feature qe-bridge-claude, ADR-001 D4-A).
3
+ *
4
+ * WHY. The pattern store already has a cross-process lock (`store-lock.ts`), but it guards ONE
5
+ * resource. Other read-modify-write surfaces in this repo have the same lost-update shape and no
6
+ * guard — the first of them being the `$CODEX_HOME/hooks.json` merge writer, whose race was
7
+ * ACCEPTED as a degradation with an explicit exit condition naming this leg
8
+ * (`architecture/degradations.md`). A read-modify-write that two processes interleave loses one
9
+ * side's addition, and BOTH report success — atomicity (temp+rename) prevents corruption; only
10
+ * mutual exclusion prevents a lost update.
11
+ *
12
+ * WHAT THIS IS. The same `proper-lockfile` machinery as `withStoreLockSync`: atomic `mkdir`
13
+ * acquisition, heartbeat-refreshed staleness (age alone is never liveness), a real acquisition
14
+ * DEADLINE, and a LOUD throw on timeout rather than proceeding unlocked — proceeding would restore
15
+ * exactly the silent lost update this exists to stop. It is emphatically NOT the hand-rolled `wx`
16
+ * file lock that an independent review graded F (`store-lock.ts:14-31`).
17
+ *
18
+ * WHY A MIRROR AND NOT A CALL. `store-lock.ts` keeps its internals private and this feature's
19
+ * out-of-scope fence forbids changing the store lock's semantics, so the ~40 lines of acquisition
20
+ * machinery are mirrored here rather than extracted. The duplication is deliberate and is a named
21
+ * unification candidate; the options type, the stale/timeout defaults and the environment override
22
+ * are IMPORTED from `store-lock.ts`, so the two cannot drift apart on their contract.
23
+ *
24
+ * HONEST LIMIT. This is an ADVISORY lock: it serializes writers that TAKE it. A foreign process
25
+ * that never heard of it writes whenever it likes. For `hooks.json` the pre-existing mitigations
26
+ * (foreign entries preserved byte-for-byte, timestamped backup, atomic temp+rename) remain the
27
+ * backstop for that case, and the rule doc says so in as many words.
28
+ */
29
+
30
+ import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
31
+ import { randomBytes } from 'node:crypto';
32
+ import { createRequire } from 'node:module';
33
+ import { join, resolve } from 'node:path';
34
+ import { lockSync } from 'proper-lockfile';
35
+
36
+ import { LOCK_TIMEOUT_MS, STALE_LOCK_MS } from './store-lock.js';
37
+ import type { StoreLockOptions } from './store-lock.js';
38
+
39
+ /** `proper-lockfile` silently clamps `stale` up to this minimum. */
40
+ const MIN_STALE_MS = 2_000;
41
+
42
+ /** Lock names are filenames: one bounded, lowercase, path-free component. */
43
+ const SAFE_LOCK_NAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
44
+
45
+ export function isSafeLockName(name: string): boolean {
46
+ return typeof name === 'string' && SAFE_LOCK_NAME.test(name);
47
+ }
48
+
49
+ /** Path of a named lock (a DIRECTORY while held). Throws on an unsafe name — a lock whose path a
50
+ * caller can steer is not a lock. */
51
+ export function namedLockPath(projectRoot: string, name: string): string {
52
+ if (!isSafeLockName(name)) {
53
+ throw new NamedLockNameError(name);
54
+ }
55
+ return join(projectRoot, '.dz', 'locks', `${name}.lock`);
56
+ }
57
+
58
+ export class NamedLockNameError extends Error {
59
+ readonly code = 'ELOCKNAME';
60
+ constructor(name: string) {
61
+ super(
62
+ `invalid lock name ${JSON.stringify(name)} — a lock name must match ${String(SAFE_LOCK_NAME)} ` +
63
+ '(one lowercase path-free component); a name carrying a separator would place the lock outside .dz/locks/',
64
+ );
65
+ this.name = 'NamedLockNameError';
66
+ }
67
+ }
68
+
69
+ /** Acquisition gave up at the deadline. Nothing ran; the operation can be retried. */
70
+ export class NamedLockTimeoutError extends Error {
71
+ readonly code = 'ELOCKTIMEOUT';
72
+ constructor(lockPath: string, waitedMs: number) {
73
+ super(
74
+ `the lock at ${lockPath} stayed held for ${waitedMs}ms — another process is still inside the ` +
75
+ 'guarded section. Nothing was written; retry.',
76
+ );
77
+ this.name = 'NamedLockTimeoutError';
78
+ }
79
+ }
80
+
81
+ /** The lock was broken out from under the holder while `fn` ran: it DID run, but may have raced. */
82
+ export class NamedLockCompromisedError extends Error {
83
+ readonly code = 'ECOMPROMISED';
84
+ constructor(lockPath: string, cause: Error) {
85
+ super(`the lock at ${lockPath} was compromised while held (${cause.message}) — retry the operation.`);
86
+ this.name = 'NamedLockCompromisedError';
87
+ }
88
+ }
89
+
90
+ const defaultTimeoutMs = (): number => {
91
+ const env = Number(process.env['DZ_STORE_LOCK_TIMEOUT_MS']);
92
+ return Number.isFinite(env) && env > 0 ? env : LOCK_TIMEOUT_MS;
93
+ };
94
+
95
+ /** Synchronous sleep without spinning (Atomics.wait on a throwaway buffer). */
96
+ const sleepSync = (ms: number): void => {
97
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
98
+ };
99
+
100
+ function resolveOpts(opts: StoreLockOptions): { staleMs: number; timeoutMs: number; pollMs: number } {
101
+ return {
102
+ staleMs: Math.max(opts.staleMs ?? STALE_LOCK_MS, MIN_STALE_MS),
103
+ timeoutMs: opts.timeoutMs ?? defaultTimeoutMs(),
104
+ pollMs: opts.pollMs ?? 25,
105
+ };
106
+ }
107
+
108
+ interface Acquired {
109
+ /** Releases the lock. Returns `'released'` when it was ours, `'stolen'` when it was not (nothing
110
+ * removed, exit-time removal disarmed) and `'stolen-undisarmed'` when it was not ours AND the
111
+ * exit-time removal could not be disarmed — a gap the caller must hear about. */
112
+ readonly release: () => 'released' | 'stolen' | 'stolen-undisarmed';
113
+ }
114
+
115
+ /**
116
+ * DISARMING THE LIBRARY'S EXIT HANDLER (round-2 CRITICAL C2, second mechanism).
117
+ *
118
+ * `proper-lockfile` registers a process-exit handler that `rmdirSync`s every lock still in its
119
+ * internal map — unconditionally, with no ownership check (lockfile.js: "Remove acquired locks on
120
+ * exit"). So even after this module refuses to remove a stolen lock, the holder's PROCESS EXIT
121
+ * removed the successor's live lock a moment later. MEASURED: process C then acquired the lock while
122
+ * B was still inside its critical section.
123
+ *
124
+ * The map is reachable through the library's own `getLocks()` accessor. It is not re-exported by the
125
+ * package index, so this is a deep import — guarded by a runtime shape check, and NEVER silent: if
126
+ * the accessor is missing (a future version moved it), the compromise error says the exit-time
127
+ * removal could not be disarmed, so the gap is reported rather than assumed away.
128
+ */
129
+ const lockfileInternals: { getLocks?: () => Record<string, unknown> } = (() => {
130
+ try {
131
+ const req = createRequire(import.meta.url);
132
+ const mod = req('proper-lockfile/lib/lockfile.js') as { getLocks?: unknown };
133
+ return typeof mod.getLocks === 'function' ? { getLocks: mod.getLocks as () => Record<string, unknown> } : {};
134
+ } catch {
135
+ return {};
136
+ }
137
+ })();
138
+
139
+ /** Drop a lock we no longer own from the library's exit-time removal list. Returns false when the
140
+ * accessor is unavailable — the caller says so out loud. */
141
+ function disarmExitRemoval(resourceKey: string): boolean {
142
+ const get = lockfileInternals.getLocks;
143
+ if (get === undefined) return false;
144
+ try {
145
+ const locks = get();
146
+ if (Object.prototype.hasOwnProperty.call(locks, resourceKey)) delete locks[resourceKey];
147
+ return true;
148
+ } catch {
149
+ return false;
150
+ }
151
+ }
152
+
153
+ /**
154
+ * OWNERSHIP TOKEN (round-2 CRITICAL C2). `proper-lockfile`'s release is unconditional: it clears its
155
+ * timer and removes the lock directory, without checking that the directory is still the one it
156
+ * created. That is safe while the heartbeat runs — but a SYNCHRONOUS critical section blocks the
157
+ * event loop, so the heartbeat cannot fire, a waiter is entitled to break the stale lock, and the
158
+ * original holder then deletes its SUCCESSOR's live lock on the way out.
159
+ *
160
+ * MEASURED (2026-08-19, three real processes, reproducer in named-lock.test.ts / red-green.md):
161
+ * with a 6s synchronous body under a 2s stale threshold, process C acquired the lock while B was
162
+ * still inside its critical section — mutual exclusion gone, silently.
163
+ *
164
+ * The fix: stamp a random token BESIDE the lock directory at acquisition and verify it at release. A
165
+ * lock that no longer carries our token is somebody else's, and we do not touch it — we report the
166
+ * compromise loudly instead. (The marker lives beside, not inside: `proper-lockfile` removes the
167
+ * lock with a plain `rmdir`, so a file within it would make every release silently fail and leak the
168
+ * lock — MEASURED, the first draft of this fix did exactly that and the suite went red.)
169
+ */
170
+ function ownerMarkerPath(lockPath: string): string {
171
+ return lockPath + '.owner';
172
+ }
173
+
174
+ function tryAcquire(
175
+ projectRoot: string,
176
+ name: string,
177
+ staleMs: number,
178
+ onCompromised: (e: Error) => void,
179
+ ): Acquired | 'held' {
180
+ const lockPath = namedLockPath(projectRoot, name);
181
+ const resourceKey = resolve(projectRoot, '.dz', 'locks', name);
182
+ try {
183
+ const release = lockSync(resourceKey, {
184
+ lockfilePath: lockPath,
185
+ stale: staleMs,
186
+ realpath: false, // the guarded resource is a concept, not an existing file
187
+ onCompromised,
188
+ });
189
+ const token = randomBytes(16).toString('hex');
190
+ let ino: number | null = null;
191
+ try {
192
+ writeFileSync(ownerMarkerPath(lockPath), token, { mode: 0o600 });
193
+ ino = statSync(lockPath).ino;
194
+ } catch { /* an unwritable lock dir: fall back to inode-only evidence below */ }
195
+ return {
196
+ release: (): 'released' | 'stolen' | 'stolen-undisarmed' => {
197
+ let ours = false;
198
+ try {
199
+ const seen = readFileSync(ownerMarkerPath(lockPath), 'utf8');
200
+ const sameIno = ino === null ? true : statSync(lockPath).ino === ino;
201
+ ours = seen === token && sameIno;
202
+ } catch {
203
+ ours = false; // gone, or unreadable — either way not provably ours
204
+ }
205
+ if (!ours) {
206
+ // NEVER remove a lock directory we cannot prove is ours — including at process exit.
207
+ return disarmExitRemoval(resourceKey) ? 'stolen' : 'stolen-undisarmed';
208
+ }
209
+ try {
210
+ release();
211
+ } catch { /* ERELEASED after a compromise — the lock is no longer ours to remove */ }
212
+ try {
213
+ rmSync(ownerMarkerPath(lockPath), { force: true });
214
+ } catch { /* best effort: a stale marker is overwritten by the next holder anyway */ }
215
+ return 'released';
216
+ },
217
+ };
218
+ } catch (err) {
219
+ if ((err as NodeJS.ErrnoException).code === 'ELOCKED') return 'held';
220
+ throw err;
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Run `fn` while holding the named lock, releasing it however `fn` ends.
226
+ *
227
+ * Throws {@link NamedLockTimeoutError} when the lock cannot be acquired by the deadline (nothing
228
+ * has run) and {@link NamedLockCompromisedError} when the lock was broken while `fn` ran. The
229
+ * heartbeat cannot fire while a synchronous `fn` blocks the event loop, so keep bodies well under
230
+ * `staleMs` — the same caveat `withStoreLockSync` carries.
231
+ */
232
+ export function withNamedLockSync<T>(
233
+ projectRoot: string,
234
+ name: string,
235
+ fn: () => T,
236
+ opts: StoreLockOptions = {},
237
+ ): T {
238
+ const lockPath = namedLockPath(projectRoot, name); // validates the name BEFORE any mkdir
239
+ const { staleMs, timeoutMs, pollMs } = resolveOpts(opts);
240
+ mkdirSync(join(projectRoot, '.dz', 'locks'), { recursive: true });
241
+ const started = Date.now();
242
+ const deadline = started + timeoutMs;
243
+ let compromised: Error | undefined;
244
+ const onCompromised = (e: Error): void => { compromised = e; };
245
+ for (;;) {
246
+ const got = tryAcquire(projectRoot, name, staleMs, onCompromised);
247
+ if (got !== 'held') {
248
+ let result: T;
249
+ let outcome: 'released' | 'stolen' | 'stolen-undisarmed' = 'released';
250
+ try {
251
+ result = fn();
252
+ } finally {
253
+ outcome = got.release();
254
+ }
255
+ if (outcome !== 'released') {
256
+ throw new NamedLockCompromisedError(
257
+ lockPath,
258
+ compromised ??
259
+ new Error(
260
+ 'the lock directory no longer carries this holder\u2019s ownership token — a waiter broke it as stale ' +
261
+ 'while the critical section was still running (a synchronous body cannot heartbeat). The successor\u2019s ' +
262
+ 'lock was left untouched; THIS run must be treated as having raced and retried.' +
263
+ (outcome === 'stolen-undisarmed'
264
+ ? ' WARNING: proper-lockfile\u2019s exit-time lock removal could NOT be disarmed (its getLocks() accessor ' +
265
+ 'is unavailable in this version), so this process may still remove the successor\u2019s lock when it exits.'
266
+ : ''),
267
+ ),
268
+ );
269
+ }
270
+ if (compromised !== undefined) throw new NamedLockCompromisedError(lockPath, compromised);
271
+ return result;
272
+ }
273
+ const now = Date.now();
274
+ if (now >= deadline) throw new NamedLockTimeoutError(lockPath, now - started);
275
+ sleepSync(Math.min(pollMs, deadline - now));
276
+ }
277
+ }