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,231 @@
1
+ #!/usr/bin/env node
2
+ import { readFile } from "node:fs/promises";
3
+ import { parsePrNumber, requireOptionValue, runChild } from "../_cli-primitives.mjs";
4
+ import { formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
5
+ import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
6
+ import {
7
+ interpretReviewerLoopState,
8
+ normalizeReviewerSnapshot,
9
+ } from "@dev-loops/core/loop/reviewer-loop-state";
10
+ const HELP = `Usage: detect-reviewer-loop-state.mjs [--input <path> | --repo <owner/name> --pr <number>] [--review-requested <true|false>] [--local-state <path>]
11
+ Detect reviewer loop state for a pull request.
12
+ Modes:
13
+ --input <path> Interpret a JSON snapshot from file
14
+ --repo <owner/name> --pr <n> Auto-detect state from GitHub PR
15
+ Options (auto-detect mode only):
16
+ --review-requested <bool> Override review-requested detection (true/false)
17
+ --local-state <path> Path to local state file for snapshot merging
18
+ Reviewer scope is auto-resolved from PR requested reviewers.
19
+ Exit codes:
20
+ 0 Success
21
+ 1 Error
22
+ `;
23
+ function parseBool(value, flag) {
24
+ if (value === "true") return true;
25
+ if (value === "false") return false;
26
+ throw new Error(`${flag} must be true or false`);
27
+ }
28
+ export function parseDetectReviewerCliArgs(argv) {
29
+ const args = [...argv];
30
+ const options = {
31
+ inputPath: undefined,
32
+ repo: undefined,
33
+ pr: undefined,
34
+ reviewRequestedOverride: undefined,
35
+ localStatePath: undefined,
36
+ help: false,
37
+ };
38
+ while (args.length > 0) {
39
+ const token = args.shift();
40
+ if (token === "--help" || token === "-h") {
41
+ options.help = true;
42
+ return options;
43
+ }
44
+ if (token === "--input") {
45
+ options.inputPath = requireOptionValue(args, "--input");
46
+ continue;
47
+ }
48
+ if (token === "--repo") {
49
+ options.repo = requireOptionValue(args, "--repo").trim();
50
+ continue;
51
+ }
52
+ if (token === "--pr") {
53
+ options.pr = parsePrNumber(requireOptionValue(args, "--pr"));
54
+ continue;
55
+ }
56
+ if (token === "--review-requested") {
57
+ options.reviewRequestedOverride = parseBool(
58
+ requireOptionValue(args, "--review-requested"),
59
+ "--review-requested",
60
+ );
61
+ continue;
62
+ }
63
+ if (token === "--local-state") {
64
+ options.localStatePath = requireOptionValue(args, "--local-state");
65
+ continue;
66
+ }
67
+ throw new Error(`Unknown argument: ${token}`);
68
+ }
69
+ if (options.inputPath !== undefined) {
70
+ if (options.repo !== undefined || options.pr !== undefined) {
71
+ throw new Error("Choose exactly one input source: --input <path> or --repo/--pr auto-detect");
72
+ }
73
+ const hasInputOnlyConflict = options.localStatePath !== undefined
74
+ || options.reviewRequestedOverride !== undefined;
75
+ if (hasInputOnlyConflict) {
76
+ throw new Error("--input cannot be combined with --review-requested or --local-state");
77
+ }
78
+ return options;
79
+ }
80
+ const hasRepo = options.repo !== undefined;
81
+ const hasPr = options.pr !== undefined;
82
+ if (hasRepo || hasPr) {
83
+ if (!hasRepo || !hasPr) {
84
+ throw new Error("Auto-detect mode requires both --repo <owner/name> and --pr <number>");
85
+ }
86
+ parseRepoSlug(options.repo);
87
+ } else {
88
+ throw new Error("Provide either --input <path> or --repo <owner/name> --pr <number>");
89
+ }
90
+ return options;
91
+ }
92
+ async function runGhJson(args, { env, ghCommand }) {
93
+ const result = await runChild(ghCommand, args, env);
94
+ if (result.code !== 0) {
95
+ const detail = result.stderr.trim() || `exit code ${result.code}`;
96
+ throw new Error(`gh command failed: ${detail}`);
97
+ }
98
+ try {
99
+ return JSON.parse(result.stdout);
100
+ } catch {
101
+ throw new Error(`Invalid JSON from gh: ${result.stdout.trim() || "<empty>"}`);
102
+ }
103
+ }
104
+ async function fetchPrView({ repo, pr }, deps) {
105
+ const result = await runChild(
106
+ deps.ghCommand,
107
+ ["pr", "view", String(pr), "--repo", repo, "--json", "isDraft,state,number,headRefOid"],
108
+ deps.env,
109
+ );
110
+ if (result.code !== 0) {
111
+ const detail = result.stderr.trim() || `exit code ${result.code}`;
112
+ if (/no pull requests found/i.test(detail) || /could not find pull request/i.test(detail)) {
113
+ return null;
114
+ }
115
+ throw new Error(`gh command failed: ${detail}`);
116
+ }
117
+ try {
118
+ return JSON.parse(result.stdout);
119
+ } catch {
120
+ throw new Error(`Invalid JSON from gh: ${result.stdout.trim() || "<empty>"}`);
121
+ }
122
+ }
123
+ function isReviewInScope(review, reviewerLogin) {
124
+ if (!reviewerLogin) return true;
125
+ const login = typeof review?.user?.login === "string"
126
+ ? review.user.login
127
+ : (typeof review?.author?.login === "string" ? review.author.login : "");
128
+ return login.toLowerCase() === reviewerLogin.toLowerCase();
129
+ }
130
+ function isSubmittedReviewState(state) {
131
+ return ["APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED"].includes(state);
132
+ }
133
+ function pickLatestById(items) {
134
+ if (!Array.isArray(items) || items.length === 0) return null;
135
+ return items.filter(Boolean).slice().sort((a, b) => {
136
+ const aid = typeof a.id === "number" ? a.id : -1;
137
+ const bid = typeof b.id === "number" ? b.id : -1;
138
+ return bid - aid;
139
+ })[0] ?? null;
140
+ }
141
+ async function fetchReviewRequested({ repo, pr, reviewerLogin, reviewRequestedOverride }, deps) {
142
+ if (typeof reviewRequestedOverride === "boolean") return reviewRequestedOverride;
143
+ const payload = await runGhJson(["api", `repos/${repo}/pulls/${pr}/requested_reviewers`], deps);
144
+ const users = Array.isArray(payload?.users) ? payload.users : [];
145
+ if (reviewerLogin) {
146
+ return users.some((user) => {
147
+ const login = typeof user?.login === "string" ? user.login : "";
148
+ return login.toLowerCase() === reviewerLogin.toLowerCase();
149
+ });
150
+ }
151
+ return users.length > 0;
152
+ }
153
+ async function fetchReviewState({ repo, pr, reviewerLogin }, deps) {
154
+ const payload = await runGhJson(["api", `repos/${repo}/pulls/${pr}/reviews`], deps);
155
+ const reviews = Array.isArray(payload) ? payload : [];
156
+ const scoped = reviews.filter((review) => isReviewInScope(review, reviewerLogin));
157
+ const pendingReview = pickLatestById(
158
+ scoped.filter((review) => String(review?.state || "").toUpperCase() === "PENDING"),
159
+ );
160
+ const submittedReview = pickLatestById(
161
+ scoped.filter((review) => isSubmittedReviewState(String(review?.state || "").toUpperCase())),
162
+ );
163
+ return {
164
+ draftReviewPosted: Boolean(pendingReview),
165
+ draftReviewId: typeof pendingReview?.id === "number" ? pendingReview.id : null,
166
+ draftReviewUrl: typeof pendingReview?.html_url === "string" ? pendingReview.html_url : null,
167
+ draftReviewCommitSha: typeof pendingReview?.commit_id === "string" ? pendingReview.commit_id : null,
168
+ submittedReviewPresent: Boolean(submittedReview),
169
+ submittedReviewCommitSha: typeof submittedReview?.commit_id === "string" ? submittedReview.commit_id : null,
170
+ submittedReviewState: typeof submittedReview?.state === "string" ? submittedReview.state.toUpperCase() : null,
171
+ };
172
+ }
173
+ async function readLocalState(pathname) {
174
+ if (!pathname) return {};
175
+ let text;
176
+ try { text = await readFile(pathname, "utf8"); }
177
+ catch (error) { if (error && error.code === "ENOENT") return {}; throw error; }
178
+ const parsed = parseJsonText(text);
179
+ if (!parsed || typeof parsed !== "object") throw new Error("Local state file must contain a JSON object");
180
+ return parsed;
181
+ }
182
+ export async function autoDetectReviewerSnapshot(
183
+ { repo, pr, reviewerLogin, reviewRequestedOverride, localStatePath }, deps,
184
+ ) {
185
+ const prView = await fetchPrView({ repo, pr }, deps);
186
+ if (prView === null) return normalizeReviewerSnapshot({ prExists: false, reviewerLogin });
187
+ let effectiveReviewerLogin = reviewerLogin;
188
+ if (effectiveReviewerLogin === undefined) {
189
+ try {
190
+ const reviewersPayload = await runGhJson(["api", `repos/${repo}/pulls/${pr}/requested_reviewers`], deps);
191
+ const users = Array.isArray(reviewersPayload?.users) ? reviewersPayload.users : [];
192
+ const humanReviewers = users.filter((user) => {
193
+ const login = typeof user?.login === "string" ? user.login : "";
194
+ return login.length > 0 && login !== "copilot-pull-request-reviewer";
195
+ });
196
+ if (humanReviewers.length === 1) {
197
+ effectiveReviewerLogin = humanReviewers[0].login;
198
+ }
199
+ } catch {
200
+ }
201
+ }
202
+ const localState = await readLocalState(localStatePath);
203
+ const prState = typeof prView.state === "string" ? prView.state.toUpperCase() : "OPEN";
204
+ const prMerged = prState === "MERGED";
205
+ const prClosed = prState === "CLOSED";
206
+ if (prMerged || prClosed) {
207
+ return normalizeReviewerSnapshot({ ...localState, prExists: true, prNumber: typeof prView.number === "number" ? prView.number : pr, prMerged, prClosed, prHeadSha: typeof prView.headRefOid === "string" ? prView.headRefOid : null, reviewerLogin: effectiveReviewerLogin });
208
+ }
209
+ const reviewRequested = await fetchReviewRequested({ repo, pr, reviewerLogin: effectiveReviewerLogin, reviewRequestedOverride }, deps);
210
+ const reviewState = await fetchReviewState({ repo, pr, reviewerLogin: effectiveReviewerLogin }, deps);
211
+ return normalizeReviewerSnapshot({ ...localState, prExists: true, prNumber: typeof prView.number === "number" ? prView.number : pr, prDraft: Boolean(prView.isDraft), prMerged: false, prClosed: false, prHeadSha: typeof prView.headRefOid === "string" ? prView.headRefOid : null, reviewerLogin: effectiveReviewerLogin, reviewRequested, ...reviewState });
212
+ }
213
+ export async function runCli(
214
+ argv = process.argv.slice(2),
215
+ { stdout = process.stdout, env = process.env, ghCommand = "gh" } = {},
216
+ ) {
217
+ const options = parseDetectReviewerCliArgs(argv);
218
+ if (options.help) { stdout.write(HELP); return; }
219
+ let snapshot;
220
+ if (options.inputPath) {
221
+ const text = await readFile(options.inputPath, "utf8");
222
+ snapshot = normalizeReviewerSnapshot(parseJsonText(text));
223
+ } else {
224
+ snapshot = await autoDetectReviewerSnapshot(options, { env, ghCommand });
225
+ }
226
+ const interpretation = interpretReviewerLoopState(snapshot);
227
+ stdout.write(`${JSON.stringify({ ok: true, snapshot, state: interpretation.state, allowedTransitions: interpretation.allowedTransitions, nextAction: interpretation.nextAction })}\n`);
228
+ }
229
+ if (isDirectCliRun(import.meta.url)) {
230
+ runCli().catch((error) => { process.stderr.write(`${formatCliError(error)}\n`); process.exitCode = 1; });
231
+ }
@@ -0,0 +1,250 @@
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
+ detectStaleRunner,
8
+ STALE_RUNNER_ERROR,
9
+ } from "./_stale-runner-detection.mjs";
10
+ const USAGE = `Usage: detect-stale-runner.mjs --repo <owner/name> --pr <number>
11
+ Detect whether the active runner for a PR is stale or has received an exit
12
+ signal. Fails closed with status "stale_runner" or "exit_signal_recorded" so
13
+ the pre-merge guard can refuse to proceed.
14
+ Required:
15
+ --repo <owner/name> Repository slug (e.g. owner/repo)
16
+ --pr <number> Pull request number
17
+ Optional:
18
+ --stale-runner-max-age-ms <ms>
19
+ Override the staleness threshold (default 30 minutes,
20
+ or $PI_DEV_LOOP_STALE_RUNNER_MAX_AGE_MS).
21
+ --run-id <id> Override the active run id (default: read from
22
+ PI_SUBAGENT_RUN_ID). When supplied, the detector
23
+ additionally verifies the current run id is still
24
+ the active owner.
25
+ Output (stdout, JSON; always includes staleRunnerCheck):
26
+ {
27
+ "ok": true,
28
+ "repo": "owner/repo",
29
+ "pr": 17,
30
+ "status": "fresh_runner",
31
+ "activeRun": { "runId": "...", "claimedAt": "...", "updatedAt": "..." },
32
+ "staleRunnerCheck": {
33
+ "ok": true,
34
+ "failures": []
35
+ }
36
+ }
37
+ or on failure:
38
+ {
39
+ "ok": false,
40
+ "error": "stale_runner" | "exit_signal_recorded",
41
+ "message": "...",
42
+ "staleRunnerCheck": {
43
+ "ok": false,
44
+ "failures": ["stale runner: run X claimed N ms ago, last updated M ms ago (max age K ms)"]
45
+ }
46
+ }
47
+ Exit codes:
48
+ 0 Success / fresh runner / no owner record
49
+ 1 Argument error or stale/exit-signal condition detected`.trim();
50
+ const parseError = buildParseError(USAGE);
51
+ function parseCliArgs(argv) {
52
+ const args = [...argv];
53
+ const options = {
54
+ help: false,
55
+ repo: undefined,
56
+ pr: undefined,
57
+ staleRunnerMaxAgeMs: undefined,
58
+ runId: undefined,
59
+ };
60
+ while (args.length > 0) {
61
+ const token = args.shift();
62
+ if (token === "--help" || token === "-h") {
63
+ options.help = true;
64
+ return options;
65
+ }
66
+ if (token === "--repo") {
67
+ options.repo = requireOptionValue(args, "--repo", parseError).trim();
68
+ continue;
69
+ }
70
+ if (token === "--pr") {
71
+ options.pr = parsePrNumber(requireOptionValue(args, "--pr", parseError), parseError);
72
+ continue;
73
+ }
74
+ if (token === "--stale-runner-max-age-ms") {
75
+ const raw = requireOptionValue(args, "--stale-runner-max-age-ms", parseError).trim();
76
+ const parsed = Number(raw);
77
+ if (!Number.isFinite(parsed) || parsed <= 0) {
78
+ throw parseError(`--stale-runner-max-age-ms must be a positive integer (ms), got: ${raw}`);
79
+ }
80
+ options.staleRunnerMaxAgeMs = Math.floor(parsed);
81
+ continue;
82
+ }
83
+ if (token === "--run-id") {
84
+ options.runId = requireOptionValue(args, "--run-id", parseError).trim();
85
+ continue;
86
+ }
87
+ throw parseError(`Unknown argument: ${token}`);
88
+ }
89
+ if (options.repo === undefined || options.pr === undefined) {
90
+ throw parseError("detect-stale-runner requires both --repo <owner/name> and --pr <number>");
91
+ }
92
+ try {
93
+ parseRepoSlug(options.repo);
94
+ } catch (error) {
95
+ throw parseError(error instanceof Error ? error.message : String(error));
96
+ }
97
+ return options;
98
+ }
99
+ function resolveRunId(explicitRunId, env) {
100
+ if (typeof explicitRunId === "string" && explicitRunId.trim().length > 0) {
101
+ return explicitRunId.trim();
102
+ }
103
+ if (typeof env?.PI_SUBAGENT_RUN_ID === "string" && env.PI_SUBAGENT_RUN_ID.trim().length > 0) {
104
+ return env.PI_SUBAGENT_RUN_ID.trim();
105
+ }
106
+ return null;
107
+ }
108
+ function buildStaleRunnerCheck(detection) {
109
+ if (detection.status === "no_owner_record") {
110
+ return {
111
+ ok: true,
112
+ failures: [],
113
+ };
114
+ }
115
+ if (detection.status === "exit_signal_recorded") {
116
+ return {
117
+ ok: false,
118
+ failures: [`exit signal recorded for run ${detection.activeRun?.runId ?? "unknown"}: refuse to merge`],
119
+ };
120
+ }
121
+ if (detection.status === "stale_runner") {
122
+ return {
123
+ ok: false,
124
+ failures: [
125
+ `stale runner: run ${detection.staleRunner.runId} claimed ${detection.staleRunner.claimedAgeMs}ms ago, last updated ${detection.staleRunner.updatedAgeMs}ms ago (max age ${detection.staleRunner.maxAgeMs}ms)`,
126
+ ],
127
+ };
128
+ }
129
+ return { ok: true, failures: [] };
130
+ }
131
+ export async function runDetectStaleRunner(options, { env = process.env, cwd = process.cwd() } = {}) {
132
+ const detection = await detectStaleRunner({
133
+ repo: options.repo,
134
+ pr: options.pr,
135
+ maxAgeMs: options.staleRunnerMaxAgeMs,
136
+ cwd,
137
+ });
138
+ const explicitRunId = resolveRunId(options.runId, env);
139
+ const ownershipLost = explicitRunId !== null
140
+ && detection.activeRun !== null
141
+ && detection.activeRun.runId !== explicitRunId;
142
+ const ownershipMissing = explicitRunId !== null && detection.activeRun === null;
143
+ const staleRunnerCheck = buildStaleRunnerCheck(detection);
144
+ if (ownershipMissing) {
145
+ return {
146
+ ok: false,
147
+ error: "ownership_lost",
148
+ repo: options.repo.trim().toLowerCase(),
149
+ pr: options.pr,
150
+ status: "ownership_lost",
151
+ activeRun: null,
152
+ runId: explicitRunId,
153
+ exitSignals: [],
154
+ filePath: detection.filePath,
155
+ maxAgeMs: detection.maxAgeMs,
156
+ message: `Stale-runner check: run ${explicitRunId} is no longer the active owner of ${options.repo}#${options.pr}; no active owner record exists.`,
157
+ staleRunnerCheck: {
158
+ ok: false,
159
+ failures: [`ownership_lost: no active owner record exists; run ${explicitRunId} is not the owner`],
160
+ },
161
+ };
162
+ }
163
+ if (ownershipLost) {
164
+ return {
165
+ ok: false,
166
+ error: "ownership_lost",
167
+ repo: options.repo.trim().toLowerCase(),
168
+ pr: options.pr,
169
+ status: "ownership_lost",
170
+ activeRun: detection.activeRun,
171
+ runId: explicitRunId,
172
+ exitSignals: detection.exitSignal?.signals ?? [],
173
+ filePath: detection.filePath,
174
+ maxAgeMs: detection.maxAgeMs,
175
+ message: `Stale-runner check: run ${explicitRunId} is no longer the active owner of ${options.repo}#${options.pr}; current owner is ${detection.activeRun?.runId}.`,
176
+ staleRunnerCheck: {
177
+ ok: false,
178
+ failures: [`ownership_lost: active owner is ${detection.activeRun?.runId ?? "unknown"}, not ${explicitRunId}`],
179
+ },
180
+ };
181
+ }
182
+ if (detection.status === "exit_signal_recorded") {
183
+ return {
184
+ ok: false,
185
+ error: STALE_RUNNER_ERROR.EXIT_SIGNAL_RECORDED,
186
+ repo: options.repo.trim().toLowerCase(),
187
+ pr: options.pr,
188
+ status: "exit_signal_recorded",
189
+ activeRun: detection.activeRun,
190
+ runId: explicitRunId,
191
+ exitSignals: detection.exitSignal?.signals ?? [],
192
+ filePath: detection.filePath,
193
+ maxAgeMs: detection.maxAgeMs,
194
+ message: detection.message,
195
+ staleRunnerCheck,
196
+ };
197
+ }
198
+ if (detection.status === "stale_runner") {
199
+ return {
200
+ ok: false,
201
+ error: STALE_RUNNER_ERROR.STALE_RUNNER,
202
+ repo: options.repo.trim().toLowerCase(),
203
+ pr: options.pr,
204
+ status: "stale_runner",
205
+ activeRun: detection.activeRun,
206
+ runId: explicitRunId,
207
+ exitSignals: [],
208
+ staleRunner: detection.staleRunner,
209
+ filePath: detection.filePath,
210
+ maxAgeMs: detection.maxAgeMs,
211
+ message: detection.message,
212
+ staleRunnerCheck,
213
+ };
214
+ }
215
+ return {
216
+ ok: true,
217
+ repo: options.repo.trim().toLowerCase(),
218
+ pr: options.pr,
219
+ status: detection.status,
220
+ activeRun: detection.activeRun,
221
+ runId: explicitRunId,
222
+ exitSignals: [],
223
+ filePath: detection.filePath,
224
+ maxAgeMs: detection.maxAgeMs,
225
+ staleRunnerCheck,
226
+ };
227
+ }
228
+ async function main() {
229
+ try {
230
+ const options = parseCliArgs(process.argv.slice(2));
231
+ if (options.help) {
232
+ console.log(USAGE);
233
+ return;
234
+ }
235
+ const result = await runDetectStaleRunner(options, { env: process.env });
236
+ if (!result.ok) {
237
+ console.error(JSON.stringify(result));
238
+ process.exitCode = 1;
239
+ return;
240
+ }
241
+ console.log(JSON.stringify(result));
242
+ } catch (error) {
243
+ const payload = formatCliError(error, { usage: USAGE });
244
+ console.error(JSON.stringify(payload));
245
+ process.exitCode = 1;
246
+ }
247
+ }
248
+ if (isDirectCliRun(import.meta.url)) {
249
+ await main();
250
+ }
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env node
2
+ import process from "node:process";
3
+ import { execFileSync } from "node:child_process";
4
+ import { interpretTrackerLoopState } from "@dev-loops/core/loop/tracker-first-loop-state";
5
+ function showHelp() {
6
+ process.stdout.write(`Usage: detect-tracker-first-loop-state.mjs --repo <owner/name> --issue <number>
7
+ Detect tracker-first loop state for a GitHub issue.
8
+ Options:
9
+ --repo <owner/name> GitHub repository slug
10
+ --issue <number> GitHub issue number
11
+ --help, -h Show this help
12
+ Exit codes:
13
+ 0 Success
14
+ 1 Error
15
+ `);
16
+ process.exit(0);
17
+ }
18
+ function parseArgs() {
19
+ const args = process.argv.slice(2);
20
+ const opts = { repo: null, issue: null };
21
+ for (let i = 0; i < args.length; i++) {
22
+ if (args[i] === "--help" || args[i] === "-h") {
23
+ showHelp();
24
+ }
25
+ if (args[i] === "--repo" && i + 1 < args.length) opts.repo = args[++i];
26
+ else if (args[i] === "--issue" && i + 1 < args.length) opts.issue = args[++i];
27
+ }
28
+ return opts;
29
+ }
30
+ async function main() {
31
+ const opts = parseArgs();
32
+ if (!opts.repo || !opts.issue) {
33
+ process.stderr.write(
34
+ JSON.stringify({ ok: false, error: "--repo and --issue required" }) + "\n"
35
+ );
36
+ process.exitCode = 1;
37
+ return;
38
+ }
39
+ let rawState = "";
40
+ let prContext = null;
41
+ try {
42
+ const issueJson = execFileSync(
43
+ "gh",
44
+ ["issue", "view", String(opts.issue), "--repo", opts.repo, "--json", "state,title", "--jq", ".state"],
45
+ { encoding: "utf8" }
46
+ ).trim();
47
+ rawState = issueJson;
48
+ try {
49
+ const prJson = execFileSync(
50
+ "gh",
51
+ ["pr", "list", "--repo", opts.repo, "--search", `${opts.issue} in:body`, "--state", "open", "--json", "number,state,headRefName", "--jq", ".[0]"],
52
+ { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] }
53
+ ).trim();
54
+ if (prJson) prContext = JSON.parse(prJson);
55
+ } catch {
56
+ }
57
+ } catch (err) {
58
+ const message = err instanceof Error ? err.message : String(err);
59
+ process.stderr.write(
60
+ JSON.stringify({ ok: false, error: `gh command failed: ${message}` }) + "\n"
61
+ );
62
+ process.exitCode = 1;
63
+ return;
64
+ }
65
+ const result = interpretTrackerLoopState({ trackerState: rawState, prContext });
66
+ process.stdout.write(JSON.stringify(result) + "\n");
67
+ }
68
+ const isDirectRun =
69
+ process.argv[1] && process.argv[1].includes("detect-tracker-first-loop-state.mjs");
70
+ if (isDirectRun) {
71
+ main().catch((err) => {
72
+ process.stderr.write(`${err.message}\n`);
73
+ process.exitCode = 1;
74
+ });
75
+ }
76
+ export { main };
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env node
2
+ import { readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { buildParseError, formatCliError, parseJsonText } from "../_core-helpers.mjs";
6
+ import { requireOptionValue } from "../_cli-primitives.mjs";
7
+ import {
8
+ interpretTrackerPrState,
9
+ normalizeTrackerPrSnapshot,
10
+ } from "@dev-loops/core/loop/tracker-pr-state";
11
+ const USAGE = `Usage:
12
+ detect-tracker-pr-state.mjs --input <path>
13
+ Interpret a pre-built tracker-PR snapshot JSON and emit the current lifecycle
14
+ state, allowed transitions, recommended next action, and canonical reverse-sync
15
+ action.
16
+ Required:
17
+ --input <path> Path to a JSON file containing the tracker-PR snapshot.
18
+ Snapshot schema (all fields optional; unknown fields are ignored):
19
+ trackerItemExists boolean Whether a tracker work item was found
20
+ trackerItemId string|null Opaque tracker item ID (e.g. "PROJ-123") when present
21
+ prExists boolean Whether a GitHub PR exists for this item
22
+ prNumber number|null PR number if known; prNumber with prExists=false is contradictory
23
+ prDraft boolean Whether the PR is in draft state
24
+ prMerged boolean Whether the PR has been merged
25
+ prClosed boolean Whether the PR is closed on GitHub (merged PRs are also closed)
26
+ prHeadSha string|null Current PR head SHA
27
+ draftGateCommentVisible boolean Whether the draft-gate comment is visible on the PR thread
28
+ draftGateCommentHeadSha string|null Head SHA encoded in the draft-gate comment
29
+ draftGateCommentVerdict string|null Draft-gate verdict: clean|findings_present|blocked
30
+ This snapshot intentionally excludes tracker-native workflow readiness/blocking
31
+ state. Callers must combine tracker-owned workflow state separately when
32
+ interpreting whether opening a PR is appropriate.
33
+ Unlike the Copilot/reviewer loop snapshots, this tracker contract uses prClosed
34
+ for the raw GitHub closed state. Merged PRs therefore set both prMerged=true
35
+ and prClosed=true, while pr_closed_unmerged is derived from
36
+ prClosed && !prMerged.
37
+ Output (stdout, JSON):
38
+ {
39
+ "ok": true,
40
+ "snapshot": { ... },
41
+ "state": "...",
42
+ "allowedTransitions": [...],
43
+ "nextAction": "...",
44
+ "reverseSyncAction": "..."
45
+ }
46
+ Error output (stderr, JSON):
47
+ Argument/usage errors: { "ok": false, "error": "...", "usage": "..." }
48
+ Runtime failures: { "ok": false, "error": "..." }
49
+ Exit codes:
50
+ 0 Success
51
+ 1 Argument error or runtime failure`.trim();
52
+ const parseError = buildParseError(USAGE);
53
+ export function parseDetectTrackerPrCliArgs(argv) {
54
+ const args = [...argv];
55
+ const options = {
56
+ help: false,
57
+ inputPath: undefined,
58
+ };
59
+ while (args.length > 0) {
60
+ const token = args.shift();
61
+ if (token === "--help" || token === "-h") {
62
+ options.help = true;
63
+ return options;
64
+ }
65
+ if (token === "--input") {
66
+ options.inputPath = requireOptionValue(args, "--input", parseError);
67
+ continue;
68
+ }
69
+ throw parseError(`Unknown argument: ${token}`);
70
+ }
71
+ if (options.inputPath === undefined) {
72
+ throw parseError("--input <path> is required");
73
+ }
74
+ return options;
75
+ }
76
+ export async function runCli(
77
+ argv = process.argv.slice(2),
78
+ {
79
+ stdout = process.stdout,
80
+ } = {},
81
+ ) {
82
+ const options = parseDetectTrackerPrCliArgs(argv);
83
+ if (options.help) {
84
+ stdout.write(`${USAGE}\n`);
85
+ return;
86
+ }
87
+ const text = await readFile(path.resolve(options.inputPath), "utf8");
88
+ const raw = parseJsonText(text);
89
+ const snapshot = normalizeTrackerPrSnapshot(raw);
90
+ const { state, allowedTransitions, nextAction, reverseSyncAction } = interpretTrackerPrState(snapshot);
91
+ stdout.write(
92
+ `${JSON.stringify({ ok: true, snapshot, state, allowedTransitions, nextAction, reverseSyncAction })}\n`,
93
+ );
94
+ }
95
+ const isDirectRun =
96
+ process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
97
+ if (isDirectRun) {
98
+ runCli().catch((error) => {
99
+ process.stderr.write(`${formatCliError(error)}\n`);
100
+ process.exitCode = 1;
101
+ });
102
+ }