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,419 @@
1
+ #!/usr/bin/env node
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { parsePrNumber, requireOptionValue, runChild } from "../_cli-primitives.mjs";
6
+ import { buildParseError, formatCliError, parseJsonText } from "../_core-helpers.mjs";
7
+ import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
8
+ import {
9
+ buildCheckpointFilePath,
10
+ buildDefaultCheckpointDir,
11
+ } from "./_checkpoint-paths.mjs";
12
+ import { readExistingCheckpoint } from "./_checkpoint-io.mjs";
13
+ import { loadCopilotEvidence, loadReviewerEvidence } from "./_loop-evidence.mjs";
14
+ import {
15
+ ENTRYPOINT,
16
+ evaluateConductorRouting,
17
+ LOOP_FAMILY,
18
+ ROUTING_OUTCOME,
19
+ SOURCE_MODE,
20
+ } from "@dev-loops/core/loop/conductor-routing";
21
+ import {
22
+ ASYNC_START_STATUS,
23
+ buildAsyncStartRejection,
24
+ validateAsyncStartContext,
25
+ } from "@dev-loops/core/loop/async-start-contract";
26
+ import { loadDevLoopConfig, resolveConductorModel, resolveAutonomyStopAt, resolveWorkflowConfig } from "@dev-loops/core/config";
27
+ const USAGE = `Usage: outer-loop.mjs --repo <owner/name> --pr <number>
28
+ Thin outer-loop wrapper for the Copilot PR remediation loop.
29
+ Detects current PR state from both the Copilot inner loop and the reviewer
30
+ inner loop, decides the outer-loop action, and persists a minimal checkpoint.
31
+ Required:
32
+ --repo <owner/name> Repository slug (e.g. owner/repo)
33
+ --pr <number> Pull request number
34
+ --checkpoint-dir <dir> Directory for checkpoint artifact
35
+ (default: tmp/copilot-loop/<owner>/<repo>/pr-<n>/)
36
+ --copilot-input <path> Path to a pre-built copilot snapshot JSON
37
+ (skips live copilot detection; for testing)
38
+ --reviewer-input <path> Path to a pre-built reviewer snapshot JSON
39
+ (skips live reviewer detection; for testing)
40
+ Output (stdout, JSON):
41
+ { "ok": true, "outerAction": "...", "copilotState": "...",
42
+ "reviewerState": "...", "reviewerScope": { "mode": "...",
43
+ "reviewerLogin": "..."|null }, "reason"?: "...",
44
+ "conductorRouting": { "routingOutcome": "...", "outerAction": "...",
45
+ "stopReason": null|"...", "handoffEnvelope": { ... } },
46
+ "checkpoint": { "pr": N, "repo": "...", "outerAction": "...",
47
+ "copilotState": "...", "reviewerState": "...",
48
+ "reviewerScope": "...", "reviewerLogin": "..."|null,
49
+ "reason": null|"...", "timestamp": "...", "waitCycles": N,
50
+ "headSha": "..."|null },
51
+ "conductorModel": "..."|null }
52
+ Outer actions:
53
+ continue_wait Durable outer-loop wait state; re-run after bounded wait
54
+ reenter_copilot_loop Copilot inner loop needs action
55
+ reenter_reviewer_loop Reviewer inner loop needs action
56
+ stop Terminal, blocked, or reconcile-needed; do not proceed
57
+ done PR is merged or closed; loop complete
58
+ Stop reasons:
59
+ pr_not_ready PR does not exist
60
+ copilot_blocked Copilot loop is blocked
61
+ reviewer_blocked Reviewer loop is blocked
62
+ review_unavailable Copilot review is unavailable
63
+ unsafe_local_branch_mismatch_requires_reconcile
64
+ Next step needs PR-local work but local
65
+ branch does not match PR head branch
66
+ unsafe_local_head_mismatch_requires_reconcile
67
+ Next step needs PR-local work but local
68
+ HEAD does not match PR head commit
69
+ unknown_state Unrecognized combined state
70
+ Async-start contract:
71
+ This loop must run within a visible Pi-managed async context when
72
+ workflow.asyncStartMode is set to required (default). It fails closed unless
73
+ PI_SUBAGENT_RUN_ID is set, to prevent hidden detached-process fallback
74
+ (nohup, disowned shell jobs, etc.). Snapshot/test input mode
75
+ (both --copilot-input and --reviewer-input) is exempt. Any relaxed
76
+ async-start posture is maintainer-controlled repository policy, not an
77
+ agent-tunable runtime path.
78
+ Error output (stderr, JSON):
79
+ Argument/usage errors:
80
+ { "ok": false, "error": "...", "usage": "..." }
81
+ gh/git/runtime failures:
82
+ { "ok": false, "error": "..." }
83
+ Async-start contract rejection:
84
+ { "ok": false, "error": "...", "asyncStartContract": "rejected" }
85
+ Exit codes:
86
+ 0 Success
87
+ 1 Argument error, gh/git failure, or indeterminate state`.trim();
88
+ const parseError = buildParseError(USAGE);
89
+ export function parseOuterLoopCliArgs(argv) {
90
+ const args = [...argv];
91
+ const options = {
92
+ help: false,
93
+ repo: undefined,
94
+ pr: undefined,
95
+ checkpointDir: undefined,
96
+ copilotInputPath: undefined,
97
+ reviewerInputPath: undefined,
98
+ };
99
+ while (args.length > 0) {
100
+ const token = args.shift();
101
+ if (token === "--help" || token === "-h") {
102
+ options.help = true;
103
+ return options;
104
+ }
105
+ if (token === "--repo") {
106
+ options.repo = requireOptionValue(args, "--repo", parseError).trim();
107
+ continue;
108
+ }
109
+ if (token === "--pr") {
110
+ options.pr = parsePrNumber(requireOptionValue(args, "--pr", parseError), parseError);
111
+ continue;
112
+ }
113
+ if (token === "--checkpoint-dir") {
114
+ options.checkpointDir = requireOptionValue(args, "--checkpoint-dir", parseError);
115
+ continue;
116
+ }
117
+ if (token === "--copilot-input") {
118
+ options.copilotInputPath = requireOptionValue(args, "--copilot-input", parseError);
119
+ continue;
120
+ }
121
+ if (token === "--reviewer-input") {
122
+ options.reviewerInputPath = requireOptionValue(args, "--reviewer-input", parseError);
123
+ continue;
124
+ }
125
+ throw parseError(`Unknown argument: ${token}`);
126
+ }
127
+ if (!options.help) {
128
+ if (options.repo === undefined || options.pr === undefined) {
129
+ throw parseError("outer-loop requires both --repo <owner/name> and --pr <number>");
130
+ }
131
+ try {
132
+ parseRepoSlug(options.repo);
133
+ } catch (error) {
134
+ throw parseError(error instanceof Error ? error.message : String(error));
135
+ }
136
+ }
137
+ return options;
138
+ }
139
+ async function checkGitStatus({ env = process.env, gitCommand = "git" } = {}) {
140
+ const [statusResult, headRefResult, headShaResult] = await Promise.all([
141
+ runChild(gitCommand, ["status", "--porcelain"], env),
142
+ runChild(gitCommand, ["rev-parse", "--abbrev-ref", "HEAD"], env),
143
+ runChild(gitCommand, ["rev-parse", "HEAD"], env),
144
+ ]);
145
+ const isDirty = statusResult.code === 0
146
+ ? statusResult.stdout.trim().length > 0
147
+ : false;
148
+ const headRef = headRefResult.code === 0
149
+ ? headRefResult.stdout.trim()
150
+ : "";
151
+ const isDetached = headRef === "HEAD";
152
+ const branchName = !isDetached && headRef.length > 0 ? headRef : null;
153
+ const headSha = headShaResult.code === 0
154
+ ? headShaResult.stdout.trim() || null
155
+ : null;
156
+ return { isDirty, isDetached, branchName, headSha };
157
+ }
158
+ async function fetchPrHeadIdentity({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
159
+ const result = await runChild(
160
+ ghCommand,
161
+ ["pr", "view", String(pr), "--repo", repo, "--json", "headRefName,headRefOid"],
162
+ env,
163
+ );
164
+ if (result.code !== 0) {
165
+ const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`;
166
+ throw new Error(`Failed to read PR head identity: ${detail}`);
167
+ }
168
+ const payload = parseJsonText(result.stdout);
169
+ const branchName = typeof payload.headRefName === "string" && payload.headRefName.trim().length > 0
170
+ ? payload.headRefName.trim()
171
+ : null;
172
+ const headSha = typeof payload.headRefOid === "string" && payload.headRefOid.trim().length > 0
173
+ ? payload.headRefOid.trim()
174
+ : null;
175
+ return { branchName, headSha };
176
+ }
177
+ function requiresPrLocalIdentityGate(outerAction) {
178
+ return outerAction === "reenter_copilot_loop" || outerAction === "reenter_reviewer_loop";
179
+ }
180
+ function evaluatePrLocalIdentity({
181
+ localBranch,
182
+ localHeadSha,
183
+ prBranch,
184
+ prHeadSha,
185
+ }) {
186
+ const branchMatches = typeof prBranch === "string" && prBranch.length > 0
187
+ ? localBranch === prBranch
188
+ : null;
189
+ const headMatches = typeof prHeadSha === "string" && prHeadSha.length > 0
190
+ ? localHeadSha === prHeadSha
191
+ : null;
192
+ const mismatchReason = branchMatches === false
193
+ ? "unsafe_local_branch_mismatch_requires_reconcile"
194
+ : (headMatches === false ? "unsafe_local_head_mismatch_requires_reconcile" : null);
195
+ return {
196
+ localBranch,
197
+ localHeadSha,
198
+ prBranch,
199
+ prHeadSha,
200
+ branchMatches,
201
+ headMatches,
202
+ mismatchReason,
203
+ };
204
+ }
205
+ function buildPrLocalIdentityStopRouting({
206
+ repo,
207
+ pr,
208
+ branchIdentity,
209
+ }) {
210
+ const targetIdentity = { repo, pr };
211
+ const reason = branchIdentity.mismatchReason === "unsafe_local_branch_mismatch_requires_reconcile"
212
+ ? `Local branch '${branchIdentity.localBranch ?? "(unknown)"}' does not match PR head branch '${branchIdentity.prBranch ?? "(unknown)"}'; reconcile local branch/worktree before PR-local follow-up.`
213
+ : `Local HEAD '${branchIdentity.localHeadSha ?? "(unknown)"}' does not match PR head '${branchIdentity.prHeadSha ?? "(unknown)"}'; reconcile local branch/worktree before PR-local follow-up.`;
214
+ return {
215
+ routingOutcome: ROUTING_OUTCOME.STOP_NEEDS_HUMAN,
216
+ outerAction: "stop",
217
+ stopReason: branchIdentity.mismatchReason,
218
+ handoffEnvelope: {
219
+ targetIdentity,
220
+ loopFamily: LOOP_FAMILY.NONE,
221
+ entrypoint: ENTRYPOINT.NONE,
222
+ reason,
223
+ requiredArgs: { repo, pr },
224
+ requiresLocalIsolation: false,
225
+ confidence: SOURCE_MODE.LOCAL,
226
+ },
227
+ };
228
+ }
229
+ function enrichHandoffWithPrHeadIdentity(routing, { branchName, headSha }) {
230
+ if (!routing || typeof routing !== "object" || !routing.handoffEnvelope || typeof routing.handoffEnvelope !== "object") {
231
+ return routing;
232
+ }
233
+ const requiredArgs = {
234
+ ...(routing.handoffEnvelope.requiredArgs && typeof routing.handoffEnvelope.requiredArgs === "object"
235
+ ? routing.handoffEnvelope.requiredArgs
236
+ : {}),
237
+ ...(typeof branchName === "string" && branchName.length > 0 ? { headRefName: branchName } : {}),
238
+ ...(typeof headSha === "string" && headSha.length > 0 ? { headRefOid: headSha } : {}),
239
+ };
240
+ return {
241
+ ...routing,
242
+ handoffEnvelope: {
243
+ ...routing.handoffEnvelope,
244
+ requiredArgs,
245
+ },
246
+ };
247
+ }
248
+ async function writeCheckpoint(checkpointDir, checkpoint) {
249
+ await mkdir(checkpointDir, { recursive: true });
250
+ const filePath = buildCheckpointFilePath(checkpointDir);
251
+ await writeFile(filePath, `${JSON.stringify(checkpoint, null, 2)}\n`, "utf8");
252
+ }
253
+ function shouldCarryForwardWaitCycles(previousCheckpoint, { repo, pr, headSha, outerAction }) {
254
+ return previousCheckpoint !== null
255
+ && previousCheckpoint.outerAction === "continue_wait"
256
+ && outerAction === "continue_wait"
257
+ && previousCheckpoint.repo === repo
258
+ && previousCheckpoint.pr === pr
259
+ && typeof previousCheckpoint.headSha === "string"
260
+ && previousCheckpoint.headSha.length > 0
261
+ && typeof headSha === "string"
262
+ && headSha.length > 0
263
+ && previousCheckpoint.headSha === headSha;
264
+ }
265
+ export function decideOuterAction({ copilotState, reviewerState, gitStatus }) {
266
+ const routing = evaluateConductorRouting({
267
+ target: { repo: "routing/sentinel", pr: 1 },
268
+ copilotState,
269
+ reviewerState,
270
+ requiresLocalIsolation: gitStatus.isDirty || gitStatus.isDetached,
271
+ });
272
+ return {
273
+ outerAction: routing.outerAction,
274
+ ...(routing.stopReason !== null ? { reason: routing.stopReason } : {}),
275
+ };
276
+ }
277
+ export async function runOuterLoop(options, { env = process.env, ghCommand = "gh", gitCommand = "git" } = {}) {
278
+ const { repo, pr, copilotInputPath, reviewerInputPath } = options;
279
+ const normalizedRepo = repo.trim().toLowerCase();
280
+ const checkpointDir = options.checkpointDir ?? buildDefaultCheckpointDir(normalizedRepo, pr);
281
+ const isSnapshotMode = copilotInputPath !== undefined && reviewerInputPath !== undefined;
282
+ let devLoopConfig = null;
283
+ if (!isSnapshotMode) {
284
+ const loaded = await loadDevLoopConfig();
285
+ if (loaded.errors.length === 0) {
286
+ devLoopConfig = loaded.config;
287
+ }
288
+ }
289
+ const asyncStartMode = devLoopConfig === null
290
+ ? "required"
291
+ : resolveWorkflowConfig(devLoopConfig, "asyncStartMode");
292
+ const asyncStartValidation = validateAsyncStartContext({ env, isSnapshotMode, asyncStartMode });
293
+ if (asyncStartValidation.status === ASYNC_START_STATUS.REJECTED) {
294
+ return buildAsyncStartRejection(asyncStartValidation);
295
+ }
296
+ const { snapshot: copilotSnapshot, interpretation: copilotInterpretation } = await loadCopilotEvidence(
297
+ { repo: normalizedRepo, pr, copilotInputPath },
298
+ { env, ghCommand },
299
+ );
300
+ const { snapshot: reviewerSnapshot, interpretation: reviewerInterpretation } = await loadReviewerEvidence(
301
+ { repo: normalizedRepo, pr, reviewerInputPath },
302
+ { env, ghCommand },
303
+ );
304
+ const currentHeadSha = typeof reviewerSnapshot?.prHeadSha === "string" && reviewerSnapshot.prHeadSha.length > 0
305
+ ? reviewerSnapshot.prHeadSha
306
+ : null;
307
+ const gitStatus = await checkGitStatus({ env, gitCommand });
308
+ const sourceMode = (copilotInputPath !== undefined && reviewerInputPath !== undefined)
309
+ ? "snapshot"
310
+ : "local";
311
+ let conductorRouting = evaluateConductorRouting({
312
+ target: { repo: normalizedRepo, pr },
313
+ copilotState: copilotInterpretation.state,
314
+ reviewerState: reviewerInterpretation.state,
315
+ sourceMode,
316
+ requiresLocalIsolation: gitStatus.isDirty || gitStatus.isDetached,
317
+ });
318
+ let outerAction = conductorRouting.outerAction;
319
+ let outerReason = conductorRouting.stopReason;
320
+ let branchIdentity = null;
321
+ if (outerReason === null && sourceMode === "local" && requiresPrLocalIdentityGate(outerAction)) {
322
+ const prHeadIdentity = await fetchPrHeadIdentity({ repo: normalizedRepo, pr }, { env, ghCommand });
323
+ conductorRouting = enrichHandoffWithPrHeadIdentity(conductorRouting, prHeadIdentity);
324
+ branchIdentity = evaluatePrLocalIdentity({
325
+ localBranch: gitStatus.branchName,
326
+ localHeadSha: gitStatus.headSha,
327
+ prBranch: prHeadIdentity.branchName,
328
+ prHeadSha: prHeadIdentity.headSha ?? currentHeadSha,
329
+ });
330
+ const isolationManagedHandoff = conductorRouting.handoffEnvelope?.requiresLocalIsolation === true;
331
+ if (branchIdentity.mismatchReason !== null && !isolationManagedHandoff) {
332
+ outerAction = "stop";
333
+ outerReason = branchIdentity.mismatchReason;
334
+ conductorRouting = buildPrLocalIdentityStopRouting({
335
+ repo: normalizedRepo,
336
+ pr,
337
+ branchIdentity,
338
+ });
339
+ }
340
+ }
341
+ const { checkpoint: prevCheckpoint } = await readExistingCheckpoint(normalizedRepo, pr, {
342
+ checkpointDir: options.checkpointDir,
343
+ });
344
+ const prevWaitCycles = typeof prevCheckpoint?.waitCycles === "number" ? prevCheckpoint.waitCycles : 0;
345
+ const waitCycles = shouldCarryForwardWaitCycles(prevCheckpoint, {
346
+ repo: normalizedRepo,
347
+ pr,
348
+ headSha: currentHeadSha,
349
+ outerAction,
350
+ })
351
+ ? prevWaitCycles + 1
352
+ : (outerAction === "continue_wait" ? 1 : 0);
353
+ const checkpoint = {
354
+ pr,
355
+ repo: normalizedRepo,
356
+ outerAction,
357
+ copilotState: copilotInterpretation.state,
358
+ reviewerState: reviewerInterpretation.state,
359
+ reviewerScope: reviewerSnapshot.reviewerScope,
360
+ reviewerLogin: reviewerSnapshot.reviewerLogin,
361
+ reason: outerReason ?? null,
362
+ timestamp: new Date().toISOString(),
363
+ waitCycles,
364
+ headSha: currentHeadSha,
365
+ };
366
+ await writeCheckpoint(checkpointDir, checkpoint);
367
+ let conductorModel = null;
368
+ let autonomyStopAt = null;
369
+ if (devLoopConfig !== null) {
370
+ conductorModel = resolveConductorModel(devLoopConfig);
371
+ autonomyStopAt = resolveAutonomyStopAt(devLoopConfig);
372
+ }
373
+ return {
374
+ ok: true,
375
+ outerAction,
376
+ copilotState: copilotInterpretation.state,
377
+ reviewerState: reviewerInterpretation.state,
378
+ reviewerScope: {
379
+ mode: reviewerSnapshot.reviewerScope,
380
+ reviewerLogin: reviewerSnapshot.reviewerLogin,
381
+ },
382
+ ...(outerReason !== null && outerReason !== undefined ? { reason: outerReason } : {}),
383
+ ...(branchIdentity !== null ? { branchIdentity } : {}),
384
+ conductorRouting,
385
+ checkpoint,
386
+ conductorModel,
387
+ autonomyStopAt,
388
+ };
389
+ }
390
+ export async function runCli(
391
+ argv = process.argv.slice(2),
392
+ {
393
+ stdout = process.stdout,
394
+ stderr = process.stderr,
395
+ env = process.env,
396
+ ghCommand = "gh",
397
+ gitCommand = "git",
398
+ } = {},
399
+ ) {
400
+ const options = parseOuterLoopCliArgs(argv);
401
+ if (options.help) {
402
+ stdout.write(`${USAGE}\n`);
403
+ return;
404
+ }
405
+ const result = await runOuterLoop(options, { env, ghCommand, gitCommand });
406
+ if (result.ok === false) {
407
+ stderr.write(`${JSON.stringify(result)}\n`);
408
+ process.exitCode = 1;
409
+ return;
410
+ }
411
+ stdout.write(`${JSON.stringify(result)}\n`);
412
+ }
413
+ const isDirectRun = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
414
+ if (isDirectRun) {
415
+ runCli().catch((error) => {
416
+ process.stderr.write(`${formatCliError(error)}\n`);
417
+ process.exitCode = 1;
418
+ });
419
+ }
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env node
2
+ import process from "node:process";
3
+ import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
4
+ import { parsePrNumber, requireOptionValue } from "../_cli-primitives.mjs";
5
+ import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
6
+ import {
7
+ assertRunnerOwnership,
8
+ claimRunnerOwnership,
9
+ loadRunnerCoordinationState,
10
+ releaseRunnerOwnership,
11
+ } from "./_pr-runner-coordination.mjs";
12
+ const USAGE = `Usage:
13
+ pr-runner-coordination.mjs status --repo <owner/name> --pr <number>
14
+ pr-runner-coordination.mjs claim --repo <owner/name> --pr <number> [--run-id <id>]
15
+ pr-runner-coordination.mjs takeover --repo <owner/name> --pr <number> [--run-id <id>]
16
+ pr-runner-coordination.mjs assert --repo <owner/name> --pr <number> [--run-id <id>] [--require-existing]
17
+ pr-runner-coordination.mjs release --repo <owner/name> --pr <number> [--run-id <id>]
18
+ Durable one-runner-per-PR coordination helper.
19
+ If --run-id is omitted for claim/assert/release/takeover, PI_SUBAGENT_RUN_ID is used.
20
+ Output:
21
+ stdout: { "ok": true, ... }
22
+ stderr: { "ok": false, "error": "...", ... }
23
+ Exit codes:
24
+ 0 Success / clean stop-compatible result
25
+ 1 Argument error or coordination conflict`.trim();
26
+ const parseError = buildParseError(USAGE);
27
+ function parseCliArgs(argv) {
28
+ const args = [...argv];
29
+ const options = {
30
+ help: false,
31
+ command: null,
32
+ repo: undefined,
33
+ pr: undefined,
34
+ runId: undefined,
35
+ requireExisting: false,
36
+ };
37
+ const command = args.shift();
38
+ if (command === undefined || command === "--help" || command === "-h") {
39
+ options.help = true;
40
+ return options;
41
+ }
42
+ options.command = command;
43
+ while (args.length > 0) {
44
+ const token = args.shift();
45
+ if (token === "--help" || token === "-h") {
46
+ options.help = true;
47
+ return options;
48
+ }
49
+ if (token === "--repo") {
50
+ options.repo = requireOptionValue(args, "--repo", parseError).trim();
51
+ continue;
52
+ }
53
+ if (token === "--pr") {
54
+ options.pr = parsePrNumber(requireOptionValue(args, "--pr", parseError), parseError);
55
+ continue;
56
+ }
57
+ if (token === "--run-id") {
58
+ options.runId = requireOptionValue(args, "--run-id", parseError).trim();
59
+ continue;
60
+ }
61
+ if (token === "--require-existing") {
62
+ options.requireExisting = true;
63
+ continue;
64
+ }
65
+ throw parseError(`Unknown argument: ${token}`);
66
+ }
67
+ const validCommands = new Set(["status", "claim", "takeover", "assert", "release"]);
68
+ if (!validCommands.has(options.command)) {
69
+ throw parseError(`Unknown subcommand: ${options.command}`);
70
+ }
71
+ if (options.repo === undefined || options.pr === undefined) {
72
+ throw parseError("pr-runner-coordination requires both --repo <owner/name> and --pr <number>");
73
+ }
74
+ try {
75
+ parseRepoSlug(options.repo);
76
+ } catch (error) {
77
+ throw parseError(error instanceof Error ? error.message : String(error));
78
+ }
79
+ return options;
80
+ }
81
+ function resolveRunId(explicitRunId, env) {
82
+ return typeof explicitRunId === "string" && explicitRunId.trim().length > 0
83
+ ? explicitRunId.trim()
84
+ : (typeof env?.PI_SUBAGENT_RUN_ID === "string" && env.PI_SUBAGENT_RUN_ID.trim().length > 0
85
+ ? env.PI_SUBAGENT_RUN_ID.trim()
86
+ : null);
87
+ }
88
+ export async function runPrRunnerCoordination(options, { env = process.env, cwd = process.cwd() } = {}) {
89
+ if (options.command === "status") {
90
+ const { filePath, state } = await loadRunnerCoordinationState({ repo: options.repo, pr: options.pr, cwd });
91
+ return {
92
+ ok: true,
93
+ command: "status",
94
+ repo: options.repo.trim().toLowerCase(),
95
+ pr: options.pr,
96
+ filePath,
97
+ state,
98
+ };
99
+ }
100
+ const runId = resolveRunId(options.runId, env);
101
+ if (options.command === "claim") {
102
+ return claimRunnerOwnership({ repo: options.repo, pr: options.pr, runId, mode: "claim", cwd });
103
+ }
104
+ if (options.command === "takeover") {
105
+ return claimRunnerOwnership({ repo: options.repo, pr: options.pr, runId, mode: "takeover", cwd });
106
+ }
107
+ if (options.command === "assert") {
108
+ return assertRunnerOwnership({
109
+ repo: options.repo,
110
+ pr: options.pr,
111
+ runId,
112
+ requireExisting: options.requireExisting,
113
+ cwd,
114
+ });
115
+ }
116
+ if (options.command === "release") {
117
+ return releaseRunnerOwnership({ repo: options.repo, pr: options.pr, runId, cwd });
118
+ }
119
+ throw new Error(`Unhandled runner coordination command: ${options.command}`);
120
+ }
121
+ async function main() {
122
+ try {
123
+ const options = parseCliArgs(process.argv.slice(2));
124
+ if (options.help) {
125
+ console.log(USAGE);
126
+ return;
127
+ }
128
+ const result = await runPrRunnerCoordination(options, { env: process.env });
129
+ if (!result.ok) {
130
+ console.error(JSON.stringify(result));
131
+ process.exitCode = 1;
132
+ return;
133
+ }
134
+ console.log(JSON.stringify(result));
135
+ } catch (error) {
136
+ const payload = formatCliError(error, { usage: USAGE });
137
+ console.error(JSON.stringify(payload));
138
+ process.exitCode = 1;
139
+ }
140
+ }
141
+ if (isDirectCliRun(import.meta.url)) {
142
+ await main();
143
+ }
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env node
2
+ import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
3
+ import { requireOptionValue, runCommand } from "../_cli-primitives.mjs";
4
+ import {
5
+ isUnderWorktreePath, parseMainWorktreePath, isMainCheckout,
6
+ } from "@dev-loops/core/loop/worktree-guard";
7
+
8
+ const USAGE = `Usage:
9
+ pre-commit-branch-guard.mjs --expected-branch <name> [--require-worktree] [--block-main-checkout]
10
+
11
+ Verify the current git branch identity and/or worktree isolation before local commit steps.`;
12
+
13
+ const parseError = buildParseError(USAGE);
14
+
15
+ export function parseBranchGuardCliArgs(argv) {
16
+ const args = [...argv];
17
+ const options = { help: false, expectedBranch: undefined, requireWorktree: false, blockMainCheckout: false };
18
+ while (args.length > 0) {
19
+ const token = args.shift();
20
+ if (token === "--help" || token === "-h") { options.help = true; return options; }
21
+ if (token === "--expected-branch") { options.expectedBranch = requireOptionValue(args, "--expected-branch", parseError, { flagPattern: /^-/u }); continue; }
22
+ if (token === "--require-worktree") { options.requireWorktree = true; continue; }
23
+ if (token === "--block-main-checkout") { options.blockMainCheckout = true; continue; }
24
+ throw parseError(`Unknown argument: ${token}`);
25
+ }
26
+ if (options.expectedBranch === undefined) { throw parseError("--expected-branch <name> is required"); }
27
+ return options;
28
+ }
29
+
30
+ export async function runCli(argv = process.argv.slice(2), { stdout = process.stdout, stderr = process.stderr, cwd = process.cwd(), env = process.env, gitCommand = "git" } = {}) {
31
+ const options = parseBranchGuardCliArgs(argv);
32
+ if (options.help) { stdout.write(`${USAGE}\n`); return { ok: true, help: true }; }
33
+
34
+ const { stdout: branchOutput } = await runCommand(gitCommand, ["branch", "--show-current"], { cwd, env });
35
+ const currentBranch = branchOutput.trim();
36
+ if (currentBranch !== options.expectedBranch) {
37
+ const payload = { ok: false, error: "branch_mismatch", current: currentBranch, expected: options.expectedBranch };
38
+ stderr.write(`${JSON.stringify(payload)}\n`);
39
+ return payload;
40
+ }
41
+
42
+ let worktreeOk = null, mainCheckoutBlocked = null;
43
+ if (options.requireWorktree || options.blockMainCheckout) {
44
+ let mainWorktreePath = null;
45
+ if (options.blockMainCheckout) {
46
+ try { const { stdout: wtOutput } = await runCommand(gitCommand, ["worktree", "list"], { cwd, env }); mainWorktreePath = parseMainWorktreePath(wtOutput); } catch {}
47
+ }
48
+ if (options.requireWorktree) {
49
+ worktreeOk = isUnderWorktreePath(cwd);
50
+ if (!worktreeOk) { stderr.write(JSON.stringify({ ok: false, error: "not_in_worktree", cwd, requiredPrefix: "tmp/worktrees/" }) + "\n"); return { ok: false, error: "not_in_worktree" }; }
51
+ }
52
+ if (options.blockMainCheckout) {
53
+ const isMain = isMainCheckout(cwd, mainWorktreePath);
54
+ mainCheckoutBlocked = !(isMain && !isUnderWorktreePath(cwd));
55
+ if (!mainCheckoutBlocked) { stderr.write(JSON.stringify({ ok: false, error: "main_checkout_blocked", cwd, mainWorktree: mainWorktreePath }) + "\n"); return { ok: false, error: "main_checkout_blocked" }; }
56
+ }
57
+ if (!options.requireWorktree) worktreeOk = null;
58
+ if (!options.blockMainCheckout) mainCheckoutBlocked = null;
59
+ }
60
+
61
+ const payload = { ok: true, branch: currentBranch, matched: true, worktreeOk, mainCheckoutBlocked };
62
+ stdout.write(`${JSON.stringify(payload)}\n`);
63
+ return payload;
64
+ }
65
+
66
+ if (isDirectCliRun(import.meta.url)) {
67
+ runCli().then((result) => { if (result?.ok === false) { process.exitCode = 1; } }).catch((error) => { process.stderr.write(`${formatCliError(error)}\n`); process.exitCode = 1; });
68
+ }