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,1850 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync } from "node:fs";
3
+ import { access, open, readFile, readdir } from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { runChild, requireOptionValue } from "../_cli-primitives.mjs";
7
+ import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
8
+ import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
9
+ import { autoDetectSnapshot } from "./detect-copilot-loop-state.mjs";
10
+ import {
11
+ buildHandoffContractForResumeAction,
12
+ compareHandoffContracts,
13
+ parseRecordedHandoffContract,
14
+ } from "./_handoff-contract.mjs";
15
+ import { interpretLoopState, summarizeLoopInterpretation } from "@dev-loops/core/loop/copilot-loop-state";
16
+ const USAGE = `Usage: conductor-monitor.mjs --repo <owner/name> [--auto-resume]
17
+ Aggregate Copilot-loop status across all open PRs in one repo.
18
+ Required:
19
+ --repo <owner/name> Repository slug (e.g. owner/repo)
20
+ Optional:
21
+ --auto-resume Inspect documented async run artifacts, detect orphaned
22
+ PR follow-up runs, and emit deterministic resume plans.
23
+ Success output (stdout, JSON):
24
+ {
25
+ "ok": true,
26
+ "repo": "owner/repo",
27
+ "checkedAt": "...",
28
+ "prCount": 2,
29
+ "queueStatus": "queue_complete"|"monitoring"|"attention_needed",
30
+ "needsAttentionCount": 1,
31
+ "summary": {
32
+ "waiting": 1,
33
+ "needsAttention": 1,
34
+ "blocked": 0,
35
+ "done": 0
36
+ },
37
+ "prs": [
38
+ {
39
+ "number": 17,
40
+ "title": "...",
41
+ "url": "...",
42
+ "isDraft": false,
43
+ "headRefName": "...",
44
+ "authorLogin": "...",
45
+ "state": "waiting_for_copilot_review",
46
+ "nextAction": "...",
47
+ "loopDisposition": "pending",
48
+ "terminal": false,
49
+ "needsAttention": false,
50
+ "snapshot": {
51
+ "ciStatus": "none",
52
+ "copilotReviewRequestStatus": "requested",
53
+ "copilotReviewOnCurrentHead": false,
54
+ "unresolvedThreadCount": 0,
55
+ "actionableThreadCount": 0,
56
+ "copilotReviewRoundCount": 0
57
+ }
58
+ }
59
+ ]
60
+ }
61
+ Additional success fields when --auto-resume is present:
62
+ {
63
+ "autoResumeRequested": true,
64
+ "orphanedPrCount": 1,
65
+ "resumePlanCount": 1,
66
+ "manualAttentionCount": 0,
67
+ "resumePlans": [...],
68
+ "needsManualAttention": [...]
69
+ }
70
+ Queue status values:
71
+ queue_complete No open PRs remain in the repo queue
72
+ monitoring Open PRs exist, but all are in healthy wait states
73
+ attention_needed At least one open PR needs human-in-the-loop follow-up
74
+ Error output (stderr, JSON):
75
+ Argument/usage errors:
76
+ { "ok": false, "error": "...", "usage": "..." }
77
+ gh/runtime failures:
78
+ { "ok": false, "error": "..." }
79
+ Exit codes:
80
+ 0 Success
81
+ 1 Argument error, gh failure, or indeterminate PR status`.trim();
82
+ const parseError = buildParseError(USAGE);
83
+ const OPEN_PR_LIST_LIMIT = 1000;
84
+ const DEFAULT_SESSION_ROOT = path.join(os.homedir(), ".pi", "agent", "sessions");
85
+ const RUN_STATE = {
86
+ COMPLETED: "completed",
87
+ FAILED: "failed",
88
+ PAUSED: "paused",
89
+ RUNNING: "running",
90
+ QUEUED: "queued",
91
+ UNKNOWN: "unknown",
92
+ };
93
+ const RESUME_ACTION = {
94
+ NEEDS_FEEDBACK_FIX: "needs_feedback_fix",
95
+ NEEDS_REPLY_RESOLVE: "needs_reply_resolve",
96
+ NEEDS_REREQUEST_OR_WATCH: "needs_rerequest_or_watch",
97
+ AWAIT_FINAL_APPROVAL: "await_final_approval",
98
+ AWAIT_MERGE_AUTHORIZATION: "await_merge_authorization",
99
+ AWAIT_READY_FOR_REVIEW_AUTHORIZATION: "await_ready_for_review_authorization",
100
+ DONE_OR_MERGED: "done_or_merged",
101
+ NEEDS_MANUAL_ATTENTION: "needs_manual_attention",
102
+ };
103
+ const MANUAL_REASON = {
104
+ AMBIGUOUS_PR_IDENTITY: "ambiguous_pr_identity",
105
+ MISSING_PR_IDENTITY: "missing_pr_identity",
106
+ ARTIFACT_LIVE_STATE_CONFLICT: "artifact_live_state_conflict",
107
+ MISSING_OUTPUT_ARTIFACT: "missing_output_artifact",
108
+ UNCLASSIFIED_ARTIFACT_STATE: "unclassified_artifact_state",
109
+ MULTIPLE_CANDIDATE_RUNS: "multiple_candidate_runs",
110
+ STALE_WORKTREE_MISSING_RESUME_INPUTS: "stale_worktree_missing_resume_inputs",
111
+ HANDOFF_CONTRACT_INCOMPLETE: "handoff_contract_incomplete",
112
+ HANDOFF_CONTRACT_INVALID: "handoff_contract_invalid",
113
+ HANDOFF_CONTRACT_MISMATCH: "handoff_contract_mismatch",
114
+ };
115
+ function parseCliArgs(argv) {
116
+ const args = [...argv];
117
+ const options = {
118
+ help: false,
119
+ repo: undefined,
120
+ autoResume: false,
121
+ };
122
+ while (args.length > 0) {
123
+ const token = args.shift();
124
+ if (token === "--help" || token === "-h") {
125
+ options.help = true;
126
+ return options;
127
+ }
128
+ if (token === "--repo") {
129
+ options.repo = requireOptionValue(args, "--repo", parseError).trim();
130
+ continue;
131
+ }
132
+ if (token === "--auto-resume") {
133
+ options.autoResume = true;
134
+ continue;
135
+ }
136
+ throw parseError(`Unknown argument: ${token}`);
137
+ }
138
+ if (options.repo === undefined) {
139
+ throw parseError("conductor-monitor requires --repo <owner/name>");
140
+ }
141
+ try {
142
+ parseRepoSlug(options.repo);
143
+ } catch (error) {
144
+ throw parseError(error instanceof Error ? error.message : String(error));
145
+ }
146
+ return options;
147
+ }
148
+ async function listOpenPrs({ repo }, { env, ghCommand }) {
149
+ const result = await runChild(
150
+ ghCommand,
151
+ [
152
+ "pr",
153
+ "list",
154
+ "--repo",
155
+ repo,
156
+ "--state",
157
+ "open",
158
+ "--limit",
159
+ String(OPEN_PR_LIST_LIMIT),
160
+ "--json",
161
+ "number,title,url,isDraft,headRefName,author",
162
+ ],
163
+ env,
164
+ );
165
+ if (result.code !== 0) {
166
+ const detail = result.stderr.trim() || `exit code ${result.code}`;
167
+ throw new Error(`gh command failed: ${detail}`);
168
+ }
169
+ const payload = parseJsonText(result.stdout);
170
+ if (!Array.isArray(payload)) {
171
+ throw new Error("Invalid gh pr list payload: expected an array");
172
+ }
173
+ return payload
174
+ .map((pr) => ({
175
+ number: Number.isInteger(pr?.number) ? pr.number : null,
176
+ title: typeof pr?.title === "string" ? pr.title : "",
177
+ url: typeof pr?.url === "string" ? pr.url : null,
178
+ isDraft: Boolean(pr?.isDraft),
179
+ headRefName: typeof pr?.headRefName === "string" ? pr.headRefName : null,
180
+ authorLogin: typeof pr?.author?.login === "string" ? pr.author.login : null,
181
+ }))
182
+ .filter((pr) => pr.number !== null)
183
+ .sort((left, right) => left.number - right.number);
184
+ }
185
+ function summarizePrDisposition(loopDisposition) {
186
+ switch (loopDisposition) {
187
+ case "pending":
188
+ return { bucket: "waiting", needsAttention: false };
189
+ case "blocked":
190
+ return { bucket: "blocked", needsAttention: true };
191
+ case "done":
192
+ return { bucket: "done", needsAttention: false };
193
+ case "unresolved_feedback":
194
+ case "clean_converged":
195
+ case "action_required":
196
+ return { bucket: "needsAttention", needsAttention: true };
197
+ default:
198
+ return { bucket: "needsAttention", needsAttention: true };
199
+ }
200
+ }
201
+ function buildPrReport(pr, interpretation, interpretationSummary, snapshot) {
202
+ const disposition = summarizePrDisposition(interpretationSummary.loopDisposition);
203
+ return {
204
+ number: pr.number,
205
+ title: pr.title,
206
+ url: pr.url,
207
+ isDraft: pr.isDraft,
208
+ headRefName: pr.headRefName,
209
+ authorLogin: pr.authorLogin,
210
+ state: interpretation.state,
211
+ nextAction: interpretation.nextAction,
212
+ loopDisposition: interpretationSummary.loopDisposition,
213
+ terminal: interpretationSummary.terminal,
214
+ needsAttention: disposition.needsAttention,
215
+ bucket: disposition.bucket,
216
+ snapshot: {
217
+ ciStatus: snapshot.ciStatus,
218
+ copilotReviewRequestStatus: snapshot.copilotReviewRequestStatus,
219
+ copilotReviewOnCurrentHead: snapshot.copilotReviewOnCurrentHead,
220
+ unresolvedThreadCount: snapshot.unresolvedThreadCount,
221
+ actionableThreadCount: snapshot.actionableThreadCount,
222
+ copilotReviewRoundCount: snapshot.copilotReviewRoundCount,
223
+ },
224
+ };
225
+ }
226
+ async function buildPrReports(prs, { repo, env, ghCommand }) {
227
+ const reports = [];
228
+ for (const pr of prs) {
229
+ const snapshot = await autoDetectSnapshot({ repo, pr: pr.number }, { env, ghCommand });
230
+ const interpretation = interpretLoopState(snapshot);
231
+ const interpretationSummary = summarizeLoopInterpretation(interpretation);
232
+ reports.push(buildPrReport(pr, interpretation, interpretationSummary, snapshot));
233
+ }
234
+ return reports;
235
+ }
236
+ function buildQueueSummary(reports) {
237
+ return reports.reduce((accumulator, pr) => {
238
+ accumulator[pr.bucket] += 1;
239
+ return accumulator;
240
+ }, {
241
+ waiting: 0,
242
+ needsAttention: 0,
243
+ blocked: 0,
244
+ done: 0,
245
+ });
246
+ }
247
+ function buildBaseResult(repo, reports) {
248
+ const summary = buildQueueSummary(reports);
249
+ const needsAttentionCount = summary.needsAttention + summary.blocked;
250
+ const queueStatus = reports.length === 0
251
+ ? "queue_complete"
252
+ : (needsAttentionCount > 0 ? "attention_needed" : "monitoring");
253
+ return {
254
+ ok: true,
255
+ repo,
256
+ checkedAt: new Date().toISOString(),
257
+ prCount: reports.length,
258
+ queueStatus,
259
+ needsAttentionCount,
260
+ summary,
261
+ prs: reports.map(({ bucket, ...pr }) => pr),
262
+ };
263
+ }
264
+ function splitPathList(value) {
265
+ if (typeof value !== "string" || value.trim().length === 0) {
266
+ return [];
267
+ }
268
+ return value
269
+ .split(path.delimiter)
270
+ .map((segment) => segment.trim())
271
+ .filter((segment) => segment.length > 0);
272
+ }
273
+ async function pathExists(filePath) {
274
+ try {
275
+ await access(filePath);
276
+ return true;
277
+ } catch {
278
+ return false;
279
+ }
280
+ }
281
+ async function listDirectoriesIfExists(root) {
282
+ try {
283
+ const entries = await readdir(root, { withFileTypes: true });
284
+ return entries
285
+ .filter((entry) => entry.isDirectory())
286
+ .map((entry) => path.join(root, entry.name));
287
+ } catch {
288
+ return [];
289
+ }
290
+ }
291
+ async function readTextIfExists(filePath) {
292
+ if (typeof filePath !== "string" || filePath.length === 0) {
293
+ return null;
294
+ }
295
+ try {
296
+ return await readFile(filePath, "utf8");
297
+ } catch {
298
+ return null;
299
+ }
300
+ }
301
+ async function readFirstLineIfExists(filePath, chunkSize = 4096) {
302
+ if (typeof filePath !== "string" || filePath.length === 0) {
303
+ return null;
304
+ }
305
+ let handle;
306
+ try {
307
+ handle = await open(filePath, "r");
308
+ let position = 0;
309
+ let collected = "";
310
+ while (true) {
311
+ const buffer = Buffer.alloc(chunkSize);
312
+ const { bytesRead } = await handle.read(buffer, 0, chunkSize, position);
313
+ if (bytesRead === 0) {
314
+ return collected.length > 0 ? collected : null;
315
+ }
316
+ const chunk = buffer.toString("utf8", 0, bytesRead);
317
+ const newlineIndex = chunk.search(/\r?\n/u);
318
+ if (newlineIndex >= 0) {
319
+ return `${collected}${chunk.slice(0, newlineIndex)}`;
320
+ }
321
+ collected += chunk;
322
+ position += bytesRead;
323
+ }
324
+ } catch {
325
+ return null;
326
+ } finally {
327
+ await handle?.close().catch(() => {});
328
+ }
329
+ }
330
+ async function readJsonIfExists(filePath) {
331
+ const text = await readTextIfExists(filePath);
332
+ if (text === null) {
333
+ return null;
334
+ }
335
+ try {
336
+ return parseJsonText(text);
337
+ } catch {
338
+ return null;
339
+ }
340
+ }
341
+ function normalizeRunState(value) {
342
+ const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
343
+ switch (normalized) {
344
+ case "complete":
345
+ case "completed":
346
+ return RUN_STATE.COMPLETED;
347
+ case "failed":
348
+ case "failure":
349
+ case "error":
350
+ return RUN_STATE.FAILED;
351
+ case "paused":
352
+ case "interrupted":
353
+ return RUN_STATE.PAUSED;
354
+ case "running":
355
+ return RUN_STATE.RUNNING;
356
+ case "queued":
357
+ case "pending":
358
+ return RUN_STATE.QUEUED;
359
+ default:
360
+ return RUN_STATE.UNKNOWN;
361
+ }
362
+ }
363
+ function normalizeRunStateForPlan(value) {
364
+ const normalized = normalizeRunState(value);
365
+ if (normalized === RUN_STATE.UNKNOWN) {
366
+ return RUN_STATE.COMPLETED;
367
+ }
368
+ return normalized;
369
+ }
370
+ function isRunningLikeState(value) {
371
+ const normalized = normalizeRunState(value);
372
+ return normalized === RUN_STATE.RUNNING || normalized === RUN_STATE.QUEUED;
373
+ }
374
+ function isExitedState(value) {
375
+ const normalized = normalizeRunState(value);
376
+ return normalized === RUN_STATE.COMPLETED
377
+ || normalized === RUN_STATE.FAILED
378
+ || normalized === RUN_STATE.PAUSED;
379
+ }
380
+ function runStatePriority(value) {
381
+ switch (normalizeRunState(value)) {
382
+ case RUN_STATE.RUNNING:
383
+ return 5;
384
+ case RUN_STATE.QUEUED:
385
+ return 4;
386
+ case RUN_STATE.FAILED:
387
+ return 3;
388
+ case RUN_STATE.PAUSED:
389
+ return 2;
390
+ case RUN_STATE.COMPLETED:
391
+ return 1;
392
+ default:
393
+ return 0;
394
+ }
395
+ }
396
+ function createRunRecord(runId, childIndex = 0) {
397
+ return {
398
+ runId,
399
+ childIndex,
400
+ agent: null,
401
+ runState: RUN_STATE.UNKNOWN,
402
+ cwd: null,
403
+ sessionPath: null,
404
+ statusPath: null,
405
+ eventsPath: null,
406
+ outputLogPath: null,
407
+ outputArtifactPath: null,
408
+ metaPath: null,
409
+ resultPath: null,
410
+ resultSummaryPath: null,
411
+ resultSummaryText: null,
412
+ timestampMs: null,
413
+ evidence: {},
414
+ };
415
+ }
416
+ function mergeRunRecord(target, patch) {
417
+ const merged = { ...target };
418
+ for (const [key, value] of Object.entries(patch)) {
419
+ if (value === undefined || value === null) {
420
+ continue;
421
+ }
422
+ if (key === "evidence") {
423
+ merged.evidence = { ...merged.evidence, ...value };
424
+ continue;
425
+ }
426
+ if (key === "timestampMs") {
427
+ const numeric = Number.isFinite(value) ? value : null;
428
+ if (numeric !== null) {
429
+ merged.timestampMs = merged.timestampMs === null
430
+ ? numeric
431
+ : Math.max(merged.timestampMs, numeric);
432
+ }
433
+ continue;
434
+ }
435
+ if (key === "runState") {
436
+ const normalized = normalizeRunState(value);
437
+ if (runStatePriority(normalized) > runStatePriority(merged.runState)) {
438
+ merged.runState = normalized;
439
+ }
440
+ continue;
441
+ }
442
+ merged[key] = value;
443
+ }
444
+ return merged;
445
+ }
446
+ function recordKey(runId, childIndex) {
447
+ return `${runId}:${childIndex}`;
448
+ }
449
+ function parseArtifactFileName(name) {
450
+ const match = name.match(/^(?<runId>.+)_(?<agent>[^_]+)_(?<index>\d+)_(?<kind>meta\.json|output\.md|input\.md)$/u);
451
+ if (!match?.groups) {
452
+ return null;
453
+ }
454
+ return {
455
+ runId: match.groups.runId,
456
+ agent: match.groups.agent,
457
+ childIndex: Number(match.groups.index),
458
+ kind: match.groups.kind,
459
+ };
460
+ }
461
+ async function scanSessionArtifactRoot(artifactsDir, records) {
462
+ const entries = await readdir(artifactsDir, { withFileTypes: true }).catch(() => null);
463
+ if (entries === null) {
464
+ return;
465
+ }
466
+ for (const entry of entries) {
467
+ if (!entry.isFile()) {
468
+ continue;
469
+ }
470
+ const parsedName = parseArtifactFileName(entry.name);
471
+ if (parsedName === null) {
472
+ continue;
473
+ }
474
+ const { runId, childIndex, agent } = parsedName;
475
+ const key = recordKey(runId, childIndex);
476
+ const record = records.get(key) ?? createRunRecord(runId, childIndex);
477
+ const filePath = path.join(artifactsDir, entry.name);
478
+ if (entry.name.endsWith("_meta.json")) {
479
+ const meta = await readJsonIfExists(filePath);
480
+ if (meta && typeof meta === "object") {
481
+ records.set(key, mergeRunRecord(record, {
482
+ agent: typeof meta.agent === "string" ? meta.agent : (record.agent ?? agent),
483
+ metaPath: filePath,
484
+ ...(Number.isInteger(meta.exitCode) ? {
485
+ runState: meta.exitCode === 0 ? RUN_STATE.COMPLETED : RUN_STATE.FAILED,
486
+ } : {}),
487
+ timestampMs: typeof meta.timestamp === "number" ? meta.timestamp : null,
488
+ evidence: {
489
+ metaPath: filePath,
490
+ exitCode: Number.isInteger(meta.exitCode) ? meta.exitCode : null,
491
+ },
492
+ }));
493
+ }
494
+ continue;
495
+ }
496
+ if (entry.name.endsWith("_output.md")) {
497
+ records.set(key, mergeRunRecord(record, {
498
+ outputArtifactPath: filePath,
499
+ agent: record.agent ?? agent,
500
+ evidence: { outputArtifactPath: filePath },
501
+ }));
502
+ }
503
+ }
504
+ }
505
+ async function scanSessionRunRoot(root, records) {
506
+ const topLevelEntries = await readdir(root, { withFileTypes: true }).catch(() => null);
507
+ if (topLevelEntries === null) {
508
+ return;
509
+ }
510
+ for (const topLevelEntry of topLevelEntries) {
511
+ if (!topLevelEntry.isDirectory()) {
512
+ continue;
513
+ }
514
+ if (topLevelEntry.name === "subagent-artifacts") {
515
+ await scanSessionArtifactRoot(path.join(root, topLevelEntry.name), records);
516
+ continue;
517
+ }
518
+ const topLevelPath = path.join(root, topLevelEntry.name);
519
+ await scanSessionArtifactRoot(path.join(topLevelPath, "subagent-artifacts"), records).catch(() => {});
520
+ const runIdEntries = await readdir(topLevelPath, { withFileTypes: true }).catch(() => []);
521
+ for (const runIdEntry of runIdEntries) {
522
+ if (!runIdEntry.isDirectory() || runIdEntry.name === "subagent-artifacts") {
523
+ continue;
524
+ }
525
+ const runId = runIdEntry.name;
526
+ const runRoot = path.join(topLevelPath, runId);
527
+ const runDirectories = await readdir(runRoot, { withFileTypes: true }).catch(() => []);
528
+ for (const runDirectory of runDirectories) {
529
+ const indexMatch = runDirectory.name.match(/^run-(\d+)$/u);
530
+ if (!runDirectory.isDirectory() || indexMatch === null) {
531
+ continue;
532
+ }
533
+ const childIndex = Number(indexMatch[1]);
534
+ const sessionPath = path.join(runRoot, runDirectory.name, "session.jsonl");
535
+ const firstLine = await readFirstLineIfExists(sessionPath);
536
+ let header = null;
537
+ if (firstLine) {
538
+ try {
539
+ header = parseJsonText(firstLine);
540
+ } catch {
541
+ header = null;
542
+ }
543
+ }
544
+ const key = recordKey(runId, childIndex);
545
+ const record = records.get(key) ?? createRunRecord(runId, childIndex);
546
+ records.set(key, mergeRunRecord(record, {
547
+ sessionPath,
548
+ cwd: typeof header?.cwd === "string" ? header.cwd : null,
549
+ evidence: { sessionPath },
550
+ }));
551
+ }
552
+ }
553
+ }
554
+ }
555
+ async function scanAsyncRunRoot(asyncRoot, records) {
556
+ const runDirs = await readdir(asyncRoot, { withFileTypes: true }).catch(() => []);
557
+ for (const runDirEntry of runDirs) {
558
+ if (!runDirEntry.isDirectory()) {
559
+ continue;
560
+ }
561
+ const asyncDir = path.join(asyncRoot, runDirEntry.name);
562
+ const statusPath = path.join(asyncDir, "status.json");
563
+ const eventsPath = path.join(asyncDir, "events.jsonl");
564
+ const status = await readJsonIfExists(statusPath);
565
+ if (!status || typeof status !== "object") {
566
+ continue;
567
+ }
568
+ const runId = typeof status.runId === "string" && status.runId.trim().length > 0
569
+ ? status.runId.trim()
570
+ : runDirEntry.name;
571
+ const rootState = normalizeRunState(status.state);
572
+ const cwd = typeof status.cwd === "string" ? status.cwd : null;
573
+ const defaultSessionPath = typeof status.sessionFile === "string" ? status.sessionFile : null;
574
+ const baseTimestamp = [status.endedAt, status.lastUpdate, status.lastActivityAt, status.startedAt]
575
+ .find((value) => typeof value === "number");
576
+ const steps = Array.isArray(status.steps) && status.steps.length > 0
577
+ ? status.steps
578
+ : [{
579
+ agent: typeof status.agent === "string" ? status.agent : null,
580
+ status: status.state,
581
+ sessionFile: status.sessionFile,
582
+ }];
583
+ steps.forEach((step, index) => {
584
+ if (typeof step?.agent !== "string" || step.agent !== "dev-loop") {
585
+ return;
586
+ }
587
+ const key = recordKey(runId, index);
588
+ const record = records.get(key) ?? createRunRecord(runId, index);
589
+ const explicitOutputFile = typeof status.outputFile === "string"
590
+ ? status.outputFile
591
+ : path.join(asyncDir, `output-${index}.log`);
592
+ records.set(key, mergeRunRecord(record, {
593
+ agent: step.agent,
594
+ runState: step.status ?? rootState,
595
+ cwd,
596
+ sessionPath: typeof step.sessionFile === "string" ? step.sessionFile : defaultSessionPath,
597
+ statusPath,
598
+ eventsPath,
599
+ outputLogPath: explicitOutputFile,
600
+ timestampMs: typeof baseTimestamp === "number" ? baseTimestamp : null,
601
+ evidence: {
602
+ asyncDir,
603
+ statusPath,
604
+ eventsPath,
605
+ outputLogPath: explicitOutputFile,
606
+ },
607
+ }));
608
+ });
609
+ }
610
+ }
611
+ function extractResultOutputArtifactPath(result) {
612
+ const value = result?.artifactPaths;
613
+ if (!value || typeof value !== "object") {
614
+ return null;
615
+ }
616
+ if (typeof value.outputPath === "string") {
617
+ return value.outputPath;
618
+ }
619
+ if (typeof value.output === "string") {
620
+ return value.output;
621
+ }
622
+ for (const entry of Object.values(value)) {
623
+ if (typeof entry === "string" && entry.endsWith("_output.md")) {
624
+ return entry;
625
+ }
626
+ }
627
+ return null;
628
+ }
629
+ function parseRunIdFromTextSummary(text, fallbackName) {
630
+ const runLine = text.match(/^Run(?: ID)?:\s*(.+)$/imu);
631
+ if (runLine && typeof runLine[1] === "string" && runLine[1].trim().length > 0) {
632
+ return runLine[1].trim();
633
+ }
634
+ return fallbackName;
635
+ }
636
+ function parseSummaryPointers(text) {
637
+ const outputArtifactMatch = text.match(/^Output artifact:\s*(.+)$/imu);
638
+ const sessionMatch = text.match(/^Session:\s*(.+)$/imu);
639
+ const stateMatch = text.match(/^State:\s*(.+)$/imu);
640
+ const agentMatch = text.match(/^Agent:\s*(.+)$/imu);
641
+ return {
642
+ outputArtifactPath: outputArtifactMatch?.[1]?.trim() || null,
643
+ sessionPath: sessionMatch?.[1]?.trim() || null,
644
+ runState: stateMatch?.[1]?.trim() || null,
645
+ agent: agentMatch?.[1]?.trim() || null,
646
+ };
647
+ }
648
+ async function scanAsyncResultRoot(resultsRoot, records) {
649
+ const entries = await readdir(resultsRoot, { withFileTypes: true }).catch(() => []);
650
+ for (const entry of entries) {
651
+ if (!entry.isFile()) {
652
+ continue;
653
+ }
654
+ const filePath = path.join(resultsRoot, entry.name);
655
+ if (entry.name.endsWith(".json")) {
656
+ const result = await readJsonIfExists(filePath);
657
+ if (!result || typeof result !== "object") {
658
+ continue;
659
+ }
660
+ const runId = typeof result.runId === "string" && result.runId.trim().length > 0
661
+ ? result.runId.trim()
662
+ : (typeof result.id === "string" && result.id.trim().length > 0 ? result.id.trim() : null);
663
+ if (runId === null) {
664
+ continue;
665
+ }
666
+ const resultEntries = Array.isArray(result.results) && result.results.length > 0
667
+ ? result.results
668
+ : [{
669
+ agent: typeof result.agent === "string" ? result.agent : null,
670
+ sessionFile: typeof result.sessionFile === "string" ? result.sessionFile : null,
671
+ artifactPaths: result.artifactPaths,
672
+ output: typeof result.summary === "string" ? result.summary : undefined,
673
+ }];
674
+ const baseTimestamp = typeof result.timestamp === "number" ? result.timestamp : null;
675
+ const cwd = typeof result.cwd === "string" ? result.cwd : null;
676
+ resultEntries.forEach((child, index) => {
677
+ if (typeof child?.agent !== "string" || child.agent !== "dev-loop") {
678
+ return;
679
+ }
680
+ const key = recordKey(runId, index);
681
+ const record = records.get(key) ?? createRunRecord(runId, index);
682
+ records.set(key, mergeRunRecord(record, {
683
+ agent: child.agent,
684
+ runState: result.state,
685
+ cwd,
686
+ sessionPath: typeof child.sessionFile === "string" ? child.sessionFile : (typeof result.sessionFile === "string" ? result.sessionFile : null),
687
+ outputArtifactPath: extractResultOutputArtifactPath(child),
688
+ resultPath: filePath,
689
+ resultSummaryText: typeof child.output === "string" ? child.output : (typeof result.summary === "string" ? result.summary : null),
690
+ timestampMs: baseTimestamp,
691
+ evidence: { resultPath: filePath },
692
+ }));
693
+ });
694
+ continue;
695
+ }
696
+ if (!/\.(md|txt)$/iu.test(entry.name)) {
697
+ continue;
698
+ }
699
+ const text = await readTextIfExists(filePath);
700
+ if (text === null || (!text.includes("Output artifact:") && !text.includes("Session:"))) {
701
+ continue;
702
+ }
703
+ const runId = parseRunIdFromTextSummary(text, path.parse(entry.name).name);
704
+ const childIndex = 0;
705
+ const key = recordKey(runId, childIndex);
706
+ const record = records.get(key) ?? createRunRecord(runId, childIndex);
707
+ const pointers = parseSummaryPointers(text);
708
+ records.set(key, mergeRunRecord(record, {
709
+ agent: pointers.agent,
710
+ runState: pointers.runState,
711
+ sessionPath: pointers.sessionPath,
712
+ outputArtifactPath: pointers.outputArtifactPath,
713
+ resultSummaryPath: filePath,
714
+ resultSummaryText: text,
715
+ evidence: { resultSummaryPath: filePath },
716
+ }));
717
+ }
718
+ }
719
+ function collectConfiguredRoots(explicitRoots, envValue, fallbackRoots) {
720
+ if (Array.isArray(explicitRoots) && explicitRoots.length > 0) {
721
+ return [...new Set(explicitRoots.map((root) => path.resolve(root)))];
722
+ }
723
+ const fromEnv = splitPathList(envValue);
724
+ if (fromEnv.length > 0) {
725
+ return [...new Set(fromEnv.map((root) => path.resolve(root)))];
726
+ }
727
+ return [...new Set((fallbackRoots ?? []).map((root) => path.resolve(root)))];
728
+ }
729
+ async function detectDefaultAsyncRoots(kind) {
730
+ const tempDir = os.tmpdir();
731
+ const entries = await readdir(tempDir, { withFileTypes: true }).catch(() => []);
732
+ return entries
733
+ .filter((entry) => entry.isDirectory() && entry.name.startsWith("pi-subagents-"))
734
+ .map((entry) => path.join(tempDir, entry.name, kind))
735
+ .filter((candidate) => existsSync(candidate));
736
+ }
737
+ async function resolveRepoIsolation(repoRoot) {
738
+ const normalizedRepoRoot = path.resolve(repoRoot);
739
+ const worktreeRoot = path.join(normalizedRepoRoot, "tmp", "worktrees");
740
+ const existingWorktrees = await listDirectoriesIfExists(worktreeRoot);
741
+ return {
742
+ repoRoot: normalizedRepoRoot,
743
+ worktreeRoot,
744
+ worktrees: existingWorktrees.map((entry) => path.resolve(entry)),
745
+ };
746
+ }
747
+ function isPathWithinRoot(candidate, root) {
748
+ if (typeof candidate !== "string" || typeof root !== "string") {
749
+ return false;
750
+ }
751
+ const normalizedCandidate = path.resolve(candidate);
752
+ const normalizedRoot = path.resolve(root);
753
+ return normalizedCandidate === normalizedRoot || normalizedCandidate.startsWith(`${normalizedRoot}${path.sep}`);
754
+ }
755
+ function recordMatchesRepo(record, repoIsolation) {
756
+ if (typeof record.cwd !== "string" || record.cwd.trim().length === 0) {
757
+ return false;
758
+ }
759
+ if (isPathWithinRoot(record.cwd, repoIsolation.repoRoot)) {
760
+ return true;
761
+ }
762
+ if (isPathWithinRoot(record.cwd, repoIsolation.worktreeRoot)) {
763
+ return true;
764
+ }
765
+ return repoIsolation.worktrees.some((worktree) => isPathWithinRoot(record.cwd, worktree));
766
+ }
767
+ function isStaleWorktreePath(filePath, repoIsolation) {
768
+ if (typeof filePath !== "string" || filePath.trim().length === 0) {
769
+ return false;
770
+ }
771
+ if (!isPathWithinRoot(filePath, repoIsolation.worktreeRoot)) {
772
+ return false;
773
+ }
774
+ return !existsSync(filePath);
775
+ }
776
+ const LOCAL_PHASE_AGENTS = new Set([
777
+ "developer",
778
+ "quality",
779
+ "docs",
780
+ "fixer",
781
+ "refiner",
782
+ "review",
783
+ ]);
784
+ const RUN_STATE_LABELS = Object.freeze({
785
+ queued: RUN_STATE.QUEUED,
786
+ pending: RUN_STATE.QUEUED,
787
+ running: RUN_STATE.RUNNING,
788
+ paused: RUN_STATE.PAUSED,
789
+ interrupted: RUN_STATE.PAUSED,
790
+ completed: RUN_STATE.COMPLETED,
791
+ complete: RUN_STATE.COMPLETED,
792
+ done: RUN_STATE.COMPLETED,
793
+ failed: RUN_STATE.FAILED,
794
+ failure: RUN_STATE.FAILED,
795
+ error: RUN_STATE.FAILED,
796
+ });
797
+ function normalizeSummaryState(label) {
798
+ return RUN_STATE_LABELS[String(label).trim().toLowerCase()] ?? RUN_STATE.UNKNOWN;
799
+ }
800
+ function parseLocalSubagentSummary(text, filePath) {
801
+ const agentMatch = text.match(/^[-*]\s*agent(?:\s*name)?:\s*(.+)$/imu);
802
+ const statusMatch = text.match(/^[-*]\s*(?:status|state):\s*(.+)$/imu);
803
+ const runIdMatch = text.match(/^[-*]\s*run(?:\s*id)?:\s*(.+)$/imu);
804
+ const cwdMatch = text.match(/^[-*]\s*(?:cwd|working\s*directory):\s*(.+)$/imu);
805
+ const taskMatch = text.match(/^[-*]\s*(?:task|prompt\s*summary):\s*(.+)$/imu);
806
+ const agent = agentMatch?.[1]?.trim().toLowerCase() ?? null;
807
+ if (agent === null || !LOCAL_PHASE_AGENTS.has(agent)) {
808
+ return null;
809
+ }
810
+ return {
811
+ agent,
812
+ runState: normalizeSummaryState(statusMatch?.[1] ?? ""),
813
+ runId: (runIdMatch?.[1] ?? path.basename(filePath, ".md")).trim(),
814
+ cwd: cwdMatch?.[1]?.trim() ?? null,
815
+ taskSummary: taskMatch?.[1]?.trim() ?? null,
816
+ summaryPath: filePath,
817
+ evidence: { summaryPath: filePath },
818
+ childIndex: 0,
819
+ timestampMs: null,
820
+ };
821
+ }
822
+ async function scanLocalPhaseSubagents(repoRoot) {
823
+ const phasesRoot = path.join(repoRoot, "tmp", "phases");
824
+ const phaseDirs = await listDirectoriesIfExists(phasesRoot);
825
+ const runs = [];
826
+ for (const phaseDir of phaseDirs) {
827
+ const subagentsDir = path.join(phaseDir, "subagents");
828
+ let entries;
829
+ try {
830
+ entries = await readdir(subagentsDir, { withFileTypes: true });
831
+ } catch {
832
+ continue;
833
+ }
834
+ for (const entry of entries) {
835
+ if (!entry.isFile() || !entry.name.endsWith(".md")) {
836
+ continue;
837
+ }
838
+ const filePath = path.join(subagentsDir, entry.name);
839
+ const text = await readTextIfExists(filePath);
840
+ if (text === null) {
841
+ continue;
842
+ }
843
+ const parsed = parseLocalSubagentSummary(text, filePath);
844
+ if (parsed !== null && isExitedState(parsed.runState)) {
845
+ runs.push({
846
+ ...parsed,
847
+ phaseDir,
848
+ kind: "local_phase",
849
+ });
850
+ }
851
+ }
852
+ const rawDir = path.join(subagentsDir, "raw");
853
+ let rawEntries;
854
+ try {
855
+ rawEntries = await readdir(rawDir, { withFileTypes: true });
856
+ } catch {
857
+ continue;
858
+ }
859
+ for (const entry of rawEntries) {
860
+ if (!entry.isFile() || !entry.name.endsWith(".md")) {
861
+ continue;
862
+ }
863
+ const filePath = path.join(rawDir, entry.name);
864
+ const text = await readTextIfExists(filePath);
865
+ if (text === null) {
866
+ continue;
867
+ }
868
+ const parsed = parseLocalSubagentSummary(text, filePath);
869
+ if (parsed !== null && isExitedState(parsed.runState)) {
870
+ runs.push({
871
+ ...parsed,
872
+ phaseDir,
873
+ kind: "local_phase_raw",
874
+ });
875
+ }
876
+ }
877
+ }
878
+ return runs;
879
+ }
880
+ function buildLocalPhaseResumePlan(localRun) {
881
+ const phaseName = path.basename(localRun.phaseDir);
882
+ const taskDesc = localRun.taskSummary ?? "unknown";
883
+ let resumeMessage;
884
+ if (localRun.runState === RUN_STATE.COMPLETED) {
885
+ resumeMessage = `Local phase ${phaseName} subagent ${localRun.agent} (${localRun.runId}) completed. Task: ${taskDesc}. Consolidate and review results from ${localRun.phaseDir}.`;
886
+ } else if (localRun.runState === RUN_STATE.FAILED) {
887
+ resumeMessage = `Local phase ${phaseName} subagent ${localRun.agent} (${localRun.runId}) failed. Task: ${taskDesc}. Resume the phase from the last deterministic checkpoint in ${localRun.phaseDir}.`;
888
+ } else {
889
+ resumeMessage = `Local phase ${phaseName} subagent ${localRun.agent} (${localRun.runId}) exited (${localRun.runState}). Task: ${taskDesc}. Resume the phase from the last deterministic checkpoint in ${localRun.phaseDir}.`;
890
+ }
891
+ return {
892
+ kind: "local_phase",
893
+ phase: phaseName,
894
+ phaseDir: localRun.phaseDir,
895
+ agent: localRun.agent,
896
+ runId: localRun.runId,
897
+ runState: localRun.runState,
898
+ taskSummary: localRun.taskSummary,
899
+ resumeMessage,
900
+ summaryPath: localRun.summaryPath,
901
+ };
902
+ }
903
+ export async function listRepoAsyncRuns(
904
+ { repo },
905
+ {
906
+ repoRoot = process.cwd(),
907
+ env = process.env,
908
+ sessionRoots,
909
+ asyncRunRoots,
910
+ asyncResultRoots,
911
+ } = {},
912
+ ) {
913
+ parseRepoSlug(repo);
914
+ const repoIsolation = await resolveRepoIsolation(repoRoot);
915
+ const resolvedSessionRoots = collectConfiguredRoots(
916
+ sessionRoots,
917
+ env.PI_AGENT_SESSIONS_DIR ?? env.PI_SUBAGENT_SESSIONS_DIR,
918
+ [DEFAULT_SESSION_ROOT],
919
+ );
920
+ const resolvedAsyncRunRoots = collectConfiguredRoots(
921
+ asyncRunRoots,
922
+ env.PI_SUBAGENT_ASYNC_RUNS_DIR,
923
+ await detectDefaultAsyncRoots("async-subagent-runs"),
924
+ );
925
+ const resolvedAsyncResultRoots = collectConfiguredRoots(
926
+ asyncResultRoots,
927
+ env.PI_SUBAGENT_ASYNC_RESULTS_DIR,
928
+ await detectDefaultAsyncRoots("async-subagent-results"),
929
+ );
930
+ const records = new Map();
931
+ for (const sessionRoot of resolvedSessionRoots) {
932
+ if (await pathExists(sessionRoot)) {
933
+ await scanSessionRunRoot(sessionRoot, records);
934
+ }
935
+ }
936
+ for (const asyncRoot of resolvedAsyncRunRoots) {
937
+ if (await pathExists(asyncRoot)) {
938
+ await scanAsyncRunRoot(asyncRoot, records);
939
+ }
940
+ }
941
+ for (const resultsRoot of resolvedAsyncResultRoots) {
942
+ if (await pathExists(resultsRoot)) {
943
+ await scanAsyncResultRoot(resultsRoot, records);
944
+ }
945
+ }
946
+ return [...records.values()]
947
+ .filter((record) => record.agent === "dev-loop")
948
+ .filter((record) => recordMatchesRepo(record, repoIsolation))
949
+ .map((record) => ({
950
+ ...record,
951
+ staleWorktree: isStaleWorktreePath(record.cwd, repoIsolation),
952
+ repoRoot: repoIsolation.repoRoot,
953
+ worktreeRoot: repoIsolation.worktreeRoot,
954
+ }));
955
+ }
956
+ function stripFormatting(value) {
957
+ return value
958
+ .replace(/`/gu, "")
959
+ .replace(/^\*+|\*+$/gu, "")
960
+ .trim();
961
+ }
962
+ function extractPrNumberFromLine(line) {
963
+ const trimmed = stripFormatting(line);
964
+ if (/\bActive PR:\s*none\b/i.test(trimmed)) {
965
+ return null;
966
+ }
967
+ const urlMatch = trimmed.match(/\/pull\/(\d+)\b/u);
968
+ if (urlMatch) {
969
+ return Number(urlMatch[1]);
970
+ }
971
+ const hashMatch = trimmed.match(/\bPR\s*#(\d+)\b/u);
972
+ if (hashMatch) {
973
+ return Number(hashMatch[1]);
974
+ }
975
+ const activePrMatch = trimmed.match(/^Active PR:\s*.+#(\d+)\b/ui);
976
+ if (activePrMatch) {
977
+ return Number(activePrMatch[1]);
978
+ }
979
+ const prLineMatch = trimmed.match(/^PR:\s*.+#(\d+)\b/ui);
980
+ if (prLineMatch) {
981
+ return Number(prLineMatch[1]);
982
+ }
983
+ const artifactMatch = trimmed.match(/^[-*]\s*Artifact(?:\/state)? inspected:\s*PR\s*#(\d+)\b/ui);
984
+ if (artifactMatch) {
985
+ return Number(artifactMatch[1]);
986
+ }
987
+ const mergedMatch = trimmed.match(/^PR merged:\s*#(\d+)\b/ui);
988
+ if (mergedMatch) {
989
+ return Number(mergedMatch[1]);
990
+ }
991
+ const statusMatch = trimmed.match(/^Status:.*\bPR\s*#(\d+)\b/ui);
992
+ if (statusMatch) {
993
+ return Number(statusMatch[1]);
994
+ }
995
+ return null;
996
+ }
997
+ function extractPrNumbersFromArtifactText(text) {
998
+ const numbers = new Set();
999
+ const lines = text.split(/\r?\n/u);
1000
+ for (const line of lines) {
1001
+ const number = extractPrNumberFromLine(line);
1002
+ if (Number.isInteger(number)) {
1003
+ numbers.add(number);
1004
+ }
1005
+ }
1006
+ return [...numbers].sort((left, right) => left - right);
1007
+ }
1008
+ function parseArtifactState(text) {
1009
+ const artifactStateLine = text.match(/^\**Artifact state:\**\s*(.+)$/imu)?.[1];
1010
+ const statusLine = text.match(/^Status:\s*(.+)$/imu)?.[1];
1011
+ const normalizedArtifact = stripFormatting(artifactStateLine ?? "").toLowerCase();
1012
+ const normalizedStatus = stripFormatting(statusLine ?? "").toLowerCase();
1013
+ const combined = `${normalizedArtifact}\n${normalizedStatus}`;
1014
+ if (/\bmerged\b/u.test(combined)) {
1015
+ return "merged";
1016
+ }
1017
+ if (/\bclosed\b/u.test(combined)) {
1018
+ return "closed";
1019
+ }
1020
+ if (/\bopen\b/u.test(combined)) {
1021
+ return "open";
1022
+ }
1023
+ if (/final human approval|waiting_for_merge_authorization|advanced to the final human approval boundary|inspected and advanced/u.test(combined)) {
1024
+ return "open";
1025
+ }
1026
+ return null;
1027
+ }
1028
+ function parseLoopState(text) {
1029
+ const patterns = [
1030
+ /^\**Loop state:\**\s*(.+)$/imu,
1031
+ /^Current routed state:\s*(.+)$/imu,
1032
+ /^-?\s*Copilot loop state:\s*(.+)$/imu,
1033
+ /^-?\s*Routed strategy:\s*(.+)$/imu,
1034
+ ];
1035
+ for (const pattern of patterns) {
1036
+ const match = text.match(pattern);
1037
+ if (match?.[1]) {
1038
+ return stripFormatting(match[1]);
1039
+ }
1040
+ }
1041
+ return null;
1042
+ }
1043
+ function parseNextActionText(text) {
1044
+ const match = text.match(/^(?:Next action|Next recommended action):\s*(.+)$/imu);
1045
+ if (match?.[1]) {
1046
+ return stripFormatting(match[1]);
1047
+ }
1048
+ return null;
1049
+ }
1050
+ function classifyResumeBucket(text, parsedArtifactState) {
1051
+ const normalized = text.toLowerCase();
1052
+ if (
1053
+ parsedArtifactState === "merged"
1054
+ || /\bpr merged:\s*#\d+\b/u.test(normalized)
1055
+ || /\bartifact state:\s*merged\b/u.test(normalized)
1056
+ ) {
1057
+ return RESUME_ACTION.DONE_OR_MERGED;
1058
+ }
1059
+ if (
1060
+ /\bstop(?:ped)? at waiting_for_merge_authorization\b/u.test(normalized)
1061
+ || /\bcurrent stop boundary:\s*waiting_for_merge_authorization\b/u.test(normalized)
1062
+ || /\bstopping at waiting_for_merge_authorization\b/u.test(normalized)
1063
+ || /\basking for explicit merge authorization\b/u.test(normalized)
1064
+ ) {
1065
+ return RESUME_ACTION.AWAIT_MERGE_AUTHORIZATION;
1066
+ }
1067
+ if (
1068
+ /\bfinal human approval boundary\b/u.test(normalized)
1069
+ || /\bfinal human approval readiness\b/u.test(normalized)
1070
+ || /\brouted strategy:\s*`?final_approval`?/u.test(normalized)
1071
+ || /\bhuman reviews\/approves pr\b/u.test(normalized)
1072
+ || /\bawait final human approval\b/u.test(normalized)
1073
+ ) {
1074
+ return RESUME_ACTION.AWAIT_FINAL_APPROVAL;
1075
+ }
1076
+ if (
1077
+ /\balready_fixed_needs_reply_resolve\b/u.test(normalized)
1078
+ || /\breply(?:ing)? to and resolving the addressed github threads\b/u.test(normalized)
1079
+ || /\breply to and resolve each github thread\b/u.test(normalized)
1080
+ ) {
1081
+ return RESUME_ACTION.NEEDS_REPLY_RESOLVE;
1082
+ }
1083
+ if (
1084
+ /\bunresolved_feedback_present\b/u.test(normalized)
1085
+ || /\baddress(?:ing)? review feedback\b/u.test(normalized)
1086
+ || /\bfix(?:ing)? the remaining review feedback\b/u.test(normalized)
1087
+ || /\bremaining review feedback\b/u.test(normalized)
1088
+ ) {
1089
+ return RESUME_ACTION.NEEDS_FEEDBACK_FIX;
1090
+ }
1091
+ if (
1092
+ /\bwaiting_for_copilot_review\b/u.test(normalized)
1093
+ || /\bready_to_rerequest_review\b/u.test(normalized)
1094
+ || /\brequest(?:ing)? another copilot pass\b/u.test(normalized)
1095
+ || /\bwatch(?:ing)? the next copilot review cycle\b/u.test(normalized)
1096
+ || /\bcopilot review cycle\b/u.test(normalized)
1097
+ ) {
1098
+ return RESUME_ACTION.NEEDS_REREQUEST_OR_WATCH;
1099
+ }
1100
+ if (
1101
+ /\bauthorization boundary\b/u.test(normalized)
1102
+ || /\bdo not perform the next mutation without explicit approval\b/u.test(normalized)
1103
+ || /\bready-for-review\b/u.test(normalized)
1104
+ || /\bdraft review\b/u.test(normalized)
1105
+ || /\bdraft_gate\b/u.test(normalized)
1106
+ ) {
1107
+ return RESUME_ACTION.AWAIT_READY_FOR_REVIEW_AUTHORIZATION;
1108
+ }
1109
+ return null;
1110
+ }
1111
+ function buildSourceSelection(record, outputArtifactText, resultSummaryText, outputLogText) {
1112
+ const resultSummaryPath = record.resultSummaryPath ?? record.resultPath ?? null;
1113
+ const candidates = [];
1114
+ if (outputArtifactText !== null) {
1115
+ candidates.push({
1116
+ text: outputArtifactText,
1117
+ source: "output_artifact",
1118
+ artifactPath: record.outputArtifactPath ?? null,
1119
+ reportingIssue: null,
1120
+ });
1121
+ } else if (record.outputArtifactPath) {
1122
+ if (resultSummaryText !== null) {
1123
+ candidates.push({
1124
+ text: resultSummaryText,
1125
+ source: "grouped_result_summary",
1126
+ artifactPath: resultSummaryPath,
1127
+ reportingIssue: MANUAL_REASON.MISSING_OUTPUT_ARTIFACT,
1128
+ });
1129
+ }
1130
+ if (outputLogText !== null) {
1131
+ candidates.push({
1132
+ text: outputLogText,
1133
+ source: "weak_fallback_output_log",
1134
+ artifactPath: record.outputLogPath ?? null,
1135
+ reportingIssue: MANUAL_REASON.MISSING_OUTPUT_ARTIFACT,
1136
+ });
1137
+ }
1138
+ return {
1139
+ candidates,
1140
+ primarySource: "missing_output_artifact",
1141
+ weakFallbackText: outputLogText,
1142
+ outputArtifactMissing: true,
1143
+ };
1144
+ }
1145
+ if (resultSummaryText !== null) {
1146
+ candidates.push({
1147
+ text: resultSummaryText,
1148
+ source: "grouped_result_summary",
1149
+ artifactPath: resultSummaryPath,
1150
+ reportingIssue: null,
1151
+ });
1152
+ }
1153
+ if (outputLogText !== null) {
1154
+ candidates.push({
1155
+ text: outputLogText,
1156
+ source: "weak_fallback_output_log",
1157
+ artifactPath: record.outputLogPath ?? null,
1158
+ reportingIssue: null,
1159
+ });
1160
+ }
1161
+ return {
1162
+ candidates,
1163
+ primarySource: candidates.length > 0 ? candidates[0].source : "weak_fallback_only",
1164
+ weakFallbackText: outputLogText,
1165
+ outputArtifactMissing: false,
1166
+ };
1167
+ }
1168
+ function parseWeakFallbackPr(text) {
1169
+ if (typeof text !== "string" || text.trim().length === 0) {
1170
+ return null;
1171
+ }
1172
+ const prNumbers = extractPrNumbersFromArtifactText(text);
1173
+ return prNumbers.length === 1 ? prNumbers[0] : null;
1174
+ }
1175
+ export async function parseDevLoopArtifact(record) {
1176
+ const outputArtifactText = await readTextIfExists(record.outputArtifactPath);
1177
+ const resultSummaryText = record.resultSummaryText ?? await readTextIfExists(record.resultSummaryPath);
1178
+ const outputLogText = await readTextIfExists(record.outputLogPath);
1179
+ const selection = buildSourceSelection(record, outputArtifactText, resultSummaryText, outputLogText);
1180
+ if (selection.candidates.length === 0) {
1181
+ const weakFallbackPr = parseWeakFallbackPr(selection.weakFallbackText);
1182
+ return {
1183
+ ok: false,
1184
+ reason: MANUAL_REASON.MISSING_OUTPUT_ARTIFACT,
1185
+ ...(Number.isInteger(weakFallbackPr) ? { pr: weakFallbackPr } : {}),
1186
+ evidence: {
1187
+ outputArtifactPath: record.outputArtifactPath,
1188
+ resultSummaryPath: record.resultSummaryPath,
1189
+ resultPath: record.resultPath,
1190
+ outputLogPath: record.outputLogPath,
1191
+ sessionPath: record.sessionPath,
1192
+ },
1193
+ weakFallbackText: selection.weakFallbackText,
1194
+ source: selection.primarySource,
1195
+ };
1196
+ }
1197
+ let lastFailure = null;
1198
+ for (const candidate of selection.candidates) {
1199
+ const prNumbers = extractPrNumbersFromArtifactText(candidate.text);
1200
+ if (prNumbers.length > 1) {
1201
+ lastFailure = {
1202
+ ok: false,
1203
+ reason: MANUAL_REASON.AMBIGUOUS_PR_IDENTITY,
1204
+ evidence: {
1205
+ prNumbers,
1206
+ source: candidate.source,
1207
+ outputArtifactPath: record.outputArtifactPath,
1208
+ resultSummaryPath: record.resultSummaryPath,
1209
+ resultPath: record.resultPath,
1210
+ artifactPath: candidate.artifactPath,
1211
+ },
1212
+ source: candidate.source,
1213
+ weakFallbackText: selection.weakFallbackText,
1214
+ };
1215
+ continue;
1216
+ }
1217
+ if (prNumbers.length === 0) {
1218
+ lastFailure = {
1219
+ ok: false,
1220
+ reason: MANUAL_REASON.MISSING_PR_IDENTITY,
1221
+ evidence: {
1222
+ source: candidate.source,
1223
+ outputArtifactPath: record.outputArtifactPath,
1224
+ resultSummaryPath: record.resultSummaryPath,
1225
+ resultPath: record.resultPath,
1226
+ artifactPath: candidate.artifactPath,
1227
+ },
1228
+ source: candidate.source,
1229
+ weakFallbackText: selection.weakFallbackText,
1230
+ };
1231
+ continue;
1232
+ }
1233
+ const parsedArtifactState = parseArtifactState(candidate.text);
1234
+ const parsedLoopState = parseLoopState(candidate.text);
1235
+ const nextAction = parseNextActionText(candidate.text);
1236
+ const recordedHandoffContractResult = parseRecordedHandoffContract(candidate.text);
1237
+ if (recordedHandoffContractResult.reason !== null) {
1238
+ return {
1239
+ ok: false,
1240
+ reason: recordedHandoffContractResult.reason === "incomplete_handoff_contract"
1241
+ ? MANUAL_REASON.HANDOFF_CONTRACT_INCOMPLETE
1242
+ : MANUAL_REASON.HANDOFF_CONTRACT_INVALID,
1243
+ pr: prNumbers[0],
1244
+ evidence: {
1245
+ source: candidate.source,
1246
+ details: recordedHandoffContractResult.details ?? null,
1247
+ parsedArtifactState,
1248
+ parsedLoopState,
1249
+ nextAction,
1250
+ outputArtifactPath: record.outputArtifactPath,
1251
+ resultSummaryPath: record.resultSummaryPath,
1252
+ },
1253
+ source: candidate.source,
1254
+ weakFallbackText: selection.weakFallbackText,
1255
+ };
1256
+ }
1257
+ if (parsedArtifactState === null) {
1258
+ lastFailure = {
1259
+ ok: false,
1260
+ reason: MANUAL_REASON.UNCLASSIFIED_ARTIFACT_STATE,
1261
+ pr: prNumbers[0],
1262
+ evidence: {
1263
+ source: candidate.source,
1264
+ parsedLoopState,
1265
+ nextAction,
1266
+ outputArtifactPath: record.outputArtifactPath,
1267
+ resultSummaryPath: record.resultSummaryPath,
1268
+ resultPath: record.resultPath,
1269
+ artifactPath: candidate.artifactPath,
1270
+ },
1271
+ source: candidate.source,
1272
+ weakFallbackText: selection.weakFallbackText,
1273
+ };
1274
+ continue;
1275
+ }
1276
+ const resumeBucket = classifyResumeBucket(candidate.text, parsedArtifactState);
1277
+ if (resumeBucket === null) {
1278
+ lastFailure = {
1279
+ ok: false,
1280
+ reason: MANUAL_REASON.UNCLASSIFIED_ARTIFACT_STATE,
1281
+ pr: prNumbers[0],
1282
+ evidence: {
1283
+ source: candidate.source,
1284
+ parsedArtifactState,
1285
+ parsedLoopState,
1286
+ nextAction,
1287
+ outputArtifactPath: record.outputArtifactPath,
1288
+ resultSummaryPath: record.resultSummaryPath,
1289
+ resultPath: record.resultPath,
1290
+ artifactPath: candidate.artifactPath,
1291
+ },
1292
+ source: candidate.source,
1293
+ weakFallbackText: selection.weakFallbackText,
1294
+ };
1295
+ continue;
1296
+ }
1297
+ return {
1298
+ ok: true,
1299
+ pr: prNumbers[0],
1300
+ parsedArtifactState,
1301
+ parsedLoopState,
1302
+ nextAction,
1303
+ recordedHandoffContract: recordedHandoffContractResult.contract,
1304
+ resumeBucket,
1305
+ source: candidate.source,
1306
+ text: candidate.text,
1307
+ artifactPath: candidate.artifactPath,
1308
+ reportingIssue: candidate.reportingIssue,
1309
+ };
1310
+ }
1311
+ if (selection.outputArtifactMissing) {
1312
+ const weakFallbackPr = parseWeakFallbackPr(selection.weakFallbackText);
1313
+ return {
1314
+ ok: false,
1315
+ reason: MANUAL_REASON.MISSING_OUTPUT_ARTIFACT,
1316
+ ...(Number.isInteger(weakFallbackPr) ? { pr: weakFallbackPr } : {}),
1317
+ evidence: {
1318
+ outputArtifactPath: record.outputArtifactPath,
1319
+ resultSummaryPath: record.resultSummaryPath,
1320
+ resultPath: record.resultPath,
1321
+ outputLogPath: record.outputLogPath,
1322
+ sessionPath: record.sessionPath,
1323
+ },
1324
+ weakFallbackText: selection.weakFallbackText,
1325
+ source: selection.primarySource,
1326
+ };
1327
+ }
1328
+ return lastFailure ?? {
1329
+ ok: false,
1330
+ reason: MANUAL_REASON.UNCLASSIFIED_ARTIFACT_STATE,
1331
+ evidence: {
1332
+ outputArtifactPath: record.outputArtifactPath,
1333
+ resultSummaryPath: record.resultSummaryPath,
1334
+ resultPath: record.resultPath,
1335
+ outputLogPath: record.outputLogPath,
1336
+ sessionPath: record.sessionPath,
1337
+ },
1338
+ source: selection.primarySource,
1339
+ weakFallbackText: selection.weakFallbackText,
1340
+ };
1341
+ }
1342
+ function buildResumeMessage({ pr, runId, resumeAction, livePrState }) {
1343
+ switch (resumeAction) {
1344
+ case RESUME_ACTION.NEEDS_FEEDBACK_FIX:
1345
+ return `PR #${pr} is orphaned. Live state: unresolved_feedback_present. Resume the prior dev-loop from run ${runId}. Continue by fixing the remaining review feedback, then reply to and resolve each GitHub thread. Do not merge.`;
1346
+ case RESUME_ACTION.NEEDS_REPLY_RESOLVE:
1347
+ return `PR #${pr} is orphaned. Live state: already_fixed_needs_reply_resolve. Resume the prior dev-loop from run ${runId}. Continue by replying to and resolving the addressed GitHub threads before requesting another Copilot pass. Do not merge.`;
1348
+ case RESUME_ACTION.NEEDS_REREQUEST_OR_WATCH:
1349
+ return `PR #${pr} is orphaned. Live state: ${livePrState}. Resume the prior dev-loop from run ${runId}. Continue by requesting or watching the next Copilot review cycle on the current head. Do not enter gate or merge until the review settles.`;
1350
+ case RESUME_ACTION.AWAIT_FINAL_APPROVAL:
1351
+ return `PR #${pr} is orphaned. Live state: final_approval_ready. Resume the prior dev-loop from run ${runId}. Continue by summarizing the clean current-head evidence and stop at final human approval. Do not merge without explicit authorization.`;
1352
+ case RESUME_ACTION.AWAIT_MERGE_AUTHORIZATION:
1353
+ return `PR #${pr} is orphaned. Live state: clean current-head gate evidence + green CI. Resume the prior dev-loop from run ${runId}. Continue by stopping at waiting_for_merge_authorization and asking for explicit merge authorization. Do not merge automatically.`;
1354
+ case RESUME_ACTION.AWAIT_READY_FOR_REVIEW_AUTHORIZATION:
1355
+ return `PR #${pr} is orphaned. Live state: ${livePrState}. Resume the prior dev-loop from run ${runId}. Continue by staying at the current authorization boundary (assignment, ready-for-review, or draft review) and do not perform the next mutation without explicit approval.`;
1356
+ default:
1357
+ return `PR #${pr} is orphaned. Resume the prior dev-loop from run ${runId}. Continue from the last deterministic state and do not merge.`;
1358
+ }
1359
+ }
1360
+ function buildResumeCommandPreview({ runId, childIndex, childCount, resumeMessage }) {
1361
+ if (childCount > 1 || childIndex !== 0) {
1362
+ return `subagent({ action: "resume", id: "${runId}", index: ${childIndex}, message: ${JSON.stringify(resumeMessage)} })`;
1363
+ }
1364
+ return `subagent({ action: "resume", id: "${runId}", message: ${JSON.stringify(resumeMessage)} })`;
1365
+ }
1366
+ function buildManualAttentionEntry({
1367
+ pr = null,
1368
+ runId = null,
1369
+ reason,
1370
+ evidence,
1371
+ suggestedNextStep,
1372
+ }) {
1373
+ return {
1374
+ ...(Number.isInteger(pr) ? { pr } : {}),
1375
+ ...(typeof runId === "string" && runId.length > 0 ? { runId } : {}),
1376
+ reason,
1377
+ evidence,
1378
+ suggestedNextStep,
1379
+ };
1380
+ }
1381
+ export function selectLatestExitedRunForPr({ pr, exitedRuns, activeRuns }) {
1382
+ const activeMatch = activeRuns.filter((candidate) => candidate.parsedArtifact?.ok && candidate.parsedArtifact.pr === pr.number);
1383
+ const matches = exitedRuns.filter((candidate) => candidate.parsedArtifact?.ok && candidate.parsedArtifact.pr === pr.number);
1384
+ if (matches.length === 0) {
1385
+ return { kind: "none" };
1386
+ }
1387
+ const sorted = [...matches].sort((left, right) => {
1388
+ const leftTs = left.run.timestampMs ?? Number.NEGATIVE_INFINITY;
1389
+ const rightTs = right.run.timestampMs ?? Number.NEGATIVE_INFINITY;
1390
+ return rightTs - leftTs;
1391
+ });
1392
+ if (sorted.length > 1) {
1393
+ const firstTimestamp = sorted[0].run.timestampMs;
1394
+ const secondTimestamp = sorted[1].run.timestampMs;
1395
+ if (firstTimestamp === null || secondTimestamp === null || firstTimestamp === secondTimestamp) {
1396
+ return {
1397
+ kind: "manual_attention",
1398
+ reason: MANUAL_REASON.MULTIPLE_CANDIDATE_RUNS,
1399
+ runs: sorted.map((candidate) => ({
1400
+ runId: candidate.run.runId,
1401
+ childIndex: candidate.run.childIndex,
1402
+ timestampMs: candidate.run.timestampMs,
1403
+ outputArtifactPath: candidate.run.outputArtifactPath,
1404
+ sessionPath: candidate.run.sessionPath,
1405
+ })),
1406
+ };
1407
+ }
1408
+ }
1409
+ const selected = sorted[0];
1410
+ const selectedTimestamp = selected.run.timestampMs;
1411
+ if (activeMatch.length > 0) {
1412
+ const indeterminateActiveRuns = activeMatch.filter((candidate) => (
1413
+ typeof candidate.run.timestampMs !== "number"
1414
+ || typeof selectedTimestamp !== "number"
1415
+ ));
1416
+ if (indeterminateActiveRuns.length > 0) {
1417
+ return {
1418
+ kind: "manual_attention",
1419
+ reason: MANUAL_REASON.ARTIFACT_LIVE_STATE_CONFLICT,
1420
+ runs: [
1421
+ {
1422
+ runId: selected.run.runId,
1423
+ childIndex: selected.run.childIndex,
1424
+ timestampMs: selected.run.timestampMs,
1425
+ outputArtifactPath: selected.run.outputArtifactPath,
1426
+ sessionPath: selected.run.sessionPath,
1427
+ },
1428
+ ...indeterminateActiveRuns.map((candidate) => ({
1429
+ runId: candidate.run.runId,
1430
+ childIndex: candidate.run.childIndex,
1431
+ timestampMs: candidate.run.timestampMs,
1432
+ outputArtifactPath: candidate.run.outputArtifactPath,
1433
+ sessionPath: candidate.run.sessionPath,
1434
+ runState: candidate.run.runState,
1435
+ })),
1436
+ ],
1437
+ };
1438
+ }
1439
+ }
1440
+ const sameTimestampActiveRuns = activeMatch.filter((candidate) => candidate.run.timestampMs === selectedTimestamp);
1441
+ if (sameTimestampActiveRuns.length > 0) {
1442
+ return {
1443
+ kind: "manual_attention",
1444
+ reason: MANUAL_REASON.ARTIFACT_LIVE_STATE_CONFLICT,
1445
+ runs: [
1446
+ {
1447
+ runId: selected.run.runId,
1448
+ childIndex: selected.run.childIndex,
1449
+ timestampMs: selected.run.timestampMs,
1450
+ outputArtifactPath: selected.run.outputArtifactPath,
1451
+ sessionPath: selected.run.sessionPath,
1452
+ },
1453
+ ...sameTimestampActiveRuns.map((candidate) => ({
1454
+ runId: candidate.run.runId,
1455
+ childIndex: candidate.run.childIndex,
1456
+ timestampMs: candidate.run.timestampMs,
1457
+ outputArtifactPath: candidate.run.outputArtifactPath,
1458
+ sessionPath: candidate.run.sessionPath,
1459
+ runState: candidate.run.runState,
1460
+ })),
1461
+ ],
1462
+ };
1463
+ }
1464
+ const newerActiveRuns = activeMatch.filter((candidate) => candidate.run.timestampMs > selectedTimestamp);
1465
+ if (newerActiveRuns.length > 0) {
1466
+ return {
1467
+ kind: "suppressed_by_active_run",
1468
+ runIds: newerActiveRuns.map((candidate) => candidate.run.runId),
1469
+ };
1470
+ }
1471
+ return { kind: "selected", candidate: selected };
1472
+ }
1473
+ function classifyLiveStateForResume(resumeAction, prReport) {
1474
+ switch (resumeAction) {
1475
+ case RESUME_ACTION.NEEDS_FEEDBACK_FIX:
1476
+ return "unresolved_feedback_present";
1477
+ case RESUME_ACTION.NEEDS_REPLY_RESOLVE:
1478
+ return "already_fixed_needs_reply_resolve";
1479
+ case RESUME_ACTION.NEEDS_REREQUEST_OR_WATCH:
1480
+ return prReport.state;
1481
+ case RESUME_ACTION.AWAIT_FINAL_APPROVAL:
1482
+ return "final_approval_ready";
1483
+ case RESUME_ACTION.AWAIT_MERGE_AUTHORIZATION:
1484
+ return "clean current-head gate evidence + green CI";
1485
+ case RESUME_ACTION.AWAIT_READY_FOR_REVIEW_AUTHORIZATION:
1486
+ return prReport.isDraft ? "pr_draft" : prReport.state;
1487
+ default:
1488
+ return prReport.state;
1489
+ }
1490
+ }
1491
+ function hasMaterialConflict(resumeAction, prReport, parsedArtifact) {
1492
+ if (parsedArtifact.parsedArtifactState === "merged" || resumeAction === RESUME_ACTION.DONE_OR_MERGED) {
1493
+ return true;
1494
+ }
1495
+ if (resumeAction === RESUME_ACTION.AWAIT_FINAL_APPROVAL) {
1496
+ return prReport.snapshot.unresolvedThreadCount > 0 || prReport.snapshot.ciStatus === "failure";
1497
+ }
1498
+ if (resumeAction === RESUME_ACTION.AWAIT_MERGE_AUTHORIZATION) {
1499
+ return prReport.snapshot.unresolvedThreadCount > 0 || prReport.snapshot.ciStatus !== "success";
1500
+ }
1501
+ if (resumeAction === RESUME_ACTION.NEEDS_REREQUEST_OR_WATCH) {
1502
+ return prReport.state === "unresolved_feedback_present" && parsedArtifact.resumeBucket !== RESUME_ACTION.NEEDS_REPLY_RESOLVE;
1503
+ }
1504
+ return false;
1505
+ }
1506
+ const HEALTHY_CI_STATUSES = new Set(["success", "crediblyGreen"]);
1507
+
1508
+ export function isPrHealthy(prReport) {
1509
+ return prReport.snapshot.unresolvedThreadCount === 0
1510
+ && HEALTHY_CI_STATUSES.has(prReport.snapshot.ciStatus);
1511
+ }
1512
+
1513
+ const FIX_FEEDBACK_RESUME_ACTIONS = new Set([
1514
+ RESUME_ACTION.NEEDS_FEEDBACK_FIX,
1515
+ RESUME_ACTION.NEEDS_REPLY_RESOLVE,
1516
+ RESUME_ACTION.NEEDS_REREQUEST_OR_WATCH,
1517
+ ]);
1518
+
1519
+ export function buildResumePlan({ prReport, candidate, childCounts }) {
1520
+ const { run, parsedArtifact } = candidate;
1521
+ const resumeAction = parsedArtifact.resumeBucket;
1522
+ if (hasMaterialConflict(resumeAction, prReport, parsedArtifact)) {
1523
+ return {
1524
+ kind: "manual_attention",
1525
+ entry: buildManualAttentionEntry({
1526
+ pr: prReport.number,
1527
+ runId: run.runId,
1528
+ reason: MANUAL_REASON.ARTIFACT_LIVE_STATE_CONFLICT,
1529
+ evidence: {
1530
+ parsedArtifactState: parsedArtifact.parsedArtifactState,
1531
+ parsedLoopState: parsedArtifact.parsedLoopState,
1532
+ resumeAction,
1533
+ livePrState: prReport.state,
1534
+ outputArtifactPath: run.outputArtifactPath,
1535
+ sessionPath: run.sessionPath,
1536
+ },
1537
+ suggestedNextStep: "Reconcile the live PR state against the exited run artifact before resuming.",
1538
+ }),
1539
+ };
1540
+ }
1541
+ if (FIX_FEEDBACK_RESUME_ACTIONS.has(resumeAction) && isPrHealthy(prReport)) {
1542
+ return { kind: "suppressed_healthy" };
1543
+ }
1544
+ if (run.staleWorktree && !run.sessionPath && !run.outputArtifactPath && !run.resultSummaryPath && !run.resultPath) {
1545
+ return {
1546
+ kind: "manual_attention",
1547
+ entry: buildManualAttentionEntry({
1548
+ pr: prReport.number,
1549
+ runId: run.runId,
1550
+ reason: MANUAL_REASON.STALE_WORKTREE_MISSING_RESUME_INPUTS,
1551
+ evidence: {
1552
+ cwd: run.cwd,
1553
+ sessionPath: run.sessionPath,
1554
+ outputArtifactPath: run.outputArtifactPath,
1555
+ resultSummaryPath: run.resultSummaryPath,
1556
+ },
1557
+ suggestedNextStep: "Recover or recreate the missing worktree/session inputs before attempting resume.",
1558
+ }),
1559
+ };
1560
+ }
1561
+ const livePrState = classifyLiveStateForResume(resumeAction, prReport);
1562
+ const expectedHandoffContract = buildHandoffContractForResumeAction(resumeAction);
1563
+ const recordedHandoffContract = parsedArtifact.recordedHandoffContract ?? null;
1564
+ const handoffContractMismatch = compareHandoffContracts(recordedHandoffContract, expectedHandoffContract);
1565
+ if (handoffContractMismatch !== null) {
1566
+ return {
1567
+ kind: "manual_attention",
1568
+ entry: buildManualAttentionEntry({
1569
+ pr: prReport.number,
1570
+ runId: run.runId,
1571
+ reason: MANUAL_REASON.HANDOFF_CONTRACT_MISMATCH,
1572
+ evidence: {
1573
+ parsedArtifactState: parsedArtifact.parsedArtifactState,
1574
+ parsedLoopState: parsedArtifact.parsedLoopState,
1575
+ resumeAction,
1576
+ livePrState,
1577
+ recordedHandoffContract,
1578
+ expectedHandoffContract,
1579
+ outputArtifactPath: run.outputArtifactPath,
1580
+ sessionPath: run.sessionPath,
1581
+ },
1582
+ suggestedNextStep: "Reconcile the recorded handoff contract against the live PR state before resuming.",
1583
+ }),
1584
+ };
1585
+ }
1586
+ const resumeMessage = buildResumeMessage({
1587
+ pr: prReport.number,
1588
+ runId: run.runId,
1589
+ resumeAction,
1590
+ livePrState,
1591
+ });
1592
+ const childCount = childCounts.get(run.runId) ?? 1;
1593
+ return {
1594
+ kind: "resume_plan",
1595
+ entry: {
1596
+ pr: prReport.number,
1597
+ runId: run.runId,
1598
+ runState: normalizeRunStateForPlan(run.runState),
1599
+ artifactPath: parsedArtifact.artifactPath ?? run.outputArtifactPath ?? run.resultSummaryPath ?? run.resultPath ?? null,
1600
+ ...(parsedArtifact.reportingIssue ? { reportingIssue: parsedArtifact.reportingIssue } : {}),
1601
+ ...(run.sessionPath ? { sessionPath: run.sessionPath } : {}),
1602
+ parsedArtifactState: parsedArtifact.parsedArtifactState,
1603
+ parsedLoopState: parsedArtifact.parsedLoopState,
1604
+ livePrState,
1605
+ resumeAction,
1606
+ handoffContract: expectedHandoffContract,
1607
+ ...(recordedHandoffContract ? { recordedHandoffContract } : {}),
1608
+ resumeMessage,
1609
+ resumeCommandPreview: buildResumeCommandPreview({
1610
+ runId: run.runId,
1611
+ childIndex: run.childIndex,
1612
+ childCount,
1613
+ resumeMessage,
1614
+ }),
1615
+ staleWorktree: run.staleWorktree,
1616
+ },
1617
+ };
1618
+ }
1619
+ async function analyzeAutoResume({ repo, reports }, options) {
1620
+ const openPrNumbers = new Set(reports.map((report) => report.number));
1621
+ const runs = await listRepoAsyncRuns({ repo }, options);
1622
+ const childCounts = runs.reduce((map, run) => {
1623
+ map.set(run.runId, (map.get(run.runId) ?? 0) + 1);
1624
+ return map;
1625
+ }, new Map());
1626
+ const activeRuns = [];
1627
+ const exitedRuns = [];
1628
+ const manualAttention = [];
1629
+ for (const run of runs) {
1630
+ const parsedArtifact = await parseDevLoopArtifact(run);
1631
+ let candidate = { run, parsedArtifact };
1632
+ if (!parsedArtifact.ok) {
1633
+ if (isRunningLikeState(run.runState)) {
1634
+ const runningPr = parseWeakFallbackPr(parsedArtifact.weakFallbackText ?? null);
1635
+ if (Number.isInteger(runningPr)) {
1636
+ activeRuns.push({
1637
+ run,
1638
+ parsedArtifact: {
1639
+ ok: true,
1640
+ pr: runningPr,
1641
+ parsedArtifactState: "open",
1642
+ parsedLoopState: null,
1643
+ nextAction: null,
1644
+ resumeBucket: RESUME_ACTION.NEEDS_REREQUEST_OR_WATCH,
1645
+ source: "weak_fallback_output_log",
1646
+ text: parsedArtifact.weakFallbackText,
1647
+ },
1648
+ });
1649
+ }
1650
+ continue;
1651
+ }
1652
+ const parsedNumbers = Array.isArray(parsedArtifact.evidence?.prNumbers)
1653
+ ? parsedArtifact.evidence.prNumbers.filter((value) => Number.isInteger(value))
1654
+ : [];
1655
+ const touchesOpenPr = openPrNumbers.has(parsedArtifact.pr)
1656
+ || parsedNumbers.some((value) => openPrNumbers.has(value));
1657
+ if ((run.runState === RUN_STATE.UNKNOWN || isExitedState(run.runState)) && touchesOpenPr) {
1658
+ manualAttention.push(buildManualAttentionEntry({
1659
+ pr: Number.isInteger(parsedArtifact.pr) ? parsedArtifact.pr : null,
1660
+ runId: run.runId,
1661
+ reason: parsedArtifact.reason,
1662
+ evidence: {
1663
+ ...parsedArtifact.evidence,
1664
+ cwd: run.cwd,
1665
+ sessionPath: run.sessionPath,
1666
+ outputLogPath: run.outputLogPath,
1667
+ },
1668
+ suggestedNextStep: parsedArtifact.reason === MANUAL_REASON.MISSING_OUTPUT_ARTIFACT
1669
+ ? "Locate the missing output artifact or inspect the saved session before attempting resume."
1670
+ : "Inspect the saved artifact/session manually and reconcile the PR identity/state before resuming.",
1671
+ }));
1672
+ }
1673
+ continue;
1674
+ }
1675
+ candidate = { run, parsedArtifact };
1676
+ if (isRunningLikeState(run.runState)) {
1677
+ activeRuns.push(candidate);
1678
+ continue;
1679
+ }
1680
+ if (isExitedState(run.runState)) {
1681
+ exitedRuns.push(candidate);
1682
+ continue;
1683
+ }
1684
+ if (run.runState === RUN_STATE.UNKNOWN && openPrNumbers.has(parsedArtifact.pr)) {
1685
+ manualAttention.push(buildManualAttentionEntry({
1686
+ pr: parsedArtifact.pr,
1687
+ runId: run.runId,
1688
+ reason: MANUAL_REASON.ARTIFACT_LIVE_STATE_CONFLICT,
1689
+ evidence: {
1690
+ runState: run.runState,
1691
+ outputArtifactPath: run.outputArtifactPath,
1692
+ sessionPath: run.sessionPath,
1693
+ },
1694
+ suggestedNextStep: "Resolve the run state for this candidate before preparing a resume plan.",
1695
+ }));
1696
+ }
1697
+ }
1698
+ const resumePlans = [];
1699
+ for (const prReport of reports) {
1700
+ const selection = selectLatestExitedRunForPr({ pr: prReport, exitedRuns, activeRuns });
1701
+ if (selection.kind === "manual_attention") {
1702
+ manualAttention.push(buildManualAttentionEntry({
1703
+ pr: prReport.number,
1704
+ reason: selection.reason,
1705
+ evidence: { runs: selection.runs },
1706
+ suggestedNextStep: "Choose the correct exited run manually before attempting resume.",
1707
+ }));
1708
+ continue;
1709
+ }
1710
+ if (selection.kind !== "selected") {
1711
+ continue;
1712
+ }
1713
+ const built = buildResumePlan({
1714
+ prReport,
1715
+ candidate: selection.candidate,
1716
+ childCounts,
1717
+ });
1718
+ if (built.kind === "suppressed_healthy") {
1719
+ continue;
1720
+ }
1721
+ if (built.kind === "manual_attention") {
1722
+ manualAttention.push(built.entry);
1723
+ continue;
1724
+ }
1725
+ resumePlans.push(built.entry);
1726
+ }
1727
+ const orphanedPrs = new Set();
1728
+ resumePlans.forEach((plan) => orphanedPrs.add(plan.pr));
1729
+ manualAttention.forEach((entry) => {
1730
+ if (Number.isInteger(entry.pr)) {
1731
+ orphanedPrs.add(entry.pr);
1732
+ }
1733
+ });
1734
+ const localPhaseRuns = await scanLocalPhaseSubagents(options.repoRoot ?? process.cwd());
1735
+ const localPhaseResumePlans = localPhaseRuns
1736
+ .map((run) => buildLocalPhaseResumePlan(run));
1737
+ return {
1738
+ orphanedPrCount: orphanedPrs.size,
1739
+ resumePlanCount: resumePlans.length,
1740
+ manualAttentionCount: manualAttention.length,
1741
+ localPhaseOrphanedCount: localPhaseResumePlans.filter(p => p.runState !== RUN_STATE.COMPLETED).length,
1742
+ resumePlans,
1743
+ needsManualAttention: manualAttention,
1744
+ localPhaseResumePlans,
1745
+ };
1746
+ }
1747
+ function applyAutoResumeToBaseResult(baseResult, autoResume) {
1748
+ const orphanAttentionPrs = new Set();
1749
+ autoResume.resumePlans.forEach((entry) => orphanAttentionPrs.add(entry.pr));
1750
+ autoResume.needsManualAttention.forEach((entry) => {
1751
+ if (Number.isInteger(entry.pr)) {
1752
+ orphanAttentionPrs.add(entry.pr);
1753
+ }
1754
+ });
1755
+ const localPhaseAttention = (autoResume.localPhaseResumePlans?.filter(p => p.runState !== RUN_STATE.COMPLETED)?.length ?? 0) > 0;
1756
+ const queueNeedsAttention = baseResult.queueStatus === "attention_needed"
1757
+ || autoResume.resumePlanCount > 0
1758
+ || autoResume.manualAttentionCount > 0
1759
+ || localPhaseAttention;
1760
+ const queueStatus = baseResult.prCount === 0 && !localPhaseAttention
1761
+ ? "queue_complete"
1762
+ : (queueNeedsAttention ? "attention_needed" : "monitoring");
1763
+ const liveAttentionPrs = new Set(baseResult.prs.filter((pr) => pr.needsAttention).map((pr) => pr.number));
1764
+ const needsAttentionCount = new Set([...liveAttentionPrs, ...orphanAttentionPrs]).size;
1765
+ return {
1766
+ ...baseResult,
1767
+ queueStatus,
1768
+ needsAttentionCount,
1769
+ autoResumeRequested: true,
1770
+ orphanedPrCount: autoResume.orphanedPrCount,
1771
+ resumePlanCount: autoResume.resumePlanCount,
1772
+ manualAttentionCount: autoResume.manualAttentionCount,
1773
+ localPhaseOrphanedCount: autoResume.localPhaseOrphanedCount ?? 0,
1774
+ resumePlans: autoResume.resumePlans,
1775
+ needsManualAttention: autoResume.needsManualAttention,
1776
+ localPhaseResumePlans: autoResume.localPhaseResumePlans ?? [],
1777
+ };
1778
+ }
1779
+ export async function runConductorMonitor(
1780
+ { repo, autoResume = false },
1781
+ {
1782
+ env = process.env,
1783
+ ghCommand = "gh",
1784
+ repoRoot = process.cwd(),
1785
+ sessionRoots,
1786
+ asyncRunRoots,
1787
+ asyncResultRoots,
1788
+ } = {},
1789
+ ) {
1790
+ const prs = await listOpenPrs({ repo }, { env, ghCommand });
1791
+ if (prs.length === 0) {
1792
+ const baseResult = buildBaseResult(repo, []);
1793
+ if (!autoResume) {
1794
+ const localRuns = await scanLocalPhaseSubagents(repoRoot);
1795
+ if (localRuns.length > 0) {
1796
+ return {
1797
+ ...baseResult,
1798
+ localPhaseOrphanedCount: localRuns.filter(r => r.runState !== RUN_STATE.COMPLETED).length,
1799
+ localPhaseResumePlans: localRuns.map((run) => buildLocalPhaseResumePlan(run)),
1800
+ };
1801
+ }
1802
+ return baseResult;
1803
+ }
1804
+ const localPhaseRuns = await scanLocalPhaseSubagents(repoRoot);
1805
+ return applyAutoResumeToBaseResult(baseResult, {
1806
+ orphanedPrCount: 0,
1807
+ resumePlanCount: 0,
1808
+ manualAttentionCount: 0,
1809
+ resumePlans: [],
1810
+ needsManualAttention: [],
1811
+ localPhaseOrphanedCount: localPhaseRuns.filter(r => r.runState !== RUN_STATE.COMPLETED).length,
1812
+ localPhaseResumePlans: localPhaseRuns.map((run) => buildLocalPhaseResumePlan(run)),
1813
+ });
1814
+ }
1815
+ const reports = await buildPrReports(prs, { repo, env, ghCommand });
1816
+ const baseResult = buildBaseResult(repo, reports);
1817
+ if (!autoResume) {
1818
+ return baseResult;
1819
+ }
1820
+ const autoResumeResult = await analyzeAutoResume({ repo, reports }, {
1821
+ env,
1822
+ repoRoot,
1823
+ sessionRoots,
1824
+ asyncRunRoots,
1825
+ asyncResultRoots,
1826
+ });
1827
+ return applyAutoResumeToBaseResult(baseResult, autoResumeResult);
1828
+ }
1829
+ export async function runCli(
1830
+ argv = process.argv.slice(2),
1831
+ { stdout = process.stdout, env = process.env, ghCommand = "gh", cwd = process.cwd() } = {},
1832
+ ) {
1833
+ const options = parseCliArgs(argv);
1834
+ if (options.help) {
1835
+ stdout.write(`${USAGE}\n`);
1836
+ return;
1837
+ }
1838
+ const result = await runConductorMonitor(options, {
1839
+ env,
1840
+ ghCommand,
1841
+ repoRoot: cwd,
1842
+ });
1843
+ stdout.write(`${JSON.stringify(result)}\n`);
1844
+ }
1845
+ if (isDirectCliRun(import.meta.url)) {
1846
+ runCli().catch((error) => {
1847
+ process.stderr.write(`${formatCliError(error)}\n`);
1848
+ process.exitCode = 1;
1849
+ });
1850
+ }