@mgiles/perk 2.3.0 → 3.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 (128) hide show
  1. package/README.md +1 -1
  2. package/extension/adapters/planAdapterPlannotator.ts +132 -91
  3. package/extension/adapters/planAdapterTombell.ts +5 -3
  4. package/extension/doors/address.ts +235 -37
  5. package/extension/doors/annotationPush.ts +924 -0
  6. package/extension/doors/auditWaveTools.ts +352 -0
  7. package/extension/doors/ciExecutor.ts +220 -55
  8. package/extension/doors/draftReviewWaveTools.ts +384 -0
  9. package/extension/doors/harvestWaveTools.ts +279 -0
  10. package/extension/doors/land.ts +1 -0
  11. package/extension/doors/learn.ts +24 -6
  12. package/extension/doors/learnFactory.ts +33 -7
  13. package/extension/doors/lifecycleGates.ts +1 -1
  14. package/extension/doors/objectiveReviewBrowser.ts +514 -0
  15. package/extension/doors/objectiveStack.ts +1143 -0
  16. package/extension/doors/planReviewBrowser.ts +453 -0
  17. package/extension/doors/plannotatorHandoff.ts +120 -49
  18. package/extension/doors/prReview.ts +26 -16
  19. package/extension/doors/prReviewBrowser.ts +31 -23
  20. package/extension/doors/prReviewDynamic.ts +25 -18
  21. package/extension/doors/prReviewTerminal.ts +6 -11
  22. package/extension/doors/reviewWaveTools.ts +374 -0
  23. package/extension/doors/selfcheck.ts +1 -1
  24. package/extension/doors/submit.ts +82 -7
  25. package/extension/factories/gistAuthor.ts +4 -4
  26. package/extension/factories/gistDraft.ts +1 -1
  27. package/extension/factories/gistSave.ts +2 -2
  28. package/extension/factories/objective.ts +7 -7
  29. package/extension/factories/objectiveAuthor.ts +5 -4
  30. package/extension/factories/objectiveDraft.ts +61 -6
  31. package/extension/factories/objectivePlan.ts +163 -9
  32. package/extension/factories/objectiveSave.ts +16 -1
  33. package/extension/factories/planMode.ts +5 -3
  34. package/extension/factories/planReview.ts +131 -36
  35. package/extension/factories/planTitle.ts +27 -9
  36. package/extension/hunkFeedback/inbox.ts +389 -0
  37. package/extension/hunkFeedback/perkFeedback.ts +364 -0
  38. package/extension/hunkFeedback/receiver.ts +296 -0
  39. package/extension/hunkFeedback/store.ts +533 -0
  40. package/extension/index.ts +109 -44
  41. package/extension/substrate/cache.ts +75 -22
  42. package/extension/substrate/coldDoor.ts +2 -3
  43. package/extension/substrate/config.ts +36 -12
  44. package/extension/substrate/git.ts +10 -0
  45. package/extension/substrate/providers.ts +4 -33
  46. package/extension/substrate/resources.ts +11 -0
  47. package/extension/substrate/result.ts +13 -5
  48. package/extension/substrate/sessionData.ts +3 -3
  49. package/extension/substrate/sessionPointers.ts +3 -3
  50. package/extension/substrate/structuredOutput.ts +78 -19
  51. package/extension/substrate/toolGating.ts +170 -23
  52. package/extension/substrate/workflowState.ts +6 -0
  53. package/extension/surfaces/footerProvider.ts +6 -6
  54. package/extension/surfaces/surfaces.ts +98 -282
  55. package/extension/vendor/btw/btw.ts +57 -12
  56. package/extension/waves/adversarialReviewWave.ts +164 -0
  57. package/extension/waves/auditWave.ts +312 -0
  58. package/extension/waves/draftReviewWave.ts +189 -0
  59. package/extension/waves/harvestWave.ts +399 -0
  60. package/extension/waves/memoryAdapter.ts +14 -1
  61. package/extension/waves/objectiveExplorerWave.ts +120 -0
  62. package/extension/waves/prReviewDynamicWave.ts +291 -61
  63. package/extension/waves/prReviewWave.ts +58 -13
  64. package/extension/waves/reportWave.ts +406 -106
  65. package/extension/waves/reviewClassifierWave.ts +109 -0
  66. package/extension/waves/rpcAdapter.ts +65 -2
  67. package/extension/worker/readOnlySession.ts +3 -3
  68. package/extension/worker/worker.ts +71 -93
  69. package/extension/workerMain.ts +4 -6
  70. package/package.json +15 -5
  71. package/prompts/README.md +19 -0
  72. package/prompts/_fixtures/live.yaml +66 -65
  73. package/prompts/common/resume-advisory.md +1 -0
  74. package/prompts/contexts/adapters/plannotator-gist.md +13 -0
  75. package/prompts/contexts/adapters/plannotator-objective.md +1 -2
  76. package/prompts/contexts/adapters/plannotator-plan.md +1 -2
  77. package/prompts/contexts/gist-authoring.md +6 -20
  78. package/prompts/contexts/objective-authoring.md +6 -18
  79. package/prompts/stages/address/action.md +3 -14
  80. package/prompts/stages/address/preview.md +2 -13
  81. package/prompts/stages/audit.md +18 -0
  82. package/prompts/stages/gist-author/seed.md +4 -4
  83. package/prompts/stages/gist-save.md +1 -1
  84. package/prompts/stages/implement.md +3 -1
  85. package/prompts/stages/learn-code.md +1 -1
  86. package/prompts/stages/learn-docs.md +2 -2
  87. package/prompts/stages/learn-harvest.md +15 -0
  88. package/prompts/stages/learn-orchestrate.md +1 -1
  89. package/prompts/stages/objective-author/adopt.md +5 -3
  90. package/prompts/stages/objective-author/file.md +5 -4
  91. package/prompts/stages/objective-author/seed.md +5 -4
  92. package/prompts/stages/objective-land.md +7 -0
  93. package/prompts/stages/objective-plan/guidance.md +2 -13
  94. package/prompts/stages/objective-plan/seed.md +5 -12
  95. package/prompts/stages/objective-recover.md +8 -0
  96. package/prompts/stages/objective-replan.md +4 -2
  97. package/prompts/stages/objective-review-browser.md +9 -0
  98. package/prompts/stages/objective-sync.md +6 -0
  99. package/prompts/stages/plan-from/adopt.md +2 -2
  100. package/prompts/stages/plan-from/file.md +2 -2
  101. package/prompts/stages/plan-review-browser.md +9 -0
  102. package/prompts/stages/pr-review-browser/active.md +6 -14
  103. package/prompts/stages/pr-review-browser/foreign.md +6 -14
  104. package/prompts/stages/pr-review-dynamic.md +3 -3
  105. package/prompts/stages/pr-review-terminal/active.md +7 -15
  106. package/prompts/stages/pr-review-terminal/foreign.md +7 -15
  107. package/prompts/stages/pr-review.md +2 -2
  108. package/prompts/stages/replan.md +3 -3
  109. package/shared/README.md +2 -2
  110. package/shared/bindings.yaml +12 -0
  111. package/shared/contracts-history.md +11 -0
  112. package/shared/contracts.md +3652 -236
  113. package/shared/providers.yaml +27 -60
  114. package/shared/registry.yaml +32 -8
  115. package/shared/schemas/outputs/objective-doctor.schema.json +449 -0
  116. package/shared/schemas/outputs/objective-stack-land.schema.json +732 -0
  117. package/shared/schemas/outputs/objective-stack-recover.schema.json +397 -0
  118. package/shared/schemas/outputs/objective-stack-status.schema.json +577 -0
  119. package/shared/schemas/outputs/objective-stack-sync.schema.json +184 -0
  120. package/shared/schemas/outputs/plan-save.schema.json +12 -0
  121. package/shared/schemas/outputs/pr-submit.schema.json +177 -1
  122. package/extension/adapters/todoAdapterJuicesharp.ts +0 -102
  123. package/extension/checkpoints/checkpoints.ts +0 -550
  124. package/extension/checkpoints/planSteps.ts +0 -108
  125. package/extension/doors/askUser.ts +0 -198
  126. package/prompts/common/output-schemas/objective-explorer.md +0 -36
  127. package/prompts/common/output-schemas/review-classifier.md +0 -47
  128. package/prompts/contexts/adapters/juicesharp-todo.md +0 -7
@@ -0,0 +1,533 @@
1
+ // The receiver-plane file contract behind the hunk watch feedback bridge (contracts.md §8.58):
2
+ // lenient NDJSON reads over the outbox/delivered streams, the append-only ack writer, and the
3
+ // single-consumer lease operations. Pure file mechanics — no timers, no session effects; the
4
+ // delivery machine lives in inbox.ts. Paths reach here from the cache-seam helpers
5
+ // (substrate/cache.ts) — this module never constructs `.perk/workflow` segments itself.
6
+ //
7
+ // Read posture (§8.58): reads are TOTAL and lenient — a missing file is no feedback; a trailing
8
+ // partial line is HELD (a concurrent append in flight); a malformed complete line warns and is
9
+ // skipped; an unknown `schema` is held with a loud version warning (never acked); duplicate
10
+ // `feedback_id`s collapse to the first valid record and conflicting later bytes for the same id
11
+ // are reported as corruption. Full-file reads with ID indexing — no cursors/compaction in v1.
12
+
13
+ import { randomBytes } from "node:crypto";
14
+ import {
15
+ appendFileSync,
16
+ lstatSync,
17
+ mkdirSync,
18
+ readdirSync,
19
+ readFileSync,
20
+ realpathSync,
21
+ renameSync,
22
+ rmSync,
23
+ statSync,
24
+ } from "node:fs";
25
+ import { basename, dirname, join, relative } from "node:path";
26
+ import { atomicWriteFileSync, hunkDeliveredPath, hunkWatchDir } from "../substrate/cache.ts";
27
+
28
+ /** Heartbeat renewal cadence — an implementation constant (§8.58), not config. */
29
+ export const HEARTBEAT_MS = 5_000;
30
+ /** A lease whose heartbeat is older than this is reclaimable — implementation constant. */
31
+ export const STALE_LEASE_MS = 60_000;
32
+
33
+ // --- record shapes (feedback record v1 / acknowledgement v1 / lease, §8.58) ----------------
34
+
35
+ export interface FeedbackAnchor {
36
+ file_path: string;
37
+ /** Zero-based hunk position within the file (Hunk's own index). */
38
+ hunk_index: number;
39
+ side: "old" | "new";
40
+ /** Positive one-based line number on `side`. */
41
+ line: number;
42
+ }
43
+
44
+ export interface FeedbackRecord {
45
+ schema: 1;
46
+ /** `<watch_instance_id>:<hunk-note-id>` — the stable at-least-once identity. */
47
+ feedback_id: string;
48
+ watch_instance_id: string;
49
+ plan_id: string;
50
+ /** Publisher-assigned ISO-8601. */
51
+ created_at: string;
52
+ changeset_id: string | null;
53
+ anchor: FeedbackAnchor;
54
+ body: string;
55
+ }
56
+
57
+ export interface DeliveryAck {
58
+ schema: 1;
59
+ feedback_id: string;
60
+ delivered_at: string;
61
+ run_id: string;
62
+ pi_session_id: string;
63
+ }
64
+
65
+ export interface OutboxRead {
66
+ records: FeedbackRecord[];
67
+ /** Lines held for a later read: the trailing partial line + unknown-schema lines. */
68
+ held: number;
69
+ warnings: string[];
70
+ }
71
+
72
+ function isRecord(value: unknown): value is Record<string, unknown> {
73
+ return typeof value === "object" && value !== null && !Array.isArray(value);
74
+ }
75
+
76
+ function isAnchor(value: unknown): value is FeedbackAnchor {
77
+ if (!isRecord(value)) return false;
78
+ return (
79
+ typeof value.file_path === "string" &&
80
+ typeof value.hunk_index === "number" &&
81
+ Number.isInteger(value.hunk_index) &&
82
+ value.hunk_index >= 0 &&
83
+ (value.side === "old" || value.side === "new") &&
84
+ typeof value.line === "number" &&
85
+ Number.isInteger(value.line) &&
86
+ value.line >= 1
87
+ );
88
+ }
89
+
90
+ function isFeedbackRecord(
91
+ value: Record<string, unknown>,
92
+ ): value is FeedbackRecord & Record<string, unknown> {
93
+ return (
94
+ typeof value.feedback_id === "string" &&
95
+ value.feedback_id !== "" &&
96
+ typeof value.watch_instance_id === "string" &&
97
+ typeof value.plan_id === "string" &&
98
+ typeof value.created_at === "string" &&
99
+ (value.changeset_id === null || typeof value.changeset_id === "string") &&
100
+ isAnchor(value.anchor) &&
101
+ typeof value.body === "string" &&
102
+ value.body !== ""
103
+ );
104
+ }
105
+
106
+ /**
107
+ * Split NDJSON content into complete lines + the held trailing partial (no trailing LF means a
108
+ * concurrent appender may still be mid-write — hold it for the next read, never parse it).
109
+ */
110
+ function completeLines(content: string): { lines: string[]; heldPartial: boolean } {
111
+ const heldPartial = content !== "" && !content.endsWith("\n");
112
+ const lines = content.split("\n");
113
+ // The final split element is either "" (trailing LF) or the held partial — drop it either way.
114
+ lines.pop();
115
+ return { lines: lines.filter((line) => line.trim() !== ""), heldPartial };
116
+ }
117
+
118
+ /**
119
+ * The lenient §8.58 outbox read. A MISSING file is the normal, silent no-feedback state;
120
+ * every other read failure (EACCES, EISDIR, EIO, …) is surfaced as a warning — queued feedback
121
+ * must never stall invisibly (the caller's once-per-distinct-message dedupe bounds the noise).
122
+ */
123
+ export function readOutbox(path: string): OutboxRead {
124
+ let content: string;
125
+ try {
126
+ content = readFileSync(path, "utf8");
127
+ } catch (error) {
128
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
129
+ return { records: [], held: 0, warnings: [] };
130
+ }
131
+ return { records: [], held: 0, warnings: [`could not read the feedback outbox: ${error}`] };
132
+ }
133
+ const { lines, heldPartial } = completeLines(content);
134
+ const records: FeedbackRecord[] = [];
135
+ const warnings: string[] = [];
136
+ let held = heldPartial ? 1 : 0;
137
+ const firstLineById = new Map<string, string>();
138
+ for (const line of lines) {
139
+ let parsed: unknown;
140
+ try {
141
+ parsed = JSON.parse(line);
142
+ } catch {
143
+ warnings.push(`skipping a malformed outbox line (not JSON): ${line.slice(0, 80)}`);
144
+ continue;
145
+ }
146
+ if (!isRecord(parsed)) {
147
+ warnings.push(`skipping a malformed outbox line (not an object): ${line.slice(0, 80)}`);
148
+ continue;
149
+ }
150
+ if (parsed.schema !== 1) {
151
+ // An unknown version is HELD, never skipped/acked: a newer writer may be talking to an
152
+ // older receiver — pausing keeps at-least-once intact for a receiver that understands it.
153
+ warnings.push(
154
+ `holding an outbox record with unknown schema ${JSON.stringify(parsed.schema)} — ` +
155
+ "a newer perk may be required to deliver it",
156
+ );
157
+ held += 1;
158
+ continue;
159
+ }
160
+ if (!isFeedbackRecord(parsed)) {
161
+ warnings.push(`skipping a structurally invalid outbox record: ${line.slice(0, 80)}`);
162
+ continue;
163
+ }
164
+ const prior = firstLineById.get(parsed.feedback_id);
165
+ if (prior !== undefined) {
166
+ if (prior !== line) {
167
+ warnings.push(
168
+ `conflicting bytes for feedback_id ${parsed.feedback_id} — keeping the first record ` +
169
+ "(outbox corruption)",
170
+ );
171
+ }
172
+ continue; // duplicates collapse to the first valid record
173
+ }
174
+ firstLineById.set(parsed.feedback_id, line);
175
+ records.push(parsed);
176
+ }
177
+ return { records, held, warnings };
178
+ }
179
+
180
+ function isDeliveryAck(
181
+ value: Record<string, unknown>,
182
+ ): value is DeliveryAck & Record<string, unknown> {
183
+ return (
184
+ typeof value.feedback_id === "string" &&
185
+ value.feedback_id !== "" &&
186
+ typeof value.delivered_at === "string" &&
187
+ typeof value.run_id === "string" &&
188
+ typeof value.pi_session_id === "string"
189
+ );
190
+ }
191
+
192
+ export interface DeliveredRead {
193
+ ids: Set<string>;
194
+ warnings: string[];
195
+ }
196
+
197
+ /**
198
+ * The delivered-id set — acknowledgement v1 ONLY (§8.58): an id is suppressed solely by a
199
+ * structurally valid, schema-1 acknowledgement (transcript-observation evidence). A malformed
200
+ * line or an unknown `schema` never counts as delivered (the safe direction is a duplicate
201
+ * redelivery, never a silent suppression) — both warn. A missing file is silent; other read
202
+ * failures warn (redelivery-safe).
203
+ */
204
+ export function readDeliveredIds(path: string): DeliveredRead {
205
+ let content: string;
206
+ try {
207
+ content = readFileSync(path, "utf8");
208
+ } catch (error) {
209
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return { ids: new Set(), warnings: [] };
210
+ return {
211
+ ids: new Set(),
212
+ warnings: [`could not read the feedback acknowledgements: ${error}`],
213
+ };
214
+ }
215
+ const ids = new Set<string>();
216
+ const warnings: string[] = [];
217
+ for (const line of completeLines(content).lines) {
218
+ let parsed: unknown;
219
+ try {
220
+ parsed = JSON.parse(line);
221
+ } catch {
222
+ warnings.push(`skipping a malformed acknowledgement line (not JSON): ${line.slice(0, 80)}`);
223
+ continue;
224
+ }
225
+ if (!isRecord(parsed)) {
226
+ warnings.push(`skipping a malformed acknowledgement line: ${line.slice(0, 80)}`);
227
+ continue;
228
+ }
229
+ if (parsed.schema !== 1) {
230
+ warnings.push(
231
+ `ignoring an acknowledgement with unknown schema ${JSON.stringify(parsed.schema)} — ` +
232
+ "its record may redeliver (a newer perk wrote it)",
233
+ );
234
+ continue;
235
+ }
236
+ if (!isDeliveryAck(parsed)) {
237
+ warnings.push(`skipping a structurally invalid acknowledgement: ${line.slice(0, 80)}`);
238
+ continue;
239
+ }
240
+ ids.add(parsed.feedback_id);
241
+ }
242
+ return { ids, warnings };
243
+ }
244
+
245
+ /**
246
+ * Refuse a symlinked append target (§8.58): every path component from the hunk-watch dir down
247
+ * must be a real directory/file under the CANONICAL family dir — a force-tracked symlink at
248
+ * `.perk`, `workflow`, `hunk-watch`, or the file itself would otherwise redirect the O_APPEND
249
+ * write outside the worktree. Symlinks ABOVE the worktree root stay legal (macOS /tmp).
250
+ * Check-then-append TOCTOU is accepted: the threat is force-tracked static checkout content,
251
+ * not a live same-uid attacker (who already owns the files).
252
+ */
253
+ function assertUnredirectedAppendTarget(cwd: string, path: string): void {
254
+ const watchDir = hunkWatchDir(cwd);
255
+ const expected = join(realpathSync(cwd), relative(cwd, watchDir));
256
+ if (realpathSync(watchDir) !== expected) {
257
+ throw new Error(`refusing a symlinked hunk-watch dir (${watchDir} resolves elsewhere)`);
258
+ }
259
+ try {
260
+ if (lstatSync(path).isSymbolicLink()) {
261
+ throw new Error(`refusing a symlinked append target: ${path}`);
262
+ }
263
+ } catch (error) {
264
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Append acknowledgements — one complete line + LF per ack (the O_APPEND discipline), after
270
+ * refusing symlinked path components (the target must live in THIS worktree's family dir).
271
+ */
272
+ export function appendAcks(cwd: string, acks: readonly DeliveryAck[]): void {
273
+ const path = hunkDeliveredPath(cwd);
274
+ mkdirSync(dirname(path), { recursive: true });
275
+ assertUnredirectedAppendTarget(cwd, path);
276
+ for (const ack of acks) {
277
+ appendFileSync(path, `${JSON.stringify(ack)}\n`, "utf8");
278
+ }
279
+ }
280
+
281
+ // --- the consumer lease (§8.58) ------------------------------------------------------------
282
+
283
+ export interface LeaseIdentity {
284
+ runId: string;
285
+ piSessionId: string;
286
+ }
287
+
288
+ export type LeaseAcquisition = { owned: true; token: string } | { owned: false; reason: string };
289
+
290
+ interface LeaseFile {
291
+ schema: 1;
292
+ token: string;
293
+ run_id: string;
294
+ pi_session_id: string;
295
+ claimed_at: string;
296
+ heartbeat_at: string;
297
+ }
298
+
299
+ function leasePath(lockDir: string): string {
300
+ return join(lockDir, "lease.json");
301
+ }
302
+
303
+ function readLease(lockDir: string): LeaseFile | null {
304
+ try {
305
+ const parsed: unknown = JSON.parse(readFileSync(leasePath(lockDir), "utf8"));
306
+ if (
307
+ isRecord(parsed) &&
308
+ parsed.schema === 1 &&
309
+ typeof parsed.token === "string" &&
310
+ typeof parsed.run_id === "string" &&
311
+ typeof parsed.pi_session_id === "string" &&
312
+ typeof parsed.claimed_at === "string" &&
313
+ typeof parsed.heartbeat_at === "string"
314
+ ) {
315
+ return parsed as unknown as LeaseFile;
316
+ }
317
+ return null;
318
+ } catch {
319
+ return null;
320
+ }
321
+ }
322
+
323
+ /** Temp-file + rename within the lock dir (atomicWriteFileSync's own discipline). */
324
+ function writeLease(lockDir: string, lease: LeaseFile): void {
325
+ atomicWriteFileSync(leasePath(lockDir), `${JSON.stringify(lease)}\n`);
326
+ }
327
+
328
+ function freshLease(identity: LeaseIdentity, nowMs: number, claimedAt?: string): LeaseFile {
329
+ const at = new Date(nowMs).toISOString();
330
+ return {
331
+ schema: 1,
332
+ token: randomBytes(8).toString("hex"),
333
+ run_id: identity.runId,
334
+ pi_session_id: identity.piSessionId,
335
+ claimed_at: claimedAt ?? at,
336
+ heartbeat_at: at,
337
+ };
338
+ }
339
+
340
+ /** Atomic `mkdir` (non-recursive, so EEXIST is the contention signal) + first lease write. */
341
+ function tryFreshAcquire(lockDir: string, identity: LeaseIdentity, nowMs: number): string | null {
342
+ try {
343
+ mkdirSync(lockDir);
344
+ } catch (error) {
345
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") return null;
346
+ throw error;
347
+ }
348
+ const lease = freshLease(identity, nowMs);
349
+ writeLease(lockDir, lease);
350
+ return lease.token;
351
+ }
352
+
353
+ /** Deterministic-interleave seams for the reclaim-race tests — never set in production. */
354
+ export interface AcquireRaceHooks {
355
+ /** Runs after the stale lease is observed, before the quarantine rename. */
356
+ beforeQuarantine?(): void;
357
+ /** Runs after the quarantine rename attempt, before the fresh-acquire retry. */
358
+ afterQuarantine?(): void;
359
+ }
360
+
361
+ /**
362
+ * Acquire the single-consumer lease (§8.58). Atomic directory creation is the primitive; on
363
+ * contention: same-identity → idempotent reacquire with a FRESH token (the fencing that retires
364
+ * a `/reload` predecessor instance); stale (heartbeat older than `STALE_LEASE_MS`, lock-dir
365
+ * mtime when `lease.json` is corrupt) → quarantine-rename then ONE fresh-acquire retry
366
+ * (competing reclaimers converge on one winner — the rename loser can legitimately win the
367
+ * retry; the winner best-effort-removes its quarantine dir); fresh foreign → passive. `now()`
368
+ * is injected for deterministic tests.
369
+ */
370
+ export function acquireLease(
371
+ lockDir: string,
372
+ identity: LeaseIdentity,
373
+ now: () => number,
374
+ hooks: AcquireRaceHooks = {},
375
+ ): LeaseAcquisition {
376
+ mkdirSync(dirname(lockDir), { recursive: true });
377
+ const nowMs = now();
378
+ const fresh = tryFreshAcquire(lockDir, identity, nowMs);
379
+ if (fresh !== null) return { owned: true, token: fresh };
380
+
381
+ const lease = readLease(lockDir);
382
+ if (
383
+ lease !== null &&
384
+ lease.run_id === identity.runId &&
385
+ lease.pi_session_id === identity.piSessionId
386
+ ) {
387
+ const reacquired = freshLease(identity, nowMs, lease.claimed_at);
388
+ writeLease(lockDir, reacquired);
389
+ return { owned: true, token: reacquired.token };
390
+ }
391
+
392
+ // Staleness basis: the recorded heartbeat, else (corrupt lease.json) the lock-dir mtime.
393
+ let basisMs = lease !== null ? Date.parse(lease.heartbeat_at) : Number.NaN;
394
+ if (Number.isNaN(basisMs)) {
395
+ try {
396
+ basisMs = statSync(lockDir).mtimeMs;
397
+ } catch {
398
+ // The dir vanished between mkdir-EEXIST and stat (a racing release) — retry fresh below.
399
+ basisMs = Number.NEGATIVE_INFINITY;
400
+ }
401
+ }
402
+ if (nowMs - basisMs < STALE_LEASE_MS) {
403
+ const holder =
404
+ lease !== null
405
+ ? `run ${lease.run_id} (session ${lease.pi_session_id})`
406
+ : "an unidentified session";
407
+ return {
408
+ owned: false,
409
+ reason: `another live implement session holds the feedback lease: ${holder}`,
410
+ };
411
+ }
412
+
413
+ // Stale: quarantine the dead lock dir under a unique name, then ONE fresh-acquire retry.
414
+ // A failed rename means a competing reclaimer already moved it — still take the retry.
415
+ hooks.beforeQuarantine?.();
416
+ const quarantine = `${lockDir}.stale-${process.pid.toString(36)}-${randomBytes(4).toString("hex")}`;
417
+ let renamed = false;
418
+ try {
419
+ renameSync(lockDir, quarantine);
420
+ renamed = true;
421
+ } catch {
422
+ renamed = false;
423
+ }
424
+ if (renamed) {
425
+ // Post-rename freshness re-check: between our staleness judgment and the rename, a
426
+ // competing reclaimer may have COMPLETED a full reclaim — the dir we just moved would
427
+ // then hold a FRESH successor lease, not the stale one we judged. Restore it and stay
428
+ // passive (a fresh foreign lease is never stolen). If the restore loses a further race,
429
+ // the quarantined holder fails closed on its own verify fence — never two live consumers.
430
+ const moved = readLease(quarantine);
431
+ const movedFresh =
432
+ moved !== null &&
433
+ !Number.isNaN(Date.parse(moved.heartbeat_at)) &&
434
+ nowMs - Date.parse(moved.heartbeat_at) < STALE_LEASE_MS;
435
+ if (movedFresh) {
436
+ try {
437
+ renameSync(quarantine, lockDir);
438
+ } catch {
439
+ // the name was retaken meanwhile — leave the quarantine for the sweep
440
+ }
441
+ return {
442
+ owned: false,
443
+ reason: `another live implement session holds the feedback lease: run ${moved.run_id} (session ${moved.pi_session_id})`,
444
+ };
445
+ }
446
+ }
447
+ hooks.afterQuarantine?.();
448
+ const retried = tryFreshAcquire(lockDir, identity, nowMs);
449
+ if (renamed) {
450
+ try {
451
+ rmSync(quarantine, { recursive: true, force: true });
452
+ } catch {
453
+ // best-effort — a leftover quarantine dir is harmless and swept on the next open
454
+ }
455
+ }
456
+ if (retried !== null) return { owned: true, token: retried };
457
+ return { owned: false, reason: "another session reclaimed the stale feedback lease first" };
458
+ }
459
+
460
+ /**
461
+ * Best-effort removal of leftover `consumer.lock.stale-*` quarantine dirs beside `lockDir`.
462
+ * Returns warnings for anything it could not remove (warn-and-leave — the tier is disposable).
463
+ */
464
+ export function sweepQuarantine(lockDir: string): string[] {
465
+ const parent = dirname(lockDir);
466
+ const prefix = `${basename(lockDir)}.stale-`;
467
+ const warnings: string[] = [];
468
+ let entries: string[];
469
+ try {
470
+ entries = readdirSync(parent);
471
+ } catch {
472
+ return warnings;
473
+ }
474
+ for (const entry of entries) {
475
+ if (!entry.startsWith(prefix)) continue;
476
+ try {
477
+ rmSync(join(parent, entry), { recursive: true, force: true });
478
+ } catch (error) {
479
+ warnings.push(`could not sweep the stale lease quarantine ${entry}: ${error}`);
480
+ }
481
+ }
482
+ return warnings;
483
+ }
484
+
485
+ /** The lock dir's inode — the directory-identity fence for check-then-act operations. */
486
+ function lockDirIno(lockDir: string): bigint {
487
+ return statSync(lockDir, { bigint: true }).ino;
488
+ }
489
+
490
+ /**
491
+ * Renew `heartbeat_at` — throws on a lost/foreign lease (the caller reports, never renews).
492
+ *
493
+ * Inode-fenced against the reclaim race (§8.58): a stale reclaimer replaces the lock DIRECTORY
494
+ * (rename + fresh mkdir), so the inode captured before the read must still be the inode after
495
+ * the write — a mismatch means the write may have clobbered a successor's lease, and the throw
496
+ * makes THIS holder stop too. The residual sub-window degrades to BOTH consumers failing
497
+ * closed (the successor's own verifyLease fence rejects the clobbered token) — never to two
498
+ * live consumers.
499
+ */
500
+ export function renewHeartbeat(lockDir: string, token: string, now: () => number): void {
501
+ const inoBefore = lockDirIno(lockDir);
502
+ const lease = readLease(lockDir);
503
+ if (lease === null || lease.token !== token) {
504
+ throw new Error("feedback lease lost — heartbeat not renewed");
505
+ }
506
+ writeLease(lockDir, { ...lease, heartbeat_at: new Date(now()).toISOString() });
507
+ if (lockDirIno(lockDir) !== inoBefore) {
508
+ throw new Error("feedback lease lock dir was replaced during renewal — fencing lost");
509
+ }
510
+ }
511
+
512
+ /** True iff the on-disk lease still carries `token`. Any read failure is false (fail-closed). */
513
+ export function verifyLease(lockDir: string, token: string): boolean {
514
+ const lease = readLease(lockDir);
515
+ return lease !== null && lease.token === token;
516
+ }
517
+
518
+ /**
519
+ * Release the lease — removes the lock dir only on token match; best-effort, never throws.
520
+ * Inode-fenced like `renewHeartbeat`: the verify-then-remove window is re-checked against the
521
+ * directory identity so a mid-release reclaim is (almost) never deleted; the residual window
522
+ * degrades to the successor failing closed, never to misdelivery.
523
+ */
524
+ export function releaseLease(lockDir: string, token: string): void {
525
+ try {
526
+ const inoBefore = lockDirIno(lockDir);
527
+ if (!verifyLease(lockDir, token)) return;
528
+ if (lockDirIno(lockDir) !== inoBefore) return; // replaced mid-verify — not ours anymore
529
+ rmSync(lockDir, { recursive: true, force: true });
530
+ } catch {
531
+ // best-effort — a leftover lock dir goes stale and is reclaimed by the next open
532
+ }
533
+ }