dev-loops 0.6.0 → 0.7.1
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/.claude/.claude-plugin/plugin.json +1 -1
- package/.claude/agents/dev-loop.md +2 -1
- package/.claude/agents/review.md +2 -1
- package/.claude/commands/{auto.md → loop-auto.md} +1 -1
- package/.claude/commands/loop-continue.md +15 -0
- package/.claude/commands/loop-enqueue.md +22 -0
- package/.claude/commands/loop-grill.md +17 -0
- package/.claude/commands/{info.md → loop-info.md} +2 -2
- package/.claude/commands/loop-queue-status.md +24 -0
- package/.claude/commands/{start-spike.md → loop-start-spike.md} +1 -1
- package/.claude/commands/{start.md → loop-start.md} +1 -1
- package/.claude/commands/{status.md → loop-status.md} +1 -1
- package/.claude/hooks/_bash-command-classify.mjs +333 -29
- package/.claude/hooks/_hook-decisions.mjs +138 -15
- package/.claude/hooks/pre-tool-use-bash-gate.mjs +36 -15
- package/.claude/skills/copilot-pr-followup/SKILL.md +24 -12
- package/.claude/skills/dev-loop/SKILL.md +15 -11
- package/.claude/skills/docs/acceptance-criteria-verification.md +6 -2
- package/.claude/skills/docs/anti-patterns.md +2 -1
- package/.claude/skills/docs/artifact-authority-contract.md +22 -4
- package/.claude/skills/docs/copilot-loop-operations.md +1 -1
- package/.claude/skills/docs/cross-harness-regression-contract.md +60 -0
- package/.claude/skills/docs/issue-intake-procedure.md +1 -0
- package/.claude/skills/docs/local-planning-flow.md +1 -1
- package/.claude/skills/docs/local-planning-worked-example.md +1 -1
- package/.claude/skills/docs/merge-preconditions.md +17 -1
- package/.claude/skills/docs/plan-file-contract.md +1 -1
- package/.claude/skills/docs/public-dev-loop-contract.md +1 -1
- package/.claude/skills/docs/retrospective-checkpoint-contract.md +90 -76
- package/.claude/skills/docs/ui-e2e-scoping-step.md +32 -0
- package/.claude/skills/docs/workflow-handoff-contract.md +39 -0
- package/.claude/skills/local-implementation/SKILL.md +25 -14
- package/.claude/skills/loop-grill/SKILL.md +163 -0
- package/AGENTS.md +1 -0
- package/CHANGELOG.md +95 -0
- package/README.md +9 -9
- package/agents/dev-loop.agent.md +3 -2
- package/agents/developer.agent.md +1 -1
- package/agents/docs.agent.md +1 -1
- package/agents/fixer.agent.md +1 -1
- package/agents/quality.agent.md +1 -1
- package/agents/refiner.agent.md +1 -1
- package/agents/review.agent.md +3 -2
- package/cli/index.mjs +35 -42
- package/extension/README.md +4 -4
- package/extension/checks.ts +1 -5
- package/extension/harness-types.ts +1 -0
- package/extension/index.ts +6 -0
- package/extension/pi-extension-adapter.ts +2 -0
- package/extension/presentation.ts +7 -19
- package/package.json +9 -10
- package/scripts/github/capture-review-threads.mjs +2 -9
- package/scripts/github/comment-issue.mjs +2 -9
- package/scripts/github/create-label.mjs +133 -0
- package/scripts/github/detect-checkpoint-evidence.mjs +170 -14
- package/scripts/github/detect-linked-issue-pr.mjs +22 -6
- package/scripts/github/edit-pr.mjs +259 -0
- package/scripts/github/fetch-ci-logs.mjs +2 -9
- package/scripts/github/list-issues.mjs +2 -9
- package/scripts/github/manage-sub-issues.mjs +46 -10
- package/scripts/github/offer-human-handoff.mjs +8 -5
- package/scripts/github/post-gate-findings.mjs +14 -2
- package/scripts/github/probe-ci-status.mjs +8 -2
- package/scripts/github/probe-copilot-review.mjs +21 -14
- package/scripts/github/ready-for-review.mjs +26 -8
- package/scripts/github/reconcile-draft-gate.mjs +7 -3
- package/scripts/github/reply-resolve-review-thread.mjs +16 -5
- package/scripts/github/reply-resolve-review-threads.mjs +69 -7
- package/scripts/github/request-copilot-review.mjs +8 -2
- package/scripts/github/resolve-handoff-candidates.mjs +7 -3
- package/scripts/github/resolve-tracker-local-spec.mjs +9 -3
- package/scripts/github/stage-reviewer-draft.mjs +10 -2
- package/scripts/github/tick-verified-checkboxes.mjs +202 -0
- package/scripts/github/upsert-checkpoint-verdict.mjs +31 -10
- package/scripts/github/verify-fresh-review-context.mjs +150 -31
- package/scripts/github/view-pr.mjs +150 -0
- package/scripts/github/write-gate-context.mjs +248 -61
- package/scripts/github/write-gate-findings-log.mjs +75 -1
- package/scripts/lib/jq-output.mjs +18 -0
- package/scripts/loop/_post-convergence-change.mjs +211 -0
- package/scripts/loop/_pr-runner-coordination.mjs +70 -0
- package/scripts/loop/_repo-root-resolver.mjs +47 -0
- package/scripts/loop/build-handoff-envelope.mjs +14 -4
- package/scripts/loop/check-retro-tooling.mjs +14 -3
- package/scripts/loop/checkpoint-contract.mjs +7 -4
- package/scripts/loop/cleanup-worktree.mjs +12 -3
- package/scripts/loop/conductor-monitor.mjs +12 -18
- package/scripts/loop/copilot-pr-handoff.mjs +135 -14
- package/scripts/loop/debt-remediate.mjs +24 -12
- package/scripts/loop/detect-change-scope.mjs +36 -7
- package/scripts/loop/detect-copilot-loop-state.mjs +11 -5
- package/scripts/loop/detect-copilot-session-activity.mjs +7 -3
- package/scripts/loop/detect-initial-copilot-pr-state.mjs +7 -3
- package/scripts/loop/detect-internal-only-pr.mjs +8 -2
- package/scripts/loop/detect-issue-refinement-artifact.mjs +6 -3
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +149 -66
- package/scripts/loop/detect-reviewer-loop-state.mjs +12 -2
- package/scripts/loop/detect-tracker-first-loop-state.mjs +9 -1
- package/scripts/loop/detect-tracker-pr-state.mjs +10 -3
- package/scripts/loop/ensure-worktree.mjs +8 -3
- package/scripts/loop/info.mjs +12 -4
- package/scripts/loop/inspect-run-viewer/constants.mjs +4 -18
- package/scripts/loop/inspect-run-viewer/vendor/mermaid.min.js +3405 -0
- package/scripts/loop/inspect-run-viewer-ci-changes.mjs +18 -6
- package/scripts/loop/inspect-run-viewer.mjs +67 -4
- package/scripts/loop/inspect-run.mjs +8 -2
- package/scripts/loop/outer-loop.mjs +8 -4
- package/scripts/loop/pr-runner-coordination.mjs +2 -9
- package/scripts/loop/pre-commit-branch-guard.mjs +16 -10
- package/scripts/loop/pre-flight-gate.mjs +9 -9
- package/scripts/loop/pre-pr-ready-gate.mjs +8 -4
- package/scripts/loop/pre-write-remote-freshness-guard.mjs +13 -4
- package/scripts/loop/provision-worktree.mjs +74 -3
- package/scripts/loop/resolve-dev-loop-startup.mjs +76 -7
- package/scripts/loop/resolve-gate-dispatch.mjs +134 -0
- package/scripts/loop/resolve-pr-conflicts.mjs +14 -7
- package/scripts/loop/run-conductor-cycle.mjs +8 -2
- package/scripts/loop/run-queue.mjs +18 -9
- package/scripts/loop/run-refinement-audit.mjs +8 -9
- package/scripts/loop/run-watch-cycle.mjs +2 -9
- package/scripts/loop/sanctioned-commands.mjs +104 -0
- package/scripts/loop/steer-loop.mjs +29 -16
- package/scripts/loop/validate-pr-body-spec.mjs +207 -0
- package/scripts/loop/watch-initial-copilot-pr.mjs +8 -3
- package/scripts/pages/build-site.mjs +59 -8
- package/scripts/pages/build-state-atlas.mjs +441 -0
- package/scripts/projects/_resolve-project.mjs +148 -0
- package/scripts/projects/add-queue-item.mjs +60 -54
- package/scripts/projects/archive-done-items.mjs +49 -84
- package/scripts/projects/ensure-queue-board.mjs +10 -36
- package/scripts/projects/list-queue-items.mjs +116 -65
- package/scripts/projects/move-queue-item.mjs +28 -49
- package/scripts/projects/reconcile-queue.mjs +253 -0
- package/scripts/projects/reorder-queue-item.mjs +41 -47
- package/scripts/projects/resolve-active-board-item.mjs +108 -46
- package/scripts/projects/sync-item-status.mjs +15 -10
- package/scripts/refine/_refine-helpers.mjs +21 -5
- package/scripts/refine/exit-spike.mjs +18 -8
- package/scripts/refine/promote-plan.mjs +22 -16
- package/scripts/refine/prose-linkage-detector.mjs +1 -1
- package/scripts/refine/refine-plan-file.mjs +13 -4
- package/scripts/refine/refinement-completeness-checker.mjs +1 -1
- package/scripts/refine/scaffold-spike-file.mjs +4 -8
- package/scripts/refine/scope-boundary-cross-checker.mjs +1 -1
- package/scripts/refine/tree-integrity-validator.mjs +1 -1
- package/scripts/refine/validate-plan-file.mjs +1 -1
- package/scripts/refine/validate-spike-file.mjs +1 -1
- package/scripts/refine/verify.mjs +11 -6
- package/scripts/release/assert-core-dependency-version.mjs +123 -0
- package/scripts/release/extract-changelog-section.mjs +16 -2
- package/skills/copilot-pr-followup/SKILL.md +24 -12
- package/skills/dev-loop/SKILL.md +10 -6
- package/skills/docs/acceptance-criteria-verification.md +6 -2
- package/skills/docs/anti-patterns.md +2 -1
- package/skills/docs/artifact-authority-contract.md +22 -4
- package/skills/docs/copilot-loop-operations.md +1 -1
- package/skills/docs/cross-harness-regression-contract.md +60 -0
- package/skills/docs/issue-intake-procedure.md +1 -0
- package/skills/docs/local-planning-flow.md +1 -1
- package/skills/docs/local-planning-worked-example.md +1 -1
- package/skills/docs/merge-preconditions.md +17 -1
- package/skills/docs/plan-file-contract.md +1 -1
- package/skills/docs/public-dev-loop-contract.md +1 -1
- package/skills/docs/retrospective-checkpoint-contract.md +90 -76
- package/skills/docs/ui-e2e-scoping-step.md +32 -0
- package/skills/docs/workflow-handoff-contract.md +39 -0
- package/skills/local-implementation/SKILL.md +25 -14
- package/skills/loop-grill/SKILL.md +165 -0
- package/.claude/commands/continue.md +0 -15
- package/scripts/loop/conductor.mjs +0 -233
- package/scripts/loop/detect-stale-runner.mjs +0 -265
- package/scripts/loop/pre-push-main-guard.mjs +0 -117
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
replyAndMaybeResolve,
|
|
13
13
|
validateResolutionMessage,
|
|
14
14
|
} from "./_review-thread-mutations.mjs";
|
|
15
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
|
|
15
16
|
const USAGE = `Usage: reply-resolve-review-threads.mjs --repo <owner/name> --pr <number> [--author <login>] [--message <text>] [--resolve]
|
|
16
17
|
Reply to all matching unresolved review threads on one PR and optionally resolve them.
|
|
17
18
|
Required:
|
|
@@ -30,9 +31,11 @@ Error output (stderr, JSON):
|
|
|
30
31
|
{ "ok": false, "error": "...", "usage": "..." }
|
|
31
32
|
Runtime/gh failures:
|
|
32
33
|
{ "ok": false, "error": "...", "partialProgress"?: { ... } }
|
|
34
|
+
${JQ_OUTPUT_USAGE}
|
|
33
35
|
Exit codes:
|
|
34
36
|
0 Success
|
|
35
|
-
1 Argument error or gh/runtime failure
|
|
37
|
+
1 Argument error or gh/runtime failure
|
|
38
|
+
2 Invalid --jq filter`.trim();
|
|
36
39
|
const parseError = buildParseError(USAGE);
|
|
37
40
|
export function parseReplyResolveThreadsCliArgs(argv) {
|
|
38
41
|
const { tokens } = parseArgs({
|
|
@@ -44,6 +47,7 @@ export function parseReplyResolveThreadsCliArgs(argv) {
|
|
|
44
47
|
author: { type: "string" },
|
|
45
48
|
message: { type: "string" },
|
|
46
49
|
resolve: { type: "boolean" },
|
|
50
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
47
51
|
},
|
|
48
52
|
allowPositionals: true,
|
|
49
53
|
strict: false,
|
|
@@ -88,6 +92,7 @@ export function parseReplyResolveThreadsCliArgs(argv) {
|
|
|
88
92
|
options.resolve = true;
|
|
89
93
|
continue;
|
|
90
94
|
}
|
|
95
|
+
if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
|
|
91
96
|
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
92
97
|
}
|
|
93
98
|
if (options.repo === undefined || options.pr === undefined) {
|
|
@@ -111,6 +116,62 @@ async function readStdinText(stdin) {
|
|
|
111
116
|
}
|
|
112
117
|
return text;
|
|
113
118
|
}
|
|
119
|
+
// When --message is set, stdin is read only to detect a conflicting second
|
|
120
|
+
// message source. A detached/idle pipe never sends EOF, so an unbounded read
|
|
121
|
+
// hangs forever and the process never exits (issue #1012). Resolve as soon as
|
|
122
|
+
// any NON-WHITESPACE byte arrives (a conflicting body is detected the instant
|
|
123
|
+
// real content appears — no need to wait for EOF); keep buffering while only
|
|
124
|
+
// whitespace has arrived (a leading newline is not yet a conflict and more may
|
|
125
|
+
// follow); resolve on natural EOF; and time out on a silent/idle pipe. Either
|
|
126
|
+
// way the stdin handle is released so the event loop can drain and the tool
|
|
127
|
+
// always terminates.
|
|
128
|
+
const CONFLICT_STDIN_TIMEOUT_MS = 500;
|
|
129
|
+
function readStdinConflictProbe(stdin, timeoutMs) {
|
|
130
|
+
return new Promise((resolve) => {
|
|
131
|
+
let settled = false;
|
|
132
|
+
let timer;
|
|
133
|
+
const cleanup = () => {
|
|
134
|
+
clearTimeout(timer);
|
|
135
|
+
stdin.off?.("data", onData);
|
|
136
|
+
stdin.off?.("end", onEnd);
|
|
137
|
+
stdin.off?.("error", onEnd);
|
|
138
|
+
// Release the handle: an abandoned reader on a still-open pipe would
|
|
139
|
+
// otherwise keep the event loop alive and re-introduce the hang.
|
|
140
|
+
stdin.pause?.();
|
|
141
|
+
if (typeof stdin.unref === "function") {
|
|
142
|
+
stdin.unref();
|
|
143
|
+
} else {
|
|
144
|
+
stdin.destroy?.();
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
const finish = (value) => {
|
|
148
|
+
if (settled) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
settled = true;
|
|
152
|
+
cleanup();
|
|
153
|
+
resolve(value);
|
|
154
|
+
};
|
|
155
|
+
// Resolve early only once non-whitespace content is seen (a real conflict);
|
|
156
|
+
// '' or whitespace-only on clean EOF is not a conflict; undefined only on
|
|
157
|
+
// timeout (idle pipe) so the caller proceeds with --message.
|
|
158
|
+
let text = "";
|
|
159
|
+
const onData = (chunk) => {
|
|
160
|
+
text += String(chunk);
|
|
161
|
+
if (text.trim().length > 0) {
|
|
162
|
+
finish(text);
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
const onEnd = () => finish(text);
|
|
166
|
+
timer = setTimeout(() => finish(undefined), timeoutMs);
|
|
167
|
+
timer.unref?.();
|
|
168
|
+
stdin.setEncoding?.("utf8");
|
|
169
|
+
stdin.on?.("data", onData);
|
|
170
|
+
stdin.on?.("end", onEnd);
|
|
171
|
+
stdin.on?.("error", onEnd);
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
114
175
|
async function resolveMessageInput(options, { stdin = process.stdin } = {}) {
|
|
115
176
|
if (typeof options.message === "string") {
|
|
116
177
|
if (stdin.isTTY) {
|
|
@@ -119,8 +180,8 @@ async function resolveMessageInput(options, { stdin = process.stdin } = {}) {
|
|
|
119
180
|
}
|
|
120
181
|
return options.message;
|
|
121
182
|
}
|
|
122
|
-
const stdinText = await
|
|
123
|
-
if (stdinText.trim().length > 0) {
|
|
183
|
+
const stdinText = await readStdinConflictProbe(stdin, CONFLICT_STDIN_TIMEOUT_MS);
|
|
184
|
+
if (typeof stdinText === "string" && stdinText.trim().length > 0) {
|
|
124
185
|
throw parseError("Choose exactly one message source: --message <text> or stdin");
|
|
125
186
|
}
|
|
126
187
|
if (options.message.trim().length === 0) {
|
|
@@ -241,6 +302,7 @@ export async function runCli(
|
|
|
241
302
|
{
|
|
242
303
|
stdin = process.stdin,
|
|
243
304
|
stdout = process.stdout,
|
|
305
|
+
stderr = process.stderr,
|
|
244
306
|
env = process.env,
|
|
245
307
|
ghCommand = "gh",
|
|
246
308
|
} = {},
|
|
@@ -258,7 +320,7 @@ export async function runCli(
|
|
|
258
320
|
);
|
|
259
321
|
const { matchedTargets, skippedThreadCount } = planBatchReplyTargets(parsed, options.author);
|
|
260
322
|
if (matchedTargets.length === 0) {
|
|
261
|
-
|
|
323
|
+
process.exitCode = emitResult(createSuccessPayload({
|
|
262
324
|
repo: options.repo,
|
|
263
325
|
pr: options.pr,
|
|
264
326
|
author: options.author,
|
|
@@ -268,7 +330,7 @@ export async function runCli(
|
|
|
268
330
|
resolvedThreadCount: 0,
|
|
269
331
|
skippedThreadCount,
|
|
270
332
|
results: [],
|
|
271
|
-
})
|
|
333
|
+
}), { jq: options.jq, silent: options.silent, stdout, stderr });
|
|
272
334
|
return;
|
|
273
335
|
}
|
|
274
336
|
const results = [];
|
|
@@ -328,7 +390,7 @@ export async function runCli(
|
|
|
328
390
|
}
|
|
329
391
|
const repliedThreadCount = results.length;
|
|
330
392
|
const resolvedThreadCount = results.filter((entry) => entry.resolved).length;
|
|
331
|
-
|
|
393
|
+
process.exitCode = emitResult(createSuccessPayload({
|
|
332
394
|
repo: options.repo,
|
|
333
395
|
pr: options.pr,
|
|
334
396
|
author: options.author,
|
|
@@ -338,7 +400,7 @@ export async function runCli(
|
|
|
338
400
|
resolvedThreadCount,
|
|
339
401
|
skippedThreadCount,
|
|
340
402
|
results,
|
|
341
|
-
})
|
|
403
|
+
}), { jq: options.jq, silent: options.silent, stdout, stderr });
|
|
342
404
|
}
|
|
343
405
|
if (isDirectCliRun(import.meta.url)) {
|
|
344
406
|
runCli().catch((error) => {
|
|
@@ -15,6 +15,7 @@ import { fetchGithubReviewThreadsPayload } from "./capture-review-threads.mjs";
|
|
|
15
15
|
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
16
16
|
import { buildSnapshotFromPrFacts, interpretLoopState } from "@dev-loops/core/loop/copilot-loop-state";
|
|
17
17
|
import { loadDevLoopConfig, resolveRefinement } from "@dev-loops/core/config";
|
|
18
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
|
|
18
19
|
const BLOCKED_BY_COPILOT_COMMENT_STATUS = "blocked_by_copilot_comment";
|
|
19
20
|
const SUPPRESSED_SAME_HEAD_CLEAN_STATUS = "suppressed_same_head_clean";
|
|
20
21
|
const ROUND_CAP_REACHED_STATUS = "round_cap_reached";
|
|
@@ -50,9 +51,11 @@ Error output (stderr, JSON):
|
|
|
50
51
|
{ "ok": false, "error": "...", "usage": "..." }
|
|
51
52
|
gh/runtime failures:
|
|
52
53
|
{ "ok": false, "error": "..." }
|
|
54
|
+
${JQ_OUTPUT_USAGE}
|
|
53
55
|
Exit codes:
|
|
54
56
|
0 Success (including unavailable)
|
|
55
|
-
1 Argument error or gh failure
|
|
57
|
+
1 Argument error or gh failure
|
|
58
|
+
2 Invalid --jq filter`.trim();
|
|
56
59
|
const parseError = buildParseError(USAGE);
|
|
57
60
|
export function parseRequestCliArgs(argv) {
|
|
58
61
|
const { tokens } = parseArgs({
|
|
@@ -62,6 +65,7 @@ export function parseRequestCliArgs(argv) {
|
|
|
62
65
|
"force-rerequest-review": { type: "boolean" },
|
|
63
66
|
repo: { type: "string" },
|
|
64
67
|
pr: { type: "string" },
|
|
68
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
65
69
|
},
|
|
66
70
|
allowPositionals: true,
|
|
67
71
|
strict: false,
|
|
@@ -96,6 +100,7 @@ export function parseRequestCliArgs(argv) {
|
|
|
96
100
|
options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
|
|
97
101
|
continue;
|
|
98
102
|
}
|
|
103
|
+
if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
|
|
99
104
|
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
100
105
|
}
|
|
101
106
|
if (options.repo === undefined || options.pr === undefined) {
|
|
@@ -609,6 +614,7 @@ export async function runCli(
|
|
|
609
614
|
argv = process.argv.slice(2),
|
|
610
615
|
{
|
|
611
616
|
stdout = process.stdout,
|
|
617
|
+
stderr = process.stderr,
|
|
612
618
|
env = process.env,
|
|
613
619
|
ghCommand = "gh",
|
|
614
620
|
} = {},
|
|
@@ -619,7 +625,7 @@ export async function runCli(
|
|
|
619
625
|
return;
|
|
620
626
|
}
|
|
621
627
|
const result = await performCopilotReviewRequest(options, { env, ghCommand });
|
|
622
|
-
|
|
628
|
+
process.exitCode = emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
|
|
623
629
|
}
|
|
624
630
|
if (isDirectCliRun(import.meta.url)) {
|
|
625
631
|
runCli().catch((error) => {
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
loadDevLoopConfig,
|
|
10
10
|
resolveHumanHandoffConfig,
|
|
11
11
|
} from "@dev-loops/core/config";
|
|
12
|
+
import { JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
|
|
12
13
|
|
|
13
14
|
const USAGE = `Usage: resolve-handoff-candidates.mjs --repo <owner/name> --pr <number> [--changed-files <a,b,c>] [--pr-author <login>]
|
|
14
15
|
Resolve an ordered, deduped list of human-handoff reviewer/assignee candidates
|
|
@@ -39,9 +40,11 @@ Optional:
|
|
|
39
40
|
Output (stdout, JSON):
|
|
40
41
|
{ "ok": true, "enabled": bool, "candidates": [{ "login", "source", "isTeam"?, "paths"? }],
|
|
41
42
|
"changedFiles": [...], "sources": [...], "warnings": [...] }
|
|
43
|
+
${JQ_OUTPUT_USAGE}
|
|
42
44
|
Exit codes:
|
|
43
45
|
0 Success (including disabled no-op and fail-soft per-source skips)
|
|
44
|
-
1 Argument error
|
|
46
|
+
1 Argument error
|
|
47
|
+
2 Invalid --jq filter`.trim();
|
|
45
48
|
|
|
46
49
|
const parseError = buildParseError(USAGE);
|
|
47
50
|
|
|
@@ -74,6 +77,8 @@ export function parseResolveCandidatesCliArgs(argv) {
|
|
|
74
77
|
continue;
|
|
75
78
|
}
|
|
76
79
|
if (token === "--pr-author") { options.prAuthor = nextValue(args, ++i, "--pr-author").trim().replace(/^@/, ""); continue; }
|
|
80
|
+
if (token === "--jq") { options.jq = nextValue(args, ++i, "--jq"); continue; }
|
|
81
|
+
if (token === "--silent" || token === "-s") { options.silent = true; continue; }
|
|
77
82
|
throw parseError(`Unknown argument: ${token}`);
|
|
78
83
|
}
|
|
79
84
|
if (options.repo === undefined || options.pr === undefined) {
|
|
@@ -403,8 +408,7 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
|
|
|
403
408
|
{ repo: options.repo, pr: options.pr, changedFiles: options.changedFiles, prAuthor: options.prAuthor ?? null },
|
|
404
409
|
{ ...deps, config, repoRoot },
|
|
405
410
|
);
|
|
406
|
-
|
|
407
|
-
return 0;
|
|
411
|
+
return emitResult(result, { jq: options.jq, silent: options.silent });
|
|
408
412
|
}
|
|
409
413
|
|
|
410
414
|
if (isDirectCliRun(import.meta.url)) {
|
|
@@ -3,6 +3,7 @@ import { parseArgs } from "node:util";
|
|
|
3
3
|
import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
|
|
4
4
|
import { parseIssueNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
|
|
5
5
|
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
6
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
|
|
6
7
|
const ISSUE_JSON_FIELDS = "number,title,body,url,state";
|
|
7
8
|
const USAGE = `Usage: resolve-tracker-local-spec.mjs (--repo <owner/name> --issue <number> | --issue-url <github-issue-url>)
|
|
8
9
|
Resolve the canonical tracker-backed local spec bundle from one GitHub issue reference.
|
|
@@ -30,7 +31,8 @@ Error output (stderr, JSON):
|
|
|
30
31
|
Argument/usage errors:
|
|
31
32
|
{ "ok": false, "error": "...", "usage": "..." }
|
|
32
33
|
gh/runtime failures:
|
|
33
|
-
{ "ok": false, "error": "..." }
|
|
34
|
+
{ "ok": false, "error": "..." }
|
|
35
|
+
${JQ_OUTPUT_USAGE}`.trim();
|
|
34
36
|
const parseError = buildParseError(USAGE);
|
|
35
37
|
export function parseGitHubIssueUrl(value) {
|
|
36
38
|
let parsedUrl;
|
|
@@ -68,6 +70,7 @@ export function parseResolveTrackerLocalSpecCliArgs(argv) {
|
|
|
68
70
|
repo: { type: "string" },
|
|
69
71
|
issue: { type: "string" },
|
|
70
72
|
"issue-url": { type: "string" },
|
|
73
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
71
74
|
},
|
|
72
75
|
allowPositionals: true,
|
|
73
76
|
strict: false,
|
|
@@ -102,6 +105,7 @@ export function parseResolveTrackerLocalSpecCliArgs(argv) {
|
|
|
102
105
|
options.issueUrl = requireTokenValue(token, parseError).trim();
|
|
103
106
|
continue;
|
|
104
107
|
}
|
|
108
|
+
if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
|
|
105
109
|
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
106
110
|
}
|
|
107
111
|
const usingIssueUrl = typeof options.issueUrl === "string";
|
|
@@ -119,6 +123,8 @@ export function parseResolveTrackerLocalSpecCliArgs(argv) {
|
|
|
119
123
|
repo,
|
|
120
124
|
issue,
|
|
121
125
|
issueUrl: options.issueUrl,
|
|
126
|
+
...(options.jq !== undefined ? { jq: options.jq } : {}),
|
|
127
|
+
...(options.silent !== undefined ? { silent: options.silent } : {}),
|
|
122
128
|
};
|
|
123
129
|
}
|
|
124
130
|
try {
|
|
@@ -201,7 +207,7 @@ export async function resolveTrackerLocalSpec(
|
|
|
201
207
|
}
|
|
202
208
|
export async function runCli(
|
|
203
209
|
argv = process.argv.slice(2),
|
|
204
|
-
{ stdout = process.stdout, env = process.env, ghCommand = "gh" } = {},
|
|
210
|
+
{ stdout = process.stdout, stderr = process.stderr, env = process.env, ghCommand = "gh" } = {},
|
|
205
211
|
) {
|
|
206
212
|
const options = parseResolveTrackerLocalSpecCliArgs(argv);
|
|
207
213
|
if (options.help) {
|
|
@@ -212,7 +218,7 @@ export async function runCli(
|
|
|
212
218
|
{ repo: options.repo, issue: options.issue },
|
|
213
219
|
{ env, ghCommand },
|
|
214
220
|
);
|
|
215
|
-
|
|
221
|
+
process.exitCode = emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
|
|
216
222
|
}
|
|
217
223
|
if (isDirectCliRun(import.meta.url)) {
|
|
218
224
|
runCli().catch((error) => {
|
|
@@ -7,6 +7,7 @@ import { parseArgs } from "node:util";
|
|
|
7
7
|
import { parsePositiveInteger, requireTokenValue } from "../_cli-primitives.mjs";
|
|
8
8
|
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
9
9
|
import { buildDraftReviewPayload } from "@dev-loops/core/loop/reviewer-loop-state";
|
|
10
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
|
|
10
11
|
const HELP = `Usage: stage-reviewer-draft.mjs --repo <owner/name> --pr <number> --review-file <path> [--local-state-output <path>]
|
|
11
12
|
Stage a pending draft review on a GitHub pull request.
|
|
12
13
|
Options:
|
|
@@ -15,9 +16,13 @@ Options:
|
|
|
15
16
|
--review-file <path> Path to JSON file containing review payload (required)
|
|
16
17
|
--local-state-output <path> Path to write local state snapshot (optional)
|
|
17
18
|
--help, -h Show this help
|
|
19
|
+
|
|
20
|
+
${JQ_OUTPUT_USAGE}
|
|
21
|
+
|
|
18
22
|
Exit codes:
|
|
19
23
|
0 Success
|
|
20
24
|
1 Error
|
|
25
|
+
2 Invalid --jq filter
|
|
21
26
|
`;
|
|
22
27
|
export function parseStageDraftCliArgs(argv) {
|
|
23
28
|
const { tokens } = parseArgs({
|
|
@@ -28,6 +33,7 @@ export function parseStageDraftCliArgs(argv) {
|
|
|
28
33
|
pr: { type: "string" },
|
|
29
34
|
"review-file": { type: "string" },
|
|
30
35
|
"local-state-output": { type: "string" },
|
|
36
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
31
37
|
},
|
|
32
38
|
allowPositionals: true,
|
|
33
39
|
strict: false,
|
|
@@ -67,6 +73,7 @@ export function parseStageDraftCliArgs(argv) {
|
|
|
67
73
|
options.localStateOutput = requireTokenValue(token);
|
|
68
74
|
continue;
|
|
69
75
|
}
|
|
76
|
+
if (matchJqOutputToken(token, options, (t) => requireTokenValue(t))) continue;
|
|
70
77
|
throw new Error(`Unknown argument: ${token.rawName}`);
|
|
71
78
|
}
|
|
72
79
|
if (!options.repo || !options.pr || !options.reviewFile) {
|
|
@@ -169,6 +176,7 @@ export async function runCli(
|
|
|
169
176
|
argv = process.argv.slice(2),
|
|
170
177
|
{
|
|
171
178
|
stdout = process.stdout,
|
|
179
|
+
stderr = process.stderr,
|
|
172
180
|
env = process.env,
|
|
173
181
|
ghCommand = "gh",
|
|
174
182
|
} = {},
|
|
@@ -190,7 +198,7 @@ export async function runCli(
|
|
|
190
198
|
await postDraftReview({ repo: options.repo, pr: options.pr, reviewPayload }, { env, ghCommand }),
|
|
191
199
|
);
|
|
192
200
|
const localStatePath = await writeLocalState(options.localStateOutput, draftReview);
|
|
193
|
-
|
|
201
|
+
process.exitCode = emitResult({
|
|
194
202
|
ok: true,
|
|
195
203
|
repo: options.repo,
|
|
196
204
|
pr: options.pr,
|
|
@@ -199,7 +207,7 @@ export async function runCli(
|
|
|
199
207
|
reviewState: draftReview.state,
|
|
200
208
|
commitSha: draftReview.commitSha,
|
|
201
209
|
localStatePath,
|
|
202
|
-
}
|
|
210
|
+
}, { jq: options.jq, silent: options.silent, stdout, stderr });
|
|
203
211
|
}
|
|
204
212
|
if (isDirectCliRun(import.meta.url)) {
|
|
205
213
|
runCli().catch((error) => {
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Mirror the issue-body AC/DoD tick procedure onto the PR body: after a clean
|
|
3
|
+
// pre_approval_gate verification, flip each verified item's `- [ ]` to `- [x]`
|
|
4
|
+
// in the PR body via a single `gh pr edit --body-file` update. Matches labels by
|
|
5
|
+
// EXACT (trimmed) text and fails closed — never blanket-checks, never unchecks.
|
|
6
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { parseArgs } from "node:util";
|
|
10
|
+
import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
|
|
11
|
+
import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
|
|
12
|
+
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
13
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
|
|
14
|
+
import { editPr } from "./edit-pr.mjs";
|
|
15
|
+
import { viewPr } from "./view-pr.mjs";
|
|
16
|
+
|
|
17
|
+
const USAGE = `Usage: tick-verified-checkboxes.mjs --repo <owner/name> --pr <number> --verified <label> [--verified <label>...] [--dry-run]
|
|
18
|
+
Tick verified acceptance-criteria checkboxes in a PR body. Mirrors the issue-body
|
|
19
|
+
tick from skills/docs/acceptance-criteria-verification.md onto the PR body so the
|
|
20
|
+
merged PR shows checked AC/DoD. Flips \`- [ ] <label>\` to \`- [x] <label>\` ONLY when
|
|
21
|
+
<label> exactly equals a --verified label (exact-match, fail-closed: never blanket-
|
|
22
|
+
checks, never unchecks). Applies a single \`gh pr edit --body-file\` update.
|
|
23
|
+
Required:
|
|
24
|
+
--repo <owner/name> Repository slug (e.g. owner/repo)
|
|
25
|
+
--pr <number> Pull request number
|
|
26
|
+
--verified <label> An exact (trimmed) checklist label that was verified
|
|
27
|
+
(repeatable; at least one is required)
|
|
28
|
+
Options:
|
|
29
|
+
--dry-run Compute + report the flips but never edit the PR
|
|
30
|
+
Output (stdout, JSON):
|
|
31
|
+
{ "ok": true, "pr": 17, "flipped": [...], "unmatched": [...], "edited": true|false }
|
|
32
|
+
Error output (stderr, JSON):
|
|
33
|
+
{ "ok": false, "error": "...", "usage"?: "..." }
|
|
34
|
+
${JQ_OUTPUT_USAGE}
|
|
35
|
+
Exit codes:
|
|
36
|
+
0 Success
|
|
37
|
+
1 Argument error or gh failure
|
|
38
|
+
2 Invalid --jq filter`.trim();
|
|
39
|
+
const parseError = buildParseError(USAGE);
|
|
40
|
+
|
|
41
|
+
// GFM checklist line: optional indentation, a `-`/`*`/`+` bullet, a `[ ]`/`[x]`
|
|
42
|
+
// box, then the label. Capture indent + bullet so we preserve them on flip, and
|
|
43
|
+
// an optional trailing `\r` so CRLF bodies (as returned by `gh pr view`) round-
|
|
44
|
+
// trip byte-for-byte instead of silently failing to match.
|
|
45
|
+
const CHECKBOX_RE = /^(\s*)([-*+])\s+\[([ xX])\]\s+(.*?)(\r?)$/;
|
|
46
|
+
|
|
47
|
+
// PURE: flip only unchecked checklist lines whose trimmed label EXACTLY equals a
|
|
48
|
+
// verified label. Never unchecks. Never touches non-checkbox text. Idempotent:
|
|
49
|
+
// an already-`[x]` verified label is neither flipped nor reported as unmatched.
|
|
50
|
+
// Returns { body, flipped, unmatched } where flipped = labels changed this call
|
|
51
|
+
// and unmatched = verified labels absent from the body's checklist.
|
|
52
|
+
export function tickVerifiedCheckboxes(body, verifiedLabels) {
|
|
53
|
+
const verified = new Set(
|
|
54
|
+
(verifiedLabels ?? []).map((l) => String(l).trim()).filter((l) => l.length > 0),
|
|
55
|
+
);
|
|
56
|
+
const flipped = [];
|
|
57
|
+
const found = new Set();
|
|
58
|
+
const lines = String(body).split("\n");
|
|
59
|
+
const nextLines = lines.map((line) => {
|
|
60
|
+
const m = line.match(CHECKBOX_RE);
|
|
61
|
+
if (!m) return line;
|
|
62
|
+
const [, indent, bullet, mark, rest, cr] = m;
|
|
63
|
+
const label = rest.trim();
|
|
64
|
+
if (!verified.has(label)) return line;
|
|
65
|
+
found.add(label);
|
|
66
|
+
if (mark !== " ") return line; // already checked — leave it, idempotent
|
|
67
|
+
if (!flipped.includes(label)) flipped.push(label); // duplicate lines: report once
|
|
68
|
+
return `${indent}${bullet} [x] ${rest}${cr}`;
|
|
69
|
+
});
|
|
70
|
+
const unmatched = [...verified].filter((label) => !found.has(label));
|
|
71
|
+
return { body: nextLines.join("\n"), flipped, unmatched };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function parseTickVerifiedCliArgs(argv) {
|
|
75
|
+
const { tokens } = parseArgs({
|
|
76
|
+
args: [...argv],
|
|
77
|
+
options: {
|
|
78
|
+
help: { type: "boolean", short: "h" },
|
|
79
|
+
repo: { type: "string" },
|
|
80
|
+
pr: { type: "string" },
|
|
81
|
+
verified: { type: "string", multiple: true },
|
|
82
|
+
"dry-run": { type: "boolean" },
|
|
83
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
84
|
+
},
|
|
85
|
+
allowPositionals: true,
|
|
86
|
+
strict: false,
|
|
87
|
+
tokens: true,
|
|
88
|
+
});
|
|
89
|
+
const options = {
|
|
90
|
+
help: false,
|
|
91
|
+
repo: undefined,
|
|
92
|
+
pr: undefined,
|
|
93
|
+
verified: [],
|
|
94
|
+
dryRun: false,
|
|
95
|
+
jq: undefined,
|
|
96
|
+
silent: false,
|
|
97
|
+
};
|
|
98
|
+
for (const token of tokens) {
|
|
99
|
+
if (token.kind === "positional") {
|
|
100
|
+
throw parseError(`Unknown argument: ${token.value}`);
|
|
101
|
+
}
|
|
102
|
+
if (token.kind !== "option") continue;
|
|
103
|
+
if (token.name === "help") {
|
|
104
|
+
options.help = true;
|
|
105
|
+
return options;
|
|
106
|
+
}
|
|
107
|
+
if (token.name === "repo") {
|
|
108
|
+
options.repo = requireTokenValue(token, parseError).trim();
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (token.name === "pr") {
|
|
112
|
+
options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (token.name === "verified") {
|
|
116
|
+
const label = requireTokenValue(token, parseError).trim();
|
|
117
|
+
if (label.length === 0) throw parseError("--verified must be a non-empty label");
|
|
118
|
+
options.verified.push(label);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (token.name === "dry-run") {
|
|
122
|
+
options.dryRun = true;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
|
|
126
|
+
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
127
|
+
}
|
|
128
|
+
if (options.repo === undefined || options.pr === undefined) {
|
|
129
|
+
throw parseError("Ticking PR checkboxes requires both --repo <owner/name> and --pr <number>");
|
|
130
|
+
}
|
|
131
|
+
if (options.verified.length === 0) {
|
|
132
|
+
throw parseError("at least one --verified is required");
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
parseRepoSlug(options.repo);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
throw parseError(error instanceof Error ? error.message : String(error));
|
|
138
|
+
}
|
|
139
|
+
return options;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function tickCheckboxes(
|
|
143
|
+
options,
|
|
144
|
+
{ env = process.env, ghCommand = "gh", run = runChild, editPr: edit = editPr } = {},
|
|
145
|
+
) {
|
|
146
|
+
// Reuse view-pr.mjs rather than re-implementing `gh pr view --json body`.
|
|
147
|
+
const { pr } = await viewPr(
|
|
148
|
+
{ repo: options.repo, pr: options.pr, fields: "body" },
|
|
149
|
+
{ env, ghCommand, run },
|
|
150
|
+
);
|
|
151
|
+
const currentBody = typeof pr.body === "string" ? pr.body : "";
|
|
152
|
+
const { body: nextBody, flipped, unmatched } = tickVerifiedCheckboxes(currentBody, options.verified);
|
|
153
|
+
if (flipped.length === 0 || options.dryRun) {
|
|
154
|
+
return { ok: true, pr: options.pr, flipped, unmatched, edited: false };
|
|
155
|
+
}
|
|
156
|
+
const dir = await mkdtemp(join(tmpdir(), "tick-verified-"));
|
|
157
|
+
try {
|
|
158
|
+
const bodyFile = join(dir, "body.md");
|
|
159
|
+
await writeFile(bodyFile, nextBody, "utf8");
|
|
160
|
+
await edit(
|
|
161
|
+
{ repo: options.repo, pr: options.pr, bodyFile, addAssignees: [], removeAssignees: [] },
|
|
162
|
+
{ env, ghCommand, run },
|
|
163
|
+
);
|
|
164
|
+
return { ok: true, pr: options.pr, flipped, unmatched, edited: true };
|
|
165
|
+
} finally {
|
|
166
|
+
// Clean up the temp dir on success or failure; never let cleanup mask the result/error.
|
|
167
|
+
try {
|
|
168
|
+
await rm(dir, { recursive: true, force: true });
|
|
169
|
+
} catch {
|
|
170
|
+
// ignore cleanup failures
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function runCli(
|
|
176
|
+
argv = process.argv.slice(2),
|
|
177
|
+
{ stdout = process.stdout, stderr = process.stderr, env = process.env, ghCommand = "gh", run = runChild, editPr: edit = editPr } = {},
|
|
178
|
+
) {
|
|
179
|
+
let options;
|
|
180
|
+
try {
|
|
181
|
+
options = parseTickVerifiedCliArgs(argv);
|
|
182
|
+
} catch (error) {
|
|
183
|
+
stderr.write(`${formatCliError(error)}\n`);
|
|
184
|
+
return 1;
|
|
185
|
+
}
|
|
186
|
+
if (options.help) {
|
|
187
|
+
stdout.write(`${USAGE}\n`);
|
|
188
|
+
return 0;
|
|
189
|
+
}
|
|
190
|
+
let result;
|
|
191
|
+
try {
|
|
192
|
+
result = await tickCheckboxes(options, { env, ghCommand, run, editPr: edit });
|
|
193
|
+
} catch (error) {
|
|
194
|
+
stderr.write(`${JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) })}\n`);
|
|
195
|
+
return 1;
|
|
196
|
+
}
|
|
197
|
+
return emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
201
|
+
runCli().then((code) => { process.exitCode = code; });
|
|
202
|
+
}
|
|
@@ -3,7 +3,7 @@ import { readFile } from "node:fs/promises";
|
|
|
3
3
|
import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
|
|
4
4
|
import { loadDevLoopConfig, resolveGateConfig, resolveRefinementConfig } from "@dev-loops/core/config";
|
|
5
5
|
import { parseArgs } from "node:util";
|
|
6
|
-
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
|
|
6
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
|
|
7
7
|
import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
|
|
8
8
|
import { truncateText } from "@dev-loops/core/bash-exit-one";
|
|
9
9
|
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
@@ -391,14 +391,7 @@ export function parseUpsertCheckpointVerdictCliArgs(argv) {
|
|
|
391
391
|
options.inlineReason = smartTruncate(reason, MAX_GATE_COMMENT_EXCERPT_LENGTH);
|
|
392
392
|
continue;
|
|
393
393
|
}
|
|
394
|
-
if (token
|
|
395
|
-
options.jq = requireTokenValue(token, parseError);
|
|
396
|
-
continue;
|
|
397
|
-
}
|
|
398
|
-
if (token.name === "silent") {
|
|
399
|
-
options.silent = true;
|
|
400
|
-
continue;
|
|
401
|
-
}
|
|
394
|
+
if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
|
|
402
395
|
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
403
396
|
}
|
|
404
397
|
// Default execution mode to inline_single_agent when omitted. inlineReason is
|
|
@@ -897,7 +890,14 @@ async function postDraftGateViaDraftTransition(options, { env, ghCommand, repoRo
|
|
|
897
890
|
try {
|
|
898
891
|
// The PR is now a draft, so RUN_DRAFT_GATE is the legal action. Re-enter with
|
|
899
892
|
// the caller's full options; prIsDraft is now true so this branch is skipped.
|
|
900
|
-
|
|
893
|
+
// `_draftTransitionInProgress` guards against unbounded recursion: if GitHub's
|
|
894
|
+
// draft-state read still lags the conversion mutation on re-entry (isDraft reads
|
|
895
|
+
// false again), the reconcile branch must NOT fire a second time — it fails closed
|
|
896
|
+
// with a clear error instead of recursing indefinitely (exit 13). (#1020)
|
|
897
|
+
result = await upsertCheckpointVerdict(
|
|
898
|
+
{ ...options, _draftTransitionInProgress: true },
|
|
899
|
+
{ env, ghCommand, repoRoot },
|
|
900
|
+
);
|
|
901
901
|
} catch (error) {
|
|
902
902
|
if (conversion.alreadyDraft !== true) {
|
|
903
903
|
try {
|
|
@@ -1060,11 +1060,32 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
|
|
|
1060
1060
|
if (
|
|
1061
1061
|
options.gate === "draft_gate"
|
|
1062
1062
|
&& !prIsDraft
|
|
1063
|
+
&& !options._draftTransitionInProgress
|
|
1063
1064
|
&& !coordination.draftGateAlreadySatisfied
|
|
1064
1065
|
&& coordination.allowedNextActions.includes(PR_CHECKPOINT_ACTION.RECONCILE_DRAFT_GATE)
|
|
1065
1066
|
) {
|
|
1066
1067
|
return await postDraftGateViaDraftTransition(options, { env, ghCommand, repoRoot });
|
|
1067
1068
|
}
|
|
1069
|
+
// Fail closed on a lagged draft-state read: we are re-entering FROM
|
|
1070
|
+
// postDraftGateViaDraftTransition (which just converted the PR to draft) yet the
|
|
1071
|
+
// coordination context still reports the PR as non-draft. Recursing would loop
|
|
1072
|
+
// indefinitely (the original #1020 hang → exit 13, error swallowed). Surface a
|
|
1073
|
+
// clear, actionable error instead so the operator knows the draft conversion did
|
|
1074
|
+
// not take (or GitHub's read lags the mutation) and can retry. (#1020)
|
|
1075
|
+
if (
|
|
1076
|
+
options.gate === "draft_gate"
|
|
1077
|
+
&& !prIsDraft
|
|
1078
|
+
&& options._draftTransitionInProgress
|
|
1079
|
+
) {
|
|
1080
|
+
throw new Error(
|
|
1081
|
+
`draft_gate self-heal for ${options.repo}#${options.pr} failed: the PR was converted to draft ` +
|
|
1082
|
+
`to post the verdict, but GitHub still reports it as non-draft on re-entry (draft-state read lagged ` +
|
|
1083
|
+
`the conversion mutation, or the conversion did not take). Not recursing. Re-run the draft_gate post ` +
|
|
1084
|
+
`once the PR reflects the draft state, or reconcile manually with ` +
|
|
1085
|
+
`\`gh pr ready ${options.pr} --repo ${options.repo}\` / ` +
|
|
1086
|
+
`\`node scripts/github/reconcile-draft-gate.mjs --repo ${options.repo} --pr ${options.pr}\`.`,
|
|
1087
|
+
);
|
|
1088
|
+
}
|
|
1068
1089
|
if (gateActionForbidden) {
|
|
1069
1090
|
throw new Error(buildGateEntryRefusalError({ options, coordination }));
|
|
1070
1091
|
}
|