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.
- package/.pi/dev-loop/defaults.yaml +477 -0
- package/AGENTS.md +25 -0
- package/CHANGELOG.md +18 -0
- package/LICENSE +21 -0
- package/README.md +178 -0
- package/agents/dev-loop.agent.md +82 -0
- package/agents/developer.agent.md +37 -0
- package/agents/docs.agent.md +33 -0
- package/agents/fixer.agent.md +53 -0
- package/agents/quality.agent.md +28 -0
- package/agents/refiner.agent.md +87 -0
- package/agents/review.agent.md +64 -0
- package/cli/index.mjs +424 -0
- package/extension/README.md +233 -0
- package/extension/checks.ts +94 -0
- package/extension/index.ts +131 -0
- package/extension/post-merge-update.ts +512 -0
- package/extension/presentation.ts +107 -0
- package/lib/dev-loops-core.mjs +284 -0
- package/package.json +103 -0
- package/scripts/README.md +1007 -0
- package/scripts/_cli-primitives.mjs +10 -0
- package/scripts/_core-helpers.mjs +30 -0
- package/scripts/docs/validate-links.mjs +567 -0
- package/scripts/docs/validate-no-duplicate-rules.mjs +250 -0
- package/scripts/github/_review-thread-mutations.mjs +214 -0
- package/scripts/github/capture-review-threads.mjs +180 -0
- package/scripts/github/create-draft-pr.mjs +108 -0
- package/scripts/github/detect-checkpoint-evidence.mjs +393 -0
- package/scripts/github/detect-linked-issue-pr.mjs +331 -0
- package/scripts/github/manage-sub-issues.mjs +394 -0
- package/scripts/github/probe-copilot-review.mjs +323 -0
- package/scripts/github/ready-for-review.mjs +93 -0
- package/scripts/github/reconcile-draft-gate.mjs +328 -0
- package/scripts/github/reply-resolve-review-thread.mjs +42 -0
- package/scripts/github/reply-resolve-review-threads.mjs +329 -0
- package/scripts/github/request-copilot-review.mjs +551 -0
- package/scripts/github/resolve-tracker-local-spec.mjs +205 -0
- package/scripts/github/stage-reviewer-draft.mjs +191 -0
- package/scripts/github/upsert-checkpoint-verdict.mjs +694 -0
- package/scripts/github/verify-fresh-review-context.mjs +125 -0
- package/scripts/github/write-gate-findings-log.mjs +212 -0
- package/scripts/loop/_checkpoint-io.mjs +55 -0
- package/scripts/loop/_checkpoint-paths.mjs +28 -0
- package/scripts/loop/_handoff-contract.mjs +230 -0
- package/scripts/loop/_inspect-run-viewer-adapter.mjs +345 -0
- package/scripts/loop/_loop-evidence.mjs +32 -0
- package/scripts/loop/_pr-runner-coordination.mjs +611 -0
- package/scripts/loop/_stale-runner-detection.mjs +145 -0
- package/scripts/loop/_steering-state-file.mjs +134 -0
- package/scripts/loop/build-handoff-envelope.mjs +181 -0
- package/scripts/loop/checkpoint-contract.mjs +49 -0
- package/scripts/loop/conductor-monitor.mjs +1850 -0
- package/scripts/loop/conductor.mjs +214 -0
- package/scripts/loop/copilot-pr-handoff.mjs +493 -0
- package/scripts/loop/debt-remediate.mjs +304 -0
- package/scripts/loop/detect-change-scope.mjs +102 -0
- package/scripts/loop/detect-copilot-loop-state.mjs +454 -0
- package/scripts/loop/detect-copilot-session-activity.mjs +186 -0
- package/scripts/loop/detect-initial-copilot-pr-state.mjs +318 -0
- package/scripts/loop/detect-internal-only-pr.mjs +270 -0
- package/scripts/loop/detect-issue-refinement-artifact.mjs +163 -0
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +509 -0
- package/scripts/loop/detect-reviewer-loop-state.mjs +231 -0
- package/scripts/loop/detect-stale-runner.mjs +250 -0
- package/scripts/loop/detect-tracker-first-loop-state.mjs +76 -0
- package/scripts/loop/detect-tracker-pr-state.mjs +102 -0
- package/scripts/loop/info.mjs +267 -0
- package/scripts/loop/inspect-run-viewer/cli.mjs +117 -0
- package/scripts/loop/inspect-run-viewer/constants.mjs +80 -0
- package/scripts/loop/inspect-run-viewer/graph.mjs +757 -0
- package/scripts/loop/inspect-run-viewer/handoff-envelope-renderer.mjs +398 -0
- package/scripts/loop/inspect-run-viewer/inbox.mjs +308 -0
- package/scripts/loop/inspect-run-viewer/managed-instance.mjs +750 -0
- package/scripts/loop/inspect-run-viewer/rendering.mjs +411 -0
- package/scripts/loop/inspect-run-viewer/server.mjs +638 -0
- package/scripts/loop/inspect-run-viewer/shared.mjs +103 -0
- package/scripts/loop/inspect-run-viewer/status.mjs +715 -0
- package/scripts/loop/inspect-run-viewer-ci-changes.mjs +77 -0
- package/scripts/loop/inspect-run-viewer.mjs +82 -0
- package/scripts/loop/inspect-run.mjs +382 -0
- package/scripts/loop/outer-loop.mjs +419 -0
- package/scripts/loop/pr-runner-coordination.mjs +143 -0
- package/scripts/loop/pre-commit-branch-guard.mjs +68 -0
- package/scripts/loop/pre-flight-gate.mjs +236 -0
- package/scripts/loop/pre-pr-ready-gate.mjs +183 -0
- package/scripts/loop/pre-push-main-guard.mjs +103 -0
- package/scripts/loop/pre-write-remote-freshness-guard.mjs +32 -0
- package/scripts/loop/print-gates.mjs +42 -0
- package/scripts/loop/resolve-dev-loop-startup.mjs +533 -0
- package/scripts/loop/run-conductor-cycle.mjs +322 -0
- package/scripts/loop/run-queue.mjs +124 -0
- package/scripts/loop/run-refinement-audit.mjs +513 -0
- package/scripts/loop/run-watch-cycle.mjs +358 -0
- package/scripts/loop/steer-loop.mjs +841 -0
- package/scripts/loop/ui-designer-review-contract.mjs +76 -0
- package/scripts/loop/watch-initial-copilot-pr.mjs +253 -0
- package/scripts/projects/add-queue-item.mjs +528 -0
- package/scripts/projects/ensure-queue-board.mjs +837 -0
- package/scripts/projects/list-queue-items.mjs +489 -0
- package/scripts/projects/move-queue-item.mjs +549 -0
- package/scripts/projects/reorder-queue-item.mjs +518 -0
- package/scripts/refine/_refine-helpers.mjs +258 -0
- package/scripts/refine/prose-linkage-detector.mjs +92 -0
- package/scripts/refine/refinement-completeness-checker.mjs +88 -0
- package/scripts/refine/scope-boundary-cross-checker.mjs +163 -0
- package/scripts/refine/tree-integrity-validator.mjs +211 -0
- package/scripts/refine/verify.mjs +178 -0
- package/scripts/repo-wiki-local.mjs +156 -0
- package/scripts/repo-wiki.mjs +119 -0
- package/skills/copilot-pr-followup/SKILL.md +380 -0
- package/skills/dev-loop/SKILL.md +141 -0
- package/skills/dev-loop/scripts/dev-mode-context.mjs +152 -0
- package/skills/dev-loop/scripts/dev-mode-context.test.mjs +80 -0
- package/skills/dev-loop/scripts/init-phase.mjs +71 -0
- package/skills/dev-loop/scripts/log-bash-exit-1.mjs +25 -0
- package/skills/dev-loop/scripts/phase-files.mjs +29 -0
- package/skills/dev-loop/scripts/post-gate-verdict-fallback.mjs +480 -0
- package/skills/dev-loop/scripts/post-gate-verdict-fallback.test.mjs +732 -0
- package/skills/dev-loop/scripts/render-template.mjs +82 -0
- package/skills/dev-loop/scripts/render-template.test.mjs +63 -0
- package/skills/dev-loop/templates/bootstrap-agents.md +26 -0
- package/skills/dev-loop/templates/bootstrap-implementation-state.md +31 -0
- package/skills/dev-loop/templates/bootstrap-implementation-workflow.md +17 -0
- package/skills/dev-loop/templates/dev-mode-retrospective.md +15 -0
- package/skills/dev-loop/templates/dev-mode-review.md +17 -0
- package/skills/dev-loop/templates/dev-mode-skill-changes.md +11 -0
- package/skills/dev-loop/templates/merged-phase-plan.md +19 -0
- package/skills/dev-loop/templates/phase-doc.md +27 -0
- package/skills/dev-loop/templates/phase-summary.md +13 -0
- package/skills/dev-loop/templates/phase-variant.md +15 -0
- package/skills/dev-loop/templates/retrospective.md +11 -0
- package/skills/dev-loop/templates/review.md +32 -0
- package/skills/dev-loop/templates/ui-vision-review.md +55 -0
- package/skills/docs/acceptance-criteria-verification.md +21 -0
- package/skills/docs/anti-patterns.md +21 -0
- package/skills/docs/artifact-authority-contract.md +119 -0
- package/skills/docs/confirmation-rules.md +28 -0
- package/skills/docs/copilot-ci-status-contract.md +52 -0
- package/skills/docs/copilot-loop-operations.md +233 -0
- package/skills/docs/debt-remediation-contract.md +107 -0
- package/skills/docs/entrypoint-strategies.md +115 -0
- package/skills/docs/epic-tree-refinement-procedure.md +234 -0
- package/skills/docs/issue-intake-procedure.md +235 -0
- package/skills/docs/main-agent-contract.md +72 -0
- package/skills/docs/merge-preconditions.md +29 -0
- package/skills/docs/pr-lifecycle-contract.md +209 -0
- package/skills/docs/public-dev-loop-contract.md +497 -0
- package/skills/docs/retrospective-checkpoint-contract.md +159 -0
- package/skills/docs/stop-conditions.md +29 -0
- package/skills/docs/structural-quality.md +42 -0
- package/skills/docs/tracker-first-loop-state.md +281 -0
- package/skills/docs/validation-policy.md +27 -0
- package/skills/docs/workflow-handoff-contract.md +135 -0
- package/skills/final-approval/SKILL.md +19 -0
- 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
|
+
}
|