dev-loops 0.1.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 (156) hide show
  1. package/.pi/dev-loop/defaults.yaml +477 -0
  2. package/AGENTS.md +25 -0
  3. package/CHANGELOG.md +18 -0
  4. package/LICENSE +21 -0
  5. package/README.md +178 -0
  6. package/agents/dev-loop.agent.md +82 -0
  7. package/agents/developer.agent.md +37 -0
  8. package/agents/docs.agent.md +33 -0
  9. package/agents/fixer.agent.md +53 -0
  10. package/agents/quality.agent.md +28 -0
  11. package/agents/refiner.agent.md +87 -0
  12. package/agents/review.agent.md +64 -0
  13. package/cli/index.mjs +424 -0
  14. package/extension/README.md +233 -0
  15. package/extension/checks.ts +94 -0
  16. package/extension/index.ts +131 -0
  17. package/extension/post-merge-update.ts +512 -0
  18. package/extension/presentation.ts +107 -0
  19. package/lib/dev-loops-core.mjs +284 -0
  20. package/package.json +103 -0
  21. package/scripts/README.md +1007 -0
  22. package/scripts/_cli-primitives.mjs +10 -0
  23. package/scripts/_core-helpers.mjs +30 -0
  24. package/scripts/docs/validate-links.mjs +567 -0
  25. package/scripts/docs/validate-no-duplicate-rules.mjs +250 -0
  26. package/scripts/github/_review-thread-mutations.mjs +214 -0
  27. package/scripts/github/capture-review-threads.mjs +180 -0
  28. package/scripts/github/create-draft-pr.mjs +108 -0
  29. package/scripts/github/detect-checkpoint-evidence.mjs +393 -0
  30. package/scripts/github/detect-linked-issue-pr.mjs +331 -0
  31. package/scripts/github/manage-sub-issues.mjs +394 -0
  32. package/scripts/github/probe-copilot-review.mjs +323 -0
  33. package/scripts/github/ready-for-review.mjs +93 -0
  34. package/scripts/github/reconcile-draft-gate.mjs +328 -0
  35. package/scripts/github/reply-resolve-review-thread.mjs +42 -0
  36. package/scripts/github/reply-resolve-review-threads.mjs +329 -0
  37. package/scripts/github/request-copilot-review.mjs +551 -0
  38. package/scripts/github/resolve-tracker-local-spec.mjs +205 -0
  39. package/scripts/github/stage-reviewer-draft.mjs +191 -0
  40. package/scripts/github/upsert-checkpoint-verdict.mjs +694 -0
  41. package/scripts/github/verify-fresh-review-context.mjs +125 -0
  42. package/scripts/github/write-gate-findings-log.mjs +212 -0
  43. package/scripts/loop/_checkpoint-io.mjs +55 -0
  44. package/scripts/loop/_checkpoint-paths.mjs +28 -0
  45. package/scripts/loop/_handoff-contract.mjs +230 -0
  46. package/scripts/loop/_inspect-run-viewer-adapter.mjs +345 -0
  47. package/scripts/loop/_loop-evidence.mjs +32 -0
  48. package/scripts/loop/_pr-runner-coordination.mjs +611 -0
  49. package/scripts/loop/_stale-runner-detection.mjs +145 -0
  50. package/scripts/loop/_steering-state-file.mjs +134 -0
  51. package/scripts/loop/build-handoff-envelope.mjs +181 -0
  52. package/scripts/loop/checkpoint-contract.mjs +49 -0
  53. package/scripts/loop/conductor-monitor.mjs +1850 -0
  54. package/scripts/loop/conductor.mjs +214 -0
  55. package/scripts/loop/copilot-pr-handoff.mjs +493 -0
  56. package/scripts/loop/debt-remediate.mjs +304 -0
  57. package/scripts/loop/detect-change-scope.mjs +102 -0
  58. package/scripts/loop/detect-copilot-loop-state.mjs +454 -0
  59. package/scripts/loop/detect-copilot-session-activity.mjs +186 -0
  60. package/scripts/loop/detect-initial-copilot-pr-state.mjs +318 -0
  61. package/scripts/loop/detect-internal-only-pr.mjs +270 -0
  62. package/scripts/loop/detect-issue-refinement-artifact.mjs +163 -0
  63. package/scripts/loop/detect-pr-gate-coordination-state.mjs +509 -0
  64. package/scripts/loop/detect-reviewer-loop-state.mjs +231 -0
  65. package/scripts/loop/detect-stale-runner.mjs +250 -0
  66. package/scripts/loop/detect-tracker-first-loop-state.mjs +76 -0
  67. package/scripts/loop/detect-tracker-pr-state.mjs +102 -0
  68. package/scripts/loop/info.mjs +267 -0
  69. package/scripts/loop/inspect-run-viewer/cli.mjs +117 -0
  70. package/scripts/loop/inspect-run-viewer/constants.mjs +80 -0
  71. package/scripts/loop/inspect-run-viewer/graph.mjs +757 -0
  72. package/scripts/loop/inspect-run-viewer/handoff-envelope-renderer.mjs +398 -0
  73. package/scripts/loop/inspect-run-viewer/inbox.mjs +308 -0
  74. package/scripts/loop/inspect-run-viewer/managed-instance.mjs +750 -0
  75. package/scripts/loop/inspect-run-viewer/rendering.mjs +411 -0
  76. package/scripts/loop/inspect-run-viewer/server.mjs +638 -0
  77. package/scripts/loop/inspect-run-viewer/shared.mjs +103 -0
  78. package/scripts/loop/inspect-run-viewer/status.mjs +715 -0
  79. package/scripts/loop/inspect-run-viewer-ci-changes.mjs +77 -0
  80. package/scripts/loop/inspect-run-viewer.mjs +82 -0
  81. package/scripts/loop/inspect-run.mjs +382 -0
  82. package/scripts/loop/outer-loop.mjs +419 -0
  83. package/scripts/loop/pr-runner-coordination.mjs +143 -0
  84. package/scripts/loop/pre-commit-branch-guard.mjs +68 -0
  85. package/scripts/loop/pre-flight-gate.mjs +236 -0
  86. package/scripts/loop/pre-pr-ready-gate.mjs +183 -0
  87. package/scripts/loop/pre-push-main-guard.mjs +103 -0
  88. package/scripts/loop/pre-write-remote-freshness-guard.mjs +32 -0
  89. package/scripts/loop/print-gates.mjs +42 -0
  90. package/scripts/loop/resolve-dev-loop-startup.mjs +533 -0
  91. package/scripts/loop/run-conductor-cycle.mjs +322 -0
  92. package/scripts/loop/run-queue.mjs +124 -0
  93. package/scripts/loop/run-refinement-audit.mjs +513 -0
  94. package/scripts/loop/run-watch-cycle.mjs +358 -0
  95. package/scripts/loop/steer-loop.mjs +841 -0
  96. package/scripts/loop/ui-designer-review-contract.mjs +76 -0
  97. package/scripts/loop/watch-initial-copilot-pr.mjs +253 -0
  98. package/scripts/projects/add-queue-item.mjs +528 -0
  99. package/scripts/projects/ensure-queue-board.mjs +837 -0
  100. package/scripts/projects/list-queue-items.mjs +489 -0
  101. package/scripts/projects/move-queue-item.mjs +549 -0
  102. package/scripts/projects/reorder-queue-item.mjs +518 -0
  103. package/scripts/refine/_refine-helpers.mjs +258 -0
  104. package/scripts/refine/prose-linkage-detector.mjs +92 -0
  105. package/scripts/refine/refinement-completeness-checker.mjs +88 -0
  106. package/scripts/refine/scope-boundary-cross-checker.mjs +163 -0
  107. package/scripts/refine/tree-integrity-validator.mjs +211 -0
  108. package/scripts/refine/verify.mjs +178 -0
  109. package/scripts/repo-wiki-local.mjs +156 -0
  110. package/scripts/repo-wiki.mjs +119 -0
  111. package/skills/copilot-pr-followup/SKILL.md +380 -0
  112. package/skills/dev-loop/SKILL.md +141 -0
  113. package/skills/dev-loop/scripts/dev-mode-context.mjs +152 -0
  114. package/skills/dev-loop/scripts/dev-mode-context.test.mjs +80 -0
  115. package/skills/dev-loop/scripts/init-phase.mjs +71 -0
  116. package/skills/dev-loop/scripts/log-bash-exit-1.mjs +25 -0
  117. package/skills/dev-loop/scripts/phase-files.mjs +29 -0
  118. package/skills/dev-loop/scripts/post-gate-verdict-fallback.mjs +480 -0
  119. package/skills/dev-loop/scripts/post-gate-verdict-fallback.test.mjs +732 -0
  120. package/skills/dev-loop/scripts/render-template.mjs +82 -0
  121. package/skills/dev-loop/scripts/render-template.test.mjs +63 -0
  122. package/skills/dev-loop/templates/bootstrap-agents.md +26 -0
  123. package/skills/dev-loop/templates/bootstrap-implementation-state.md +31 -0
  124. package/skills/dev-loop/templates/bootstrap-implementation-workflow.md +17 -0
  125. package/skills/dev-loop/templates/dev-mode-retrospective.md +15 -0
  126. package/skills/dev-loop/templates/dev-mode-review.md +17 -0
  127. package/skills/dev-loop/templates/dev-mode-skill-changes.md +11 -0
  128. package/skills/dev-loop/templates/merged-phase-plan.md +19 -0
  129. package/skills/dev-loop/templates/phase-doc.md +27 -0
  130. package/skills/dev-loop/templates/phase-summary.md +13 -0
  131. package/skills/dev-loop/templates/phase-variant.md +15 -0
  132. package/skills/dev-loop/templates/retrospective.md +11 -0
  133. package/skills/dev-loop/templates/review.md +32 -0
  134. package/skills/dev-loop/templates/ui-vision-review.md +55 -0
  135. package/skills/docs/acceptance-criteria-verification.md +21 -0
  136. package/skills/docs/anti-patterns.md +21 -0
  137. package/skills/docs/artifact-authority-contract.md +119 -0
  138. package/skills/docs/confirmation-rules.md +28 -0
  139. package/skills/docs/copilot-ci-status-contract.md +52 -0
  140. package/skills/docs/copilot-loop-operations.md +233 -0
  141. package/skills/docs/debt-remediation-contract.md +107 -0
  142. package/skills/docs/entrypoint-strategies.md +115 -0
  143. package/skills/docs/epic-tree-refinement-procedure.md +234 -0
  144. package/skills/docs/issue-intake-procedure.md +235 -0
  145. package/skills/docs/main-agent-contract.md +72 -0
  146. package/skills/docs/merge-preconditions.md +29 -0
  147. package/skills/docs/pr-lifecycle-contract.md +209 -0
  148. package/skills/docs/public-dev-loop-contract.md +497 -0
  149. package/skills/docs/retrospective-checkpoint-contract.md +159 -0
  150. package/skills/docs/stop-conditions.md +29 -0
  151. package/skills/docs/structural-quality.md +42 -0
  152. package/skills/docs/tracker-first-loop-state.md +281 -0
  153. package/skills/docs/validation-policy.md +27 -0
  154. package/skills/docs/workflow-handoff-contract.md +135 -0
  155. package/skills/final-approval/SKILL.md +19 -0
  156. package/skills/local-implementation/SKILL.md +640 -0
@@ -0,0 +1,611 @@
1
+ import path from "node:path";
2
+ import process from "node:process";
3
+ import { parseRepoSlugParts } from "@dev-loops/core/github/repo-slug";
4
+ import {
5
+ loadStateFile as loadSharedStateFile,
6
+ saveStateFile as saveSharedStateFile,
7
+ withStateFileLock as withSharedStateFileLock,
8
+ } from "./_steering-state-file.mjs";
9
+ export const RUNNER_COORDINATION_SCHEMA_VERSION = 2;
10
+ export const RUNNER_COORDINATION_SUPPORTED_SCHEMA_VERSIONS = Object.freeze([1, 2]);
11
+ export const RUNNER_OWNERSHIP_ERROR = Object.freeze({
12
+ ACTIVE_RUN_EXISTS: "active_run_exists",
13
+ OWNERSHIP_LOST: "ownership_lost",
14
+ OWNERSHIP_MISSING: "ownership_missing",
15
+ RUN_ID_REQUIRED: "run_id_required",
16
+ EXIT_SIGNAL_RECORDED: "exit_signal_recorded",
17
+ });
18
+ function normalizeRepoSlug(repo) {
19
+ const { owner, name } = parseRepoSlugParts(repo, {
20
+ errorMessage: `Invalid repo slug for coordination target path: ${JSON.stringify(repo)}`,
21
+ lowercase: true,
22
+ });
23
+ return `${owner}/${name}`;
24
+ }
25
+ function normalizePr(pr) {
26
+ const number = typeof pr === "number" ? pr : Number(pr);
27
+ if (!Number.isInteger(number) || number <= 0) {
28
+ throw new Error(`Invalid pull request number for runner coordination: ${JSON.stringify(pr)}`);
29
+ }
30
+ return number;
31
+ }
32
+ function normalizeRunId(runId) {
33
+ return typeof runId === "string" && runId.trim().length > 0
34
+ ? runId.trim()
35
+ : null;
36
+ }
37
+ function normalizeSignalReason(reason) {
38
+ if (typeof reason !== "string") return null;
39
+ const trimmed = reason.trim();
40
+ return trimmed.length > 0 ? trimmed.slice(0, 500) : null;
41
+ }
42
+ async function loadRunnerStateFile(filePath) {
43
+ try {
44
+ return await loadSharedStateFile(filePath);
45
+ } catch (error) {
46
+ const message = error instanceof Error ? error.message : String(error);
47
+ throw new Error(`Failed to read runner coordination state file '${filePath}': ${message}`);
48
+ }
49
+ }
50
+ async function saveRunnerStateFile(filePath, state) {
51
+ try {
52
+ return await saveSharedStateFile(filePath, state);
53
+ } catch (error) {
54
+ const message = error instanceof Error ? error.message : String(error);
55
+ throw new Error(`Failed to write runner coordination state file '${filePath}': ${message}`);
56
+ }
57
+ }
58
+ async function withRunnerStateFileLock(filePath, callback) {
59
+ try {
60
+ return await withSharedStateFileLock(filePath, callback);
61
+ } catch (error) {
62
+ const message = error instanceof Error ? error.message : String(error);
63
+ throw new Error(`Failed to acquire runner coordination state lock for '${filePath}': ${message}`);
64
+ }
65
+ }
66
+ export function defaultRunnerCoordinationFilePathForTarget({ repo, pr }, cwd = process.cwd()) {
67
+ const { owner, name } = parseRepoSlugParts(repo, {
68
+ errorMessage: `Invalid repo slug for coordination target path: ${JSON.stringify(repo)}`,
69
+ lowercase: true,
70
+ });
71
+ const normalizedPr = normalizePr(pr);
72
+ return path.join(cwd, ".pi", "runner-coordination", owner, name, `pr-${normalizedPr}.json`);
73
+ }
74
+ export function createRunnerCoordinationState({ repo, pr, runId = null, now = new Date().toISOString() }) {
75
+ const normalizedRepo = normalizeRepoSlug(repo);
76
+ const normalizedPr = normalizePr(pr);
77
+ const normalizedRunId = normalizeRunId(runId);
78
+ return {
79
+ schemaVersion: RUNNER_COORDINATION_SCHEMA_VERSION,
80
+ target: {
81
+ repo: normalizedRepo,
82
+ pr: normalizedPr,
83
+ },
84
+ activeRun: normalizedRunId === null
85
+ ? null
86
+ : {
87
+ runId: normalizedRunId,
88
+ claimedAt: now,
89
+ updatedAt: now,
90
+ },
91
+ previousRun: null,
92
+ history: normalizedRunId === null
93
+ ? []
94
+ : [{ type: "claim", runId: normalizedRunId, at: now }],
95
+ exitSignals: [],
96
+ };
97
+ }
98
+ function normalizeExitSignals(raw) {
99
+ if (!Array.isArray(raw)) return [];
100
+ const out = [];
101
+ for (const entry of raw) {
102
+ if (!entry || typeof entry !== "object") continue;
103
+ const runId = normalizeRunId(entry.runId);
104
+ if (runId === null) continue;
105
+ out.push({
106
+ runId,
107
+ at: typeof entry.at === "string" ? entry.at : null,
108
+ reason: normalizeSignalReason(entry.reason),
109
+ });
110
+ }
111
+ return out;
112
+ }
113
+ export function normalizeRunnerCoordinationState(raw, { repo, pr } = {}) {
114
+ if (!raw || typeof raw !== "object") {
115
+ throw new Error("Runner coordination state must be a non-null object");
116
+ }
117
+ if (!RUNNER_COORDINATION_SUPPORTED_SCHEMA_VERSIONS.includes(raw.schemaVersion)) {
118
+ throw new Error(
119
+ `Unsupported runner coordination schemaVersion ${JSON.stringify(raw.schemaVersion)}; expected one of ${JSON.stringify(RUNNER_COORDINATION_SUPPORTED_SCHEMA_VERSIONS)}`,
120
+ );
121
+ }
122
+ const target = raw.target;
123
+ if (!target || typeof target !== "object") {
124
+ throw new Error("Runner coordination state target is missing");
125
+ }
126
+ const normalizedRepo = normalizeRepoSlug(target.repo);
127
+ const normalizedPr = normalizePr(target.pr);
128
+ if (repo !== undefined && normalizeRepoSlug(repo) !== normalizedRepo) {
129
+ throw new Error(
130
+ `Runner coordination target repo ${JSON.stringify(normalizedRepo)} does not match expected ${JSON.stringify(normalizeRepoSlug(repo))}`,
131
+ );
132
+ }
133
+ if (pr !== undefined && normalizePr(pr) !== normalizedPr) {
134
+ throw new Error(
135
+ `Runner coordination target pr ${JSON.stringify(normalizedPr)} does not match expected ${JSON.stringify(normalizePr(pr))}`,
136
+ );
137
+ }
138
+ const activeRun = raw.activeRun && typeof raw.activeRun === "object"
139
+ ? {
140
+ runId: normalizeRunId(raw.activeRun.runId),
141
+ claimedAt: typeof raw.activeRun.claimedAt === "string" ? raw.activeRun.claimedAt : null,
142
+ updatedAt: typeof raw.activeRun.updatedAt === "string" ? raw.activeRun.updatedAt : null,
143
+ }
144
+ : null;
145
+ const previousRun = raw.previousRun && typeof raw.previousRun === "object"
146
+ ? {
147
+ runId: normalizeRunId(raw.previousRun.runId),
148
+ replacedAt: typeof raw.previousRun.replacedAt === "string" ? raw.previousRun.replacedAt : null,
149
+ replacedByRunId: normalizeRunId(raw.previousRun.replacedByRunId),
150
+ }
151
+ : null;
152
+ return {
153
+ schemaVersion: RUNNER_COORDINATION_SCHEMA_VERSION,
154
+ target: {
155
+ repo: normalizedRepo,
156
+ pr: normalizedPr,
157
+ },
158
+ activeRun: activeRun?.runId
159
+ ? {
160
+ runId: activeRun.runId,
161
+ claimedAt: activeRun.claimedAt,
162
+ updatedAt: activeRun.updatedAt,
163
+ }
164
+ : null,
165
+ previousRun: previousRun?.runId
166
+ ? {
167
+ runId: previousRun.runId,
168
+ replacedAt: previousRun.replacedAt,
169
+ replacedByRunId: previousRun.replacedByRunId,
170
+ }
171
+ : null,
172
+ history: Array.isArray(raw.history) ? raw.history : [],
173
+ exitSignals: normalizeExitSignals(raw.exitSignals),
174
+ };
175
+ }
176
+ function buildConflict({ error, repo, pr, runId, activeRun, filePath, message, exitSignals = null }) {
177
+ const payload = {
178
+ ok: false,
179
+ error,
180
+ repo,
181
+ pr,
182
+ runId,
183
+ activeRun,
184
+ filePath,
185
+ message,
186
+ };
187
+ if (exitSignals !== null) {
188
+ payload.exitSignals = exitSignals;
189
+ }
190
+ return payload;
191
+ }
192
+ export async function loadRunnerCoordinationState({ repo, pr, cwd = process.cwd(), filePath = null } = {}) {
193
+ const normalizedRepo = normalizeRepoSlug(repo);
194
+ const normalizedPr = normalizePr(pr);
195
+ const resolvedPath = filePath ?? defaultRunnerCoordinationFilePathForTarget({ repo: normalizedRepo, pr: normalizedPr }, cwd);
196
+ const raw = await loadRunnerStateFile(resolvedPath);
197
+ if (raw === null) {
198
+ return { filePath: resolvedPath, state: null };
199
+ }
200
+ return {
201
+ filePath: resolvedPath,
202
+ state: normalizeRunnerCoordinationState(raw, { repo: normalizedRepo, pr: normalizedPr }),
203
+ };
204
+ }
205
+ export async function claimRunnerOwnership({
206
+ repo,
207
+ pr,
208
+ runId,
209
+ cwd = process.cwd(),
210
+ filePath = null,
211
+ mode = "claim",
212
+ now = new Date().toISOString(),
213
+ } = {}) {
214
+ const normalizedRepo = normalizeRepoSlug(repo);
215
+ const normalizedPr = normalizePr(pr);
216
+ const normalizedRunId = normalizeRunId(runId);
217
+ if (normalizedRunId === null) {
218
+ return buildConflict({
219
+ error: RUNNER_OWNERSHIP_ERROR.RUN_ID_REQUIRED,
220
+ repo: normalizedRepo,
221
+ pr: normalizedPr,
222
+ runId: null,
223
+ activeRun: null,
224
+ filePath: filePath ?? defaultRunnerCoordinationFilePathForTarget({ repo: normalizedRepo, pr: normalizedPr }, cwd),
225
+ message: "Runner coordination claim requires a non-empty run id.",
226
+ });
227
+ }
228
+ const resolvedPath = filePath ?? defaultRunnerCoordinationFilePathForTarget({ repo: normalizedRepo, pr: normalizedPr }, cwd);
229
+ return withRunnerStateFileLock(resolvedPath, async () => {
230
+ const raw = await loadRunnerStateFile(resolvedPath);
231
+ const state = raw === null
232
+ ? createRunnerCoordinationState({ repo: normalizedRepo, pr: normalizedPr })
233
+ : normalizeRunnerCoordinationState(raw, { repo: normalizedRepo, pr: normalizedPr });
234
+ const activeRun = state.activeRun;
235
+ if (activeRun === null) {
236
+ const nextState = {
237
+ ...state,
238
+ activeRun: {
239
+ runId: normalizedRunId,
240
+ claimedAt: now,
241
+ updatedAt: now,
242
+ },
243
+ history: [...state.history, { type: "claim", runId: normalizedRunId, at: now }],
244
+ };
245
+ await saveRunnerStateFile(resolvedPath, nextState);
246
+ return {
247
+ ok: true,
248
+ status: "claimed_new",
249
+ repo: normalizedRepo,
250
+ pr: normalizedPr,
251
+ runId: normalizedRunId,
252
+ activeRun: nextState.activeRun,
253
+ previousRun: nextState.previousRun,
254
+ exitSignals: nextState.exitSignals,
255
+ filePath: resolvedPath,
256
+ };
257
+ }
258
+ if (activeRun.runId === normalizedRunId) {
259
+ const nextState = {
260
+ ...state,
261
+ activeRun: {
262
+ ...activeRun,
263
+ claimedAt: activeRun.claimedAt ?? now,
264
+ updatedAt: now,
265
+ },
266
+ history: [...state.history, { type: "refresh", runId: normalizedRunId, at: now }],
267
+ };
268
+ await saveRunnerStateFile(resolvedPath, nextState);
269
+ return {
270
+ ok: true,
271
+ status: "refreshed",
272
+ repo: normalizedRepo,
273
+ pr: normalizedPr,
274
+ runId: normalizedRunId,
275
+ activeRun: nextState.activeRun,
276
+ previousRun: nextState.previousRun,
277
+ exitSignals: nextState.exitSignals,
278
+ filePath: resolvedPath,
279
+ };
280
+ }
281
+ if (mode !== "takeover") {
282
+ return buildConflict({
283
+ error: RUNNER_OWNERSHIP_ERROR.ACTIVE_RUN_EXISTS,
284
+ repo: normalizedRepo,
285
+ pr: normalizedPr,
286
+ runId: normalizedRunId,
287
+ activeRun,
288
+ filePath: resolvedPath,
289
+ exitSignals: state.exitSignals,
290
+ message: `PR ${normalizedRepo}#${normalizedPr} is already owned by run ${activeRun.runId}. Claim failed closed.`,
291
+ });
292
+ }
293
+ const nextState = {
294
+ ...state,
295
+ activeRun: {
296
+ runId: normalizedRunId,
297
+ claimedAt: now,
298
+ updatedAt: now,
299
+ },
300
+ previousRun: {
301
+ runId: activeRun.runId,
302
+ replacedAt: now,
303
+ replacedByRunId: normalizedRunId,
304
+ },
305
+ history: [...state.history, {
306
+ type: "takeover",
307
+ runId: normalizedRunId,
308
+ previousRunId: activeRun.runId,
309
+ at: now,
310
+ }],
311
+ };
312
+ await saveRunnerStateFile(resolvedPath, nextState);
313
+ return {
314
+ ok: true,
315
+ status: "taken_over",
316
+ repo: normalizedRepo,
317
+ pr: normalizedPr,
318
+ runId: normalizedRunId,
319
+ activeRun: nextState.activeRun,
320
+ previousRun: nextState.previousRun,
321
+ exitSignals: nextState.exitSignals,
322
+ filePath: resolvedPath,
323
+ };
324
+ });
325
+ }
326
+ export async function assertRunnerOwnership({
327
+ repo,
328
+ pr,
329
+ runId,
330
+ cwd = process.cwd(),
331
+ filePath = null,
332
+ requireExisting = false,
333
+ } = {}) {
334
+ const normalizedRepo = normalizeRepoSlug(repo);
335
+ const normalizedPr = normalizePr(pr);
336
+ const normalizedRunId = normalizeRunId(runId);
337
+ const resolvedPath = filePath ?? defaultRunnerCoordinationFilePathForTarget({ repo: normalizedRepo, pr: normalizedPr }, cwd);
338
+ if (normalizedRunId === null) {
339
+ return buildConflict({
340
+ error: RUNNER_OWNERSHIP_ERROR.RUN_ID_REQUIRED,
341
+ repo: normalizedRepo,
342
+ pr: normalizedPr,
343
+ runId: null,
344
+ activeRun: null,
345
+ filePath: resolvedPath,
346
+ message: "Runner coordination ownership check requires a non-empty run id.",
347
+ });
348
+ }
349
+ const raw = await loadRunnerStateFile(resolvedPath);
350
+ if (raw === null) {
351
+ if (!requireExisting) {
352
+ return {
353
+ ok: true,
354
+ status: "no_owner_record",
355
+ repo: normalizedRepo,
356
+ pr: normalizedPr,
357
+ runId: normalizedRunId,
358
+ activeRun: null,
359
+ exitSignals: [],
360
+ filePath: resolvedPath,
361
+ };
362
+ }
363
+ return buildConflict({
364
+ error: RUNNER_OWNERSHIP_ERROR.OWNERSHIP_MISSING,
365
+ repo: normalizedRepo,
366
+ pr: normalizedPr,
367
+ runId: normalizedRunId,
368
+ activeRun: null,
369
+ filePath: resolvedPath,
370
+ message: `PR ${normalizedRepo}#${normalizedPr} has no runner ownership record for async run ${normalizedRunId}.`,
371
+ });
372
+ }
373
+ const state = normalizeRunnerCoordinationState(raw, { repo: normalizedRepo, pr: normalizedPr });
374
+ if (state.activeRun === null) {
375
+ if (!requireExisting) {
376
+ return {
377
+ ok: true,
378
+ status: "no_owner_record",
379
+ repo: normalizedRepo,
380
+ pr: normalizedPr,
381
+ runId: normalizedRunId,
382
+ activeRun: null,
383
+ previousRun: state.previousRun,
384
+ exitSignals: state.exitSignals,
385
+ filePath: resolvedPath,
386
+ };
387
+ }
388
+ return buildConflict({
389
+ error: RUNNER_OWNERSHIP_ERROR.OWNERSHIP_MISSING,
390
+ repo: normalizedRepo,
391
+ pr: normalizedPr,
392
+ runId: normalizedRunId,
393
+ activeRun: null,
394
+ filePath: resolvedPath,
395
+ message: `PR ${normalizedRepo}#${normalizedPr} has no active runner ownership record for async run ${normalizedRunId}.`,
396
+ });
397
+ }
398
+ if (state.activeRun.runId === normalizedRunId) {
399
+ return {
400
+ ok: true,
401
+ status: "owner_confirmed",
402
+ repo: normalizedRepo,
403
+ pr: normalizedPr,
404
+ runId: normalizedRunId,
405
+ activeRun: state.activeRun,
406
+ previousRun: state.previousRun,
407
+ exitSignals: state.exitSignals,
408
+ filePath: resolvedPath,
409
+ };
410
+ }
411
+ return buildConflict({
412
+ error: RUNNER_OWNERSHIP_ERROR.OWNERSHIP_LOST,
413
+ repo: normalizedRepo,
414
+ pr: normalizedPr,
415
+ runId: normalizedRunId,
416
+ activeRun: state.activeRun,
417
+ filePath: resolvedPath,
418
+ exitSignals: state.exitSignals,
419
+ message: state.activeRun?.runId
420
+ ? `PR ${normalizedRepo}#${normalizedPr} is now owned by run ${state.activeRun.runId}; run ${normalizedRunId} must stop.`
421
+ : `PR ${normalizedRepo}#${normalizedPr} no longer has an active runner ownership record; run ${normalizedRunId} must stop.`,
422
+ });
423
+ }
424
+ export async function releaseRunnerOwnership({
425
+ repo,
426
+ pr,
427
+ runId,
428
+ cwd = process.cwd(),
429
+ filePath = null,
430
+ now = new Date().toISOString(),
431
+ } = {}) {
432
+ const normalizedRepo = normalizeRepoSlug(repo);
433
+ const normalizedPr = normalizePr(pr);
434
+ const normalizedRunId = normalizeRunId(runId);
435
+ if (normalizedRunId === null) {
436
+ return buildConflict({
437
+ error: RUNNER_OWNERSHIP_ERROR.RUN_ID_REQUIRED,
438
+ repo: normalizedRepo,
439
+ pr: normalizedPr,
440
+ runId: null,
441
+ activeRun: null,
442
+ filePath: filePath ?? defaultRunnerCoordinationFilePathForTarget({ repo: normalizedRepo, pr: normalizedPr }, cwd),
443
+ message: "Runner coordination release requires a non-empty run id.",
444
+ });
445
+ }
446
+ const resolvedPath = filePath ?? defaultRunnerCoordinationFilePathForTarget({ repo: normalizedRepo, pr: normalizedPr }, cwd);
447
+ return withRunnerStateFileLock(resolvedPath, async () => {
448
+ const raw = await loadRunnerStateFile(resolvedPath);
449
+ if (raw === null) {
450
+ return {
451
+ ok: true,
452
+ status: "release_noop",
453
+ repo: normalizedRepo,
454
+ pr: normalizedPr,
455
+ runId: normalizedRunId,
456
+ activeRun: null,
457
+ exitSignals: [],
458
+ filePath: resolvedPath,
459
+ };
460
+ }
461
+ const state = normalizeRunnerCoordinationState(raw, { repo: normalizedRepo, pr: normalizedPr });
462
+ if (state.activeRun?.runId !== normalizedRunId) {
463
+ return buildConflict({
464
+ error: RUNNER_OWNERSHIP_ERROR.OWNERSHIP_LOST,
465
+ repo: normalizedRepo,
466
+ pr: normalizedPr,
467
+ runId: normalizedRunId,
468
+ activeRun: state.activeRun,
469
+ filePath: resolvedPath,
470
+ exitSignals: state.exitSignals,
471
+ message: state.activeRun?.runId
472
+ ? `Cannot release PR ${normalizedRepo}#${normalizedPr}: active owner is ${state.activeRun.runId}, not ${normalizedRunId}.`
473
+ : `Cannot release PR ${normalizedRepo}#${normalizedPr}: no active owner record remains for ${normalizedRunId}.`,
474
+ });
475
+ }
476
+ const nextState = {
477
+ ...state,
478
+ activeRun: null,
479
+ previousRun: {
480
+ runId: normalizedRunId,
481
+ replacedAt: now,
482
+ replacedByRunId: null,
483
+ },
484
+ history: [...state.history, { type: "release", runId: normalizedRunId, at: now }],
485
+ };
486
+ await saveRunnerStateFile(resolvedPath, nextState);
487
+ return {
488
+ ok: true,
489
+ status: "released",
490
+ repo: normalizedRepo,
491
+ pr: normalizedPr,
492
+ runId: normalizedRunId,
493
+ activeRun: null,
494
+ previousRun: nextState.previousRun,
495
+ exitSignals: nextState.exitSignals,
496
+ filePath: resolvedPath,
497
+ };
498
+ });
499
+ }
500
+ export async function recordExitSignalForRunner({
501
+ repo,
502
+ pr,
503
+ runId,
504
+ reason = null,
505
+ cwd = process.cwd(),
506
+ filePath = null,
507
+ now = new Date().toISOString(),
508
+ requireActiveOwner = true,
509
+ } = {}) {
510
+ const normalizedRepo = normalizeRepoSlug(repo);
511
+ const normalizedPr = normalizePr(pr);
512
+ const normalizedRunId = normalizeRunId(runId);
513
+ if (normalizedRunId === null) {
514
+ return buildConflict({
515
+ error: RUNNER_OWNERSHIP_ERROR.RUN_ID_REQUIRED,
516
+ repo: normalizedRepo,
517
+ pr: normalizedPr,
518
+ runId: null,
519
+ activeRun: null,
520
+ filePath: filePath ?? defaultRunnerCoordinationFilePathForTarget({ repo: normalizedRepo, pr: normalizedPr }, cwd),
521
+ message: "Recording an exit signal requires a non-empty run id.",
522
+ });
523
+ }
524
+ const resolvedPath = filePath ?? defaultRunnerCoordinationFilePathForTarget({ repo: normalizedRepo, pr: normalizedPr }, cwd);
525
+ return withRunnerStateFileLock(resolvedPath, async () => {
526
+ const raw = await loadRunnerStateFile(resolvedPath);
527
+ if (raw === null) {
528
+ return buildConflict({
529
+ error: RUNNER_OWNERSHIP_ERROR.OWNERSHIP_MISSING,
530
+ repo: normalizedRepo,
531
+ pr: normalizedPr,
532
+ runId: normalizedRunId,
533
+ activeRun: null,
534
+ filePath: resolvedPath,
535
+ message: `Cannot record exit signal: PR ${normalizedRepo}#${normalizedPr} has no runner coordination record.`,
536
+ });
537
+ }
538
+ const state = normalizeRunnerCoordinationState(raw, { repo: normalizedRepo, pr: normalizedPr });
539
+ if (requireActiveOwner && (state.activeRun === null || state.activeRun.runId !== normalizedRunId)) {
540
+ return buildConflict({
541
+ error: RUNNER_OWNERSHIP_ERROR.OWNERSHIP_LOST,
542
+ repo: normalizedRepo,
543
+ pr: normalizedPr,
544
+ runId: normalizedRunId,
545
+ activeRun: state.activeRun,
546
+ filePath: resolvedPath,
547
+ exitSignals: state.exitSignals,
548
+ message: state.activeRun?.runId
549
+ ? `Cannot record exit signal: PR ${normalizedRepo}#${normalizedPr} is owned by ${state.activeRun.runId}, not ${normalizedRunId}.`
550
+ : `Cannot record exit signal: PR ${normalizedRepo}#${normalizedPr} no longer has an active owner.`,
551
+ });
552
+ }
553
+ const nextSignal = {
554
+ runId: normalizedRunId,
555
+ at: now,
556
+ reason: normalizeSignalReason(reason),
557
+ };
558
+ const nextState = {
559
+ ...state,
560
+ exitSignals: [...(state.exitSignals || []), nextSignal],
561
+ };
562
+ await saveRunnerStateFile(resolvedPath, nextState);
563
+ return {
564
+ ok: true,
565
+ status: "exit_signal_recorded",
566
+ repo: normalizedRepo,
567
+ pr: normalizedPr,
568
+ runId: normalizedRunId,
569
+ activeRun: state.activeRun,
570
+ previousRun: state.previousRun,
571
+ exitSignals: nextState.exitSignals,
572
+ filePath: resolvedPath,
573
+ };
574
+ });
575
+ }
576
+ export async function ensureAsyncRunnerOwnership({
577
+ repo,
578
+ pr,
579
+ env = process.env,
580
+ cwd = process.cwd(),
581
+ claimIfMissing = true,
582
+ requireExisting = false,
583
+ } = {}) {
584
+ const runId = normalizeRunId(env?.PI_SUBAGENT_RUN_ID);
585
+ if (runId === null) {
586
+ return {
587
+ ok: true,
588
+ status: "skipped_no_async_run_id",
589
+ repo: normalizeRepoSlug(repo),
590
+ pr: normalizePr(pr),
591
+ runId: null,
592
+ activeRun: null,
593
+ exitSignals: [],
594
+ filePath: defaultRunnerCoordinationFilePathForTarget({ repo, pr }, cwd),
595
+ };
596
+ }
597
+ const asserted = await assertRunnerOwnership({ repo, pr, runId, cwd, requireExisting });
598
+ if (asserted.ok && asserted.status !== "no_owner_record") {
599
+ return asserted;
600
+ }
601
+ if (!claimIfMissing) {
602
+ return asserted;
603
+ }
604
+ if (asserted.ok && asserted.status === "no_owner_record") {
605
+ return claimRunnerOwnership({ repo, pr, runId, cwd, mode: "claim" });
606
+ }
607
+ if (asserted.error !== RUNNER_OWNERSHIP_ERROR.OWNERSHIP_MISSING) {
608
+ return asserted;
609
+ }
610
+ return claimRunnerOwnership({ repo, pr, runId, cwd, mode: "claim" });
611
+ }