dev-loops 0.6.0 → 0.7.2
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 +8 -7
- package/.claude/agents/fixer.md +1 -0
- package/.claude/agents/refiner.md +2 -2
- package/.claude/agents/review.md +4 -3
- 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} +2 -2
- 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 +60 -34
- package/.claude/skills/dev-loop/SKILL.md +16 -12
- package/.claude/skills/docs/acceptance-criteria-verification.md +8 -3
- package/.claude/skills/docs/anti-patterns.md +5 -3
- package/.claude/skills/docs/artifact-authority-contract.md +30 -11
- package/.claude/skills/docs/confirmation-rules.md +1 -0
- package/.claude/skills/docs/contract-style-guide.md +37 -0
- package/.claude/skills/docs/copilot-loop-operations.md +19 -15
- package/.claude/skills/docs/cross-harness-regression-contract.md +60 -0
- package/.claude/skills/docs/debt-remediation-contract.md +1 -1
- package/.claude/skills/docs/epic-tree-refinement-procedure.md +25 -22
- package/.claude/skills/docs/issue-intake-procedure.md +26 -19
- 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 +30 -12
- package/.claude/skills/docs/plan-file-contract.md +1 -1
- package/.claude/skills/docs/pr-lifecycle-contract.md +44 -35
- package/.claude/skills/docs/public-dev-loop-contract.md +60 -44
- package/.claude/skills/docs/retrospective-checkpoint-contract.md +106 -79
- package/.claude/skills/docs/spike-mode-contract.md +14 -7
- package/.claude/skills/docs/stop-conditions.md +30 -15
- package/.claude/skills/docs/tracker-first-loop-state.md +13 -8
- package/.claude/skills/docs/ui-e2e-scoping-step.md +32 -0
- package/.claude/skills/docs/validation-policy.md +4 -5
- package/.claude/skills/docs/workflow-handoff-contract.md +39 -0
- package/.claude/skills/local-implementation/SKILL.md +62 -145
- package/.claude/skills/loop-grill/SKILL.md +163 -0
- package/AGENTS.md +1 -0
- package/CHANGELOG.md +101 -0
- package/README.md +9 -9
- package/agents/dev-loop.agent.md +11 -10
- package/agents/developer.agent.md +1 -1
- package/agents/docs.agent.md +1 -1
- package/agents/fixer.agent.md +2 -1
- package/agents/quality.agent.md +1 -1
- package/agents/refiner.agent.md +3 -3
- package/agents/review.agent.md +5 -4
- 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 +12 -12
- package/scripts/_core-helpers.mjs +2 -0
- package/scripts/claude/headless-dev-loop.mjs +1 -1
- package/scripts/docs/validate-rule-ownership.mjs +442 -0
- package/scripts/docs/validate-state-machine-conformance.mjs +1092 -0
- package/scripts/github/_review-thread-mutations.mjs +5 -2
- 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/create-pr.mjs +119 -10
- package/scripts/github/detect-checkpoint-evidence.mjs +259 -18
- 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/list-review-threads.mjs +277 -0
- 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 +20 -5
- 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 +98 -26
- 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 +105 -15
- package/scripts/github/verify-briefing-prefixes.mjs +191 -0
- package/scripts/github/verify-fresh-review-context.mjs +226 -32
- package/scripts/github/view-pr.mjs +150 -0
- package/scripts/github/wait-pr-checks.mjs +171 -0
- package/scripts/github/write-gate-context.mjs +458 -65
- package/scripts/github/write-gate-findings-log.mjs +128 -2
- 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 +162 -14
- package/scripts/loop/debt-remediate.mjs +24 -12
- package/scripts/loop/detect-change-scope.mjs +38 -9
- package/scripts/loop/detect-copilot-loop-state.mjs +34 -8
- 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 +184 -69
- 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 +216 -10
- 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 +106 -0
- package/scripts/loop/steer-loop.mjs +29 -16
- package/scripts/loop/validate-pr-body-spec.mjs +223 -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 +443 -0
- package/scripts/projects/_resolve-project.mjs +1 -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 +59 -382
- package/scripts/projects/move-queue-item.mjs +21 -449
- package/scripts/projects/reconcile-queue.mjs +253 -0
- package/scripts/projects/reorder-queue-item.mjs +43 -68
- 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 +60 -34
- package/skills/dev-loop/SKILL.md +11 -7
- package/skills/docs/acceptance-criteria-verification.md +8 -3
- package/skills/docs/anti-patterns.md +5 -3
- package/skills/docs/artifact-authority-contract.md +30 -11
- package/skills/docs/confirmation-rules.md +1 -0
- package/skills/docs/contract-style-guide.md +37 -0
- package/skills/docs/copilot-loop-operations.md +19 -15
- package/skills/docs/cross-harness-regression-contract.md +60 -0
- package/skills/docs/debt-remediation-contract.md +1 -1
- package/skills/docs/epic-tree-refinement-procedure.md +25 -22
- package/skills/docs/issue-intake-procedure.md +26 -19
- 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 +30 -12
- package/skills/docs/plan-file-contract.md +1 -1
- package/skills/docs/pr-lifecycle-contract.md +44 -35
- package/skills/docs/public-dev-loop-contract.md +60 -44
- package/skills/docs/required-rules.json +149 -0
- package/skills/docs/retrospective-checkpoint-contract.md +106 -79
- package/skills/docs/spike-mode-contract.md +14 -7
- package/skills/docs/stop-conditions.md +30 -15
- package/skills/docs/tracker-first-loop-state.md +13 -8
- package/skills/docs/ui-e2e-scoping-step.md +32 -0
- package/skills/docs/validation-policy.md +4 -5
- package/skills/docs/workflow-handoff-contract.md +39 -0
- package/skills/local-implementation/SKILL.md +62 -145
- package/skills/loop-grill/SKILL.md +165 -0
- package/.claude/commands/continue.md +0 -15
- package/scripts/docs/validate-no-duplicate-rules.mjs +0 -250
- 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
|
@@ -54,6 +54,7 @@ import { readFileSync } from "node:fs";
|
|
|
54
54
|
import process from "node:process";
|
|
55
55
|
import { parseArgs } from "node:util";
|
|
56
56
|
import { isDirectCliRun } from "@dev-loops/core/cli/helpers";
|
|
57
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
|
|
57
58
|
|
|
58
59
|
const USAGE = `Usage: node scripts/loop/check-retro-tooling.mjs [--transcript <path>] [--json]
|
|
59
60
|
|
|
@@ -65,10 +66,13 @@ Options:
|
|
|
65
66
|
--transcript <path> File of newline-delimited commands (default: read stdin)
|
|
66
67
|
--json Emit machine-readable JSON (default: human summary)
|
|
67
68
|
|
|
69
|
+
${JQ_OUTPUT_USAGE}
|
|
70
|
+
(--jq/--silent only apply together with --json; the default text output is unaffected.)
|
|
71
|
+
|
|
68
72
|
Exit codes:
|
|
69
73
|
0 No violations
|
|
70
74
|
1 One or more violations found
|
|
71
|
-
2 Argument/runtime error`;
|
|
75
|
+
2 Argument/runtime error, or invalid --jq filter`;
|
|
72
76
|
|
|
73
77
|
/**
|
|
74
78
|
* Write-ops that currently have no internal dev-loops wrapper. Recorded
|
|
@@ -81,6 +85,10 @@ const ALLOWED_WRITE_OPS = Object.freeze([
|
|
|
81
85
|
/^gh\s+pr\s+ready\b/,
|
|
82
86
|
/^gh\s+issue\s+create\b/,
|
|
83
87
|
/^gh\s+issue\s+edit\b/,
|
|
88
|
+
// `gh label create` HAS a wrapper (scripts/github/create-label.mjs); this
|
|
89
|
+
// entry is belt-and-suspenders so a bare invocation (e.g. surfaced from the
|
|
90
|
+
// wrapper's own subprocess) classifies as an allowed write-op, not a violation.
|
|
91
|
+
/^gh\s+label\s+create\b/,
|
|
84
92
|
]);
|
|
85
93
|
|
|
86
94
|
/** Split a command line into top-level segments on &&, ||, |, ;. */
|
|
@@ -190,6 +198,7 @@ function parseCliArgs(argv) {
|
|
|
190
198
|
transcript: { type: "string" },
|
|
191
199
|
json: { type: "boolean" },
|
|
192
200
|
help: { type: "boolean", short: "h" },
|
|
201
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
193
202
|
},
|
|
194
203
|
strict: true,
|
|
195
204
|
allowPositionals: false,
|
|
@@ -221,8 +230,10 @@ async function run(argv, { stdout, stderr }) {
|
|
|
221
230
|
const { violations, allowedWriteOps, internalToolingOnly } = analyzeTranscript(transcript);
|
|
222
231
|
|
|
223
232
|
if (values.json) {
|
|
224
|
-
|
|
225
|
-
|
|
233
|
+
const payload = { ok: internalToolingOnly, internalToolingOnly, rawCallViolations: violations, allowedWriteOps };
|
|
234
|
+
return emitResult(payload, { jq: values.jq, silent: values.silent, stdout, stderr });
|
|
235
|
+
}
|
|
236
|
+
if (internalToolingOnly) {
|
|
226
237
|
stdout.write(`internalToolingOnly: true — no agent-level raw gh/python/node -e calls found.\n`);
|
|
227
238
|
if (allowedWriteOps.length > 0) {
|
|
228
239
|
stdout.write(`Allowed write-ops (no wrapper yet): ${allowedWriteOps.length}\n`);
|
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import process from "node:process";
|
|
5
5
|
import { parseArgs } from "node:util";
|
|
6
6
|
import { isDirectCliRun } from "@dev-loops/core/cli/helpers";
|
|
7
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
|
|
7
8
|
|
|
8
9
|
const CHECKPOINT_FILE = ".pi/dev-loop-retrospective-checkpoint.json";
|
|
9
10
|
const ALLOWED_STATES = new Set(["required", "complete", "skipped", "none", "missing"]);
|
|
@@ -17,7 +18,9 @@ Required:
|
|
|
17
18
|
|
|
18
19
|
Optional:
|
|
19
20
|
--notes <text> Required when --state is complete
|
|
20
|
-
--reason <text> Required when --state is skipped
|
|
21
|
+
--reason <text> Required when --state is skipped
|
|
22
|
+
|
|
23
|
+
${JQ_OUTPUT_USAGE}`;
|
|
21
24
|
|
|
22
25
|
function parseError(message) {
|
|
23
26
|
return Object.assign(new Error(message), { usage: USAGE });
|
|
@@ -43,6 +46,7 @@ function parseCliArgs(argv) {
|
|
|
43
46
|
notes: { type: "string" },
|
|
44
47
|
reason: { type: "string" },
|
|
45
48
|
help: { type: "boolean", short: "h" },
|
|
49
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
46
50
|
},
|
|
47
51
|
strict: true,
|
|
48
52
|
allowPositionals: false,
|
|
@@ -67,7 +71,7 @@ function parseCliArgs(argv) {
|
|
|
67
71
|
throw parseError('state "skipped" requires --reason');
|
|
68
72
|
}
|
|
69
73
|
|
|
70
|
-
return { state, notes: values.notes ?? null, reason: values.reason ?? null };
|
|
74
|
+
return { state, notes: values.notes ?? null, reason: values.reason ?? null, jq: values.jq, silent: values.silent === true };
|
|
71
75
|
}
|
|
72
76
|
|
|
73
77
|
async function run(argv) {
|
|
@@ -82,8 +86,7 @@ async function run(argv) {
|
|
|
82
86
|
const checkpointPath = path.join(process.cwd(), CHECKPOINT_FILE);
|
|
83
87
|
await mkdir(path.dirname(checkpointPath), { recursive: true });
|
|
84
88
|
await writeFile(checkpointPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
85
|
-
|
|
86
|
-
return 0;
|
|
89
|
+
return emitResult({ ok: true, path: CHECKPOINT_FILE, checkpoint: payload }, { jq: parsed.jq, silent: parsed.silent });
|
|
87
90
|
}
|
|
88
91
|
|
|
89
92
|
if (isDirectCliRun(import.meta.url)) {
|
|
@@ -21,6 +21,7 @@ import { requireTokenValue } from "../_cli-primitives.mjs";
|
|
|
21
21
|
import { parseArgs } from "node:util";
|
|
22
22
|
import { resolveWorktreePath, WORKTREE_NAMESPACE } from "@dev-loops/core/loop/handoff-envelope";
|
|
23
23
|
import { canonicalize } from "./_worktree-path.mjs";
|
|
24
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
|
|
24
25
|
|
|
25
26
|
const USAGE = `Usage:
|
|
26
27
|
cleanup-worktree.mjs --repo-root <p> (--issue <n> | --pr <n> | --path <p>)
|
|
@@ -37,7 +38,9 @@ Optional:
|
|
|
37
38
|
Output (stdout, JSON):
|
|
38
39
|
{ "ok": bool, "removed": <path>|null, "reason": "<why>" }
|
|
39
40
|
ok is true on success/skip (incl. fail-soft git errors); false ONLY when the
|
|
40
|
-
path is refused for being outside ${WORKTREE_NAMESPACE}/ (removed: null)
|
|
41
|
+
path is refused for being outside ${WORKTREE_NAMESPACE}/ (removed: null).
|
|
42
|
+
|
|
43
|
+
${JQ_OUTPUT_USAGE}`.trim();
|
|
41
44
|
|
|
42
45
|
const parseError = buildParseError(USAGE);
|
|
43
46
|
|
|
@@ -57,6 +60,7 @@ export function parseCleanupWorktreeCliArgs(argv) {
|
|
|
57
60
|
issue: { type: "string" },
|
|
58
61
|
pr: { type: "string" },
|
|
59
62
|
path: { type: "string" },
|
|
63
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
60
64
|
},
|
|
61
65
|
allowPositionals: true,
|
|
62
66
|
strict: false,
|
|
@@ -85,6 +89,7 @@ export function parseCleanupWorktreeCliArgs(argv) {
|
|
|
85
89
|
options.path = requireTokenValue(token, parseError, { flagPattern: /^-/u });
|
|
86
90
|
continue;
|
|
87
91
|
}
|
|
92
|
+
if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
|
|
88
93
|
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
89
94
|
}
|
|
90
95
|
if (options.help) return options;
|
|
@@ -155,14 +160,18 @@ export function cleanupWorktree({ repoRoot, issue, pr, path: explicitPath }, { g
|
|
|
155
160
|
return { ok: true, removed: target, reason: "removed" };
|
|
156
161
|
}
|
|
157
162
|
|
|
158
|
-
export function runCli(argv = process.argv.slice(2), { stdout = process.stdout } = {}) {
|
|
163
|
+
export function runCli(argv = process.argv.slice(2), { stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
159
164
|
const options = parseCleanupWorktreeCliArgs(argv);
|
|
160
165
|
if (options.help) {
|
|
161
166
|
stdout.write(`${USAGE}\n`);
|
|
162
167
|
return;
|
|
163
168
|
}
|
|
164
169
|
const result = cleanupWorktree(options);
|
|
165
|
-
|
|
170
|
+
// FAIL-SOFT contract (see file header): a parsed command always exits 0 on the
|
|
171
|
+
// non-jq path, even when `ok:false` (path refused) — it must never break a
|
|
172
|
+
// merge-completion caller. Force ok:true here so the default path keeps that
|
|
173
|
+
// contract; --jq/--silent can still read the real `ok` field explicitly.
|
|
174
|
+
process.exitCode = emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr, ok: true });
|
|
166
175
|
}
|
|
167
176
|
|
|
168
177
|
if (isDirectCliRun(import.meta.url)) {
|
|
@@ -4,7 +4,7 @@ import { access, open, readFile, readdir } from "node:fs/promises";
|
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { requireTokenValue } from "../_cli-primitives.mjs";
|
|
7
|
-
import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
|
|
7
|
+
import { buildParseError, formatCliError, isDirectCliRun, parseJsonText, readJsonIfExists } from "../_core-helpers.mjs";
|
|
8
8
|
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
9
9
|
import { autoDetectSnapshot } from "./detect-copilot-loop-state.mjs";
|
|
10
10
|
import {
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
import { interpretLoopState, summarizeLoopInterpretation } from "@dev-loops/core/loop/copilot-loop-state";
|
|
16
16
|
import { listOpenPrs } from "./_loop-pr-aggregation.mjs";
|
|
17
17
|
import { parseArgs } from "node:util";
|
|
18
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
|
|
18
19
|
const USAGE = `Usage: conductor-monitor.mjs --repo <owner/name> [--auto-resume]
|
|
19
20
|
Aggregate Copilot-loop status across all open PRs in one repo.
|
|
20
21
|
Required:
|
|
@@ -78,9 +79,11 @@ Error output (stderr, JSON):
|
|
|
78
79
|
{ "ok": false, "error": "...", "usage": "..." }
|
|
79
80
|
gh/runtime failures:
|
|
80
81
|
{ "ok": false, "error": "..." }
|
|
82
|
+
${JQ_OUTPUT_USAGE}
|
|
81
83
|
Exit codes:
|
|
82
84
|
0 Success
|
|
83
|
-
1 Argument error, gh failure, or indeterminate PR status
|
|
85
|
+
1 Argument error, gh failure, or indeterminate PR status
|
|
86
|
+
2 Invalid --jq filter`.trim();
|
|
84
87
|
const parseError = buildParseError(USAGE);
|
|
85
88
|
const DEFAULT_SESSION_ROOT = path.join(os.homedir(), ".pi", "agent", "sessions");
|
|
86
89
|
const RUN_STATE = {
|
|
@@ -125,6 +128,7 @@ function parseCliArgs(argv) {
|
|
|
125
128
|
help: { type: "boolean", short: "h" },
|
|
126
129
|
repo: { type: "string" },
|
|
127
130
|
"auto-resume": { type: "boolean" },
|
|
131
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
128
132
|
},
|
|
129
133
|
allowPositionals: true,
|
|
130
134
|
strict: false,
|
|
@@ -149,6 +153,7 @@ function parseCliArgs(argv) {
|
|
|
149
153
|
options.autoResume = true;
|
|
150
154
|
continue;
|
|
151
155
|
}
|
|
156
|
+
if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
|
|
152
157
|
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
153
158
|
}
|
|
154
159
|
if (options.repo === undefined) {
|
|
@@ -306,17 +311,6 @@ async function readFirstLineIfExists(filePath, chunkSize = 4096) {
|
|
|
306
311
|
await handle?.close().catch(() => {});
|
|
307
312
|
}
|
|
308
313
|
}
|
|
309
|
-
async function readJsonIfExists(filePath) {
|
|
310
|
-
const text = await readTextIfExists(filePath);
|
|
311
|
-
if (text === null) {
|
|
312
|
-
return null;
|
|
313
|
-
}
|
|
314
|
-
try {
|
|
315
|
-
return parseJsonText(text);
|
|
316
|
-
} catch {
|
|
317
|
-
return null;
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
314
|
function normalizeRunState(value) {
|
|
321
315
|
const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
322
316
|
switch (normalized) {
|
|
@@ -455,7 +449,7 @@ async function scanSessionArtifactRoot(artifactsDir, records) {
|
|
|
455
449
|
const record = records.get(key) ?? createRunRecord(runId, childIndex);
|
|
456
450
|
const filePath = path.join(artifactsDir, entry.name);
|
|
457
451
|
if (entry.name.endsWith("_meta.json")) {
|
|
458
|
-
const meta = await readJsonIfExists(filePath);
|
|
452
|
+
const meta = await readJsonIfExists(filePath).catch(() => null);
|
|
459
453
|
if (meta && typeof meta === "object") {
|
|
460
454
|
records.set(key, mergeRunRecord(record, {
|
|
461
455
|
agent: typeof meta.agent === "string" ? meta.agent : (record.agent ?? agent),
|
|
@@ -540,7 +534,7 @@ async function scanAsyncRunRoot(asyncRoot, records) {
|
|
|
540
534
|
const asyncDir = path.join(asyncRoot, runDirEntry.name);
|
|
541
535
|
const statusPath = path.join(asyncDir, "status.json");
|
|
542
536
|
const eventsPath = path.join(asyncDir, "events.jsonl");
|
|
543
|
-
const status = await readJsonIfExists(statusPath);
|
|
537
|
+
const status = await readJsonIfExists(statusPath).catch(() => null);
|
|
544
538
|
if (!status || typeof status !== "object") {
|
|
545
539
|
continue;
|
|
546
540
|
}
|
|
@@ -632,7 +626,7 @@ async function scanAsyncResultRoot(resultsRoot, records) {
|
|
|
632
626
|
}
|
|
633
627
|
const filePath = path.join(resultsRoot, entry.name);
|
|
634
628
|
if (entry.name.endsWith(".json")) {
|
|
635
|
-
const result = await readJsonIfExists(filePath);
|
|
629
|
+
const result = await readJsonIfExists(filePath).catch(() => null);
|
|
636
630
|
if (!result || typeof result !== "object") {
|
|
637
631
|
continue;
|
|
638
632
|
}
|
|
@@ -1807,7 +1801,7 @@ export async function runConductorMonitor(
|
|
|
1807
1801
|
}
|
|
1808
1802
|
export async function runCli(
|
|
1809
1803
|
argv = process.argv.slice(2),
|
|
1810
|
-
{ stdout = process.stdout, env = process.env, ghCommand = "gh", cwd = process.cwd() } = {},
|
|
1804
|
+
{ stdout = process.stdout, stderr = process.stderr, env = process.env, ghCommand = "gh", cwd = process.cwd() } = {},
|
|
1811
1805
|
) {
|
|
1812
1806
|
const options = parseCliArgs(argv);
|
|
1813
1807
|
if (options.help) {
|
|
@@ -1819,7 +1813,7 @@ export async function runCli(
|
|
|
1819
1813
|
ghCommand,
|
|
1820
1814
|
repoRoot: cwd,
|
|
1821
1815
|
});
|
|
1822
|
-
|
|
1816
|
+
process.exitCode = emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
|
|
1823
1817
|
}
|
|
1824
1818
|
if (isDirectCliRun(import.meta.url)) {
|
|
1825
1819
|
runCli().catch((error) => {
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { buildParseError, formatCliError, isCopilotLogin, isDirectCliRun, normalizeTimestamp } from "../_core-helpers.mjs";
|
|
2
|
+
import { buildParseError, formatCliError, isCopilotLogin, isDirectCliRun, normalizeTimestamp, parseJsonText } from "../_core-helpers.mjs";
|
|
3
3
|
import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
|
|
4
|
+
import { detectPostConvergenceSignificantChange } from "./_post-convergence-change.mjs";
|
|
4
5
|
import { detectRepoSlug, parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
5
6
|
import { resolveRunId } from "@dev-loops/core/loop/run-context";
|
|
6
|
-
import
|
|
7
|
-
import { loadDevLoopConfig, resolveRefinement } from "@dev-loops/core/config";
|
|
7
|
+
import { loadDevLoopConfig, resolveEffectiveCopilotRoundCap, resolveRefinement } from "@dev-loops/core/config";
|
|
8
8
|
import { autoDetectSnapshot } from "./detect-copilot-loop-state.mjs";
|
|
9
9
|
import { performCopilotReviewRequest } from "../github/request-copilot-review.mjs";
|
|
10
10
|
import { detectInternalOnly as detectPrInternalOnly } from "./detect-internal-only-pr.mjs";
|
|
11
11
|
import { applyConfirmedReviewRequest, interpretLoopState, NEXT_ACTIONS, STATE, summarizeLoopInterpretation, TRANSITIONS } from "@dev-loops/core/loop/copilot-loop-state";
|
|
12
|
-
import {
|
|
12
|
+
import { PR_LIFECYCLE_STATE } from "@dev-loops/core/loop/pr-lifecycle";
|
|
13
|
+
import { ensureAsyncRunnerOwnership, releaseAsyncRunnerOwnership } from "./_pr-runner-coordination.mjs";
|
|
14
|
+
import { resolveRepoRoot } from "./_repo-root-resolver.mjs";
|
|
13
15
|
|
|
14
16
|
|
|
15
17
|
import {
|
|
@@ -17,7 +19,7 @@ import {
|
|
|
17
19
|
enforceExternalHealthyWaitTimeout,
|
|
18
20
|
} from "@dev-loops/core/loop/timeout-policy";
|
|
19
21
|
import { parseArgs } from "node:util";
|
|
20
|
-
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
|
|
22
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
|
|
21
23
|
import {
|
|
22
24
|
DEFAULT_POLL_INTERVAL_MS,
|
|
23
25
|
COPILOT_REVIEW_WAIT_TIMEOUT_MS,
|
|
@@ -37,6 +39,13 @@ Optional:
|
|
|
37
39
|
--watch-status <status> Refresh deterministic loop state after a prior
|
|
38
40
|
watcher result (changed|timeout|idle). This mode
|
|
39
41
|
never requests review; it only re-detects state.
|
|
42
|
+
--lightweight This PR is light-dispatched (#1210): compose the
|
|
43
|
+
Copilot round cap with localImplementation.lightMode.
|
|
44
|
+
maxCopilotRounds (default 1) via
|
|
45
|
+
min(lightMode.maxCopilotRounds, refinement.maxCopilotRounds)
|
|
46
|
+
instead of using refinement.maxCopilotRounds alone.
|
|
47
|
+
refinement.maxCopilotRounds: 0 still disables Copilot
|
|
48
|
+
rounds even with --lightweight.
|
|
40
49
|
Output (stdout, JSON):
|
|
41
50
|
{ "ok": true, "action": "watch"|"fix"|"stop", "state": "...",
|
|
42
51
|
"allowedTransitions": [...], "nextAction": "...", "snapshot": {...},
|
|
@@ -99,7 +108,7 @@ function summarizeRequestWatchContract({
|
|
|
99
108
|
|| interpretation.state === STATE.READY_TO_REREQUEST_REVIEW
|
|
100
109
|
&& interpretation.sameHeadCleanConverged !== true
|
|
101
110
|
) {
|
|
102
|
-
routingState =
|
|
111
|
+
routingState = PR_LIFECYCLE_STATE.READY_STATE_NEEDS_COPILOT_REQUEST;
|
|
103
112
|
} else if (interpretation.state === STATE.INTERNAL_TOOLING_DIRECT_GATE) {
|
|
104
113
|
routingState = "internal_tooling_skip_copilot";
|
|
105
114
|
}
|
|
@@ -137,6 +146,7 @@ export function parseHandoffCliArgs(argv, { cwd = process.cwd() } = {}) {
|
|
|
137
146
|
repo: undefined,
|
|
138
147
|
pr: undefined,
|
|
139
148
|
watchStatus: undefined,
|
|
149
|
+
lightweight: false,
|
|
140
150
|
jq: undefined,
|
|
141
151
|
silent: false,
|
|
142
152
|
};
|
|
@@ -147,6 +157,7 @@ export function parseHandoffCliArgs(argv, { cwd = process.cwd() } = {}) {
|
|
|
147
157
|
repo: { type: "string" },
|
|
148
158
|
pr: { type: "string" },
|
|
149
159
|
"watch-status": { type: "string" },
|
|
160
|
+
lightweight: { type: "boolean" },
|
|
150
161
|
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
151
162
|
},
|
|
152
163
|
allowPositionals: true,
|
|
@@ -183,14 +194,11 @@ export function parseHandoffCliArgs(argv, { cwd = process.cwd() } = {}) {
|
|
|
183
194
|
options.watchStatus = watchStatus;
|
|
184
195
|
continue;
|
|
185
196
|
}
|
|
186
|
-
if (token.name === "
|
|
187
|
-
options.
|
|
188
|
-
continue;
|
|
189
|
-
}
|
|
190
|
-
if (token.name === "silent") {
|
|
191
|
-
options.silent = true;
|
|
197
|
+
if (token.name === "lightweight") {
|
|
198
|
+
options.lightweight = true;
|
|
192
199
|
continue;
|
|
193
200
|
}
|
|
201
|
+
if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
|
|
194
202
|
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
195
203
|
}
|
|
196
204
|
if (options.pr === undefined) {
|
|
@@ -300,12 +308,32 @@ export async function detectRecentHumanComments({ repo, pr, claimedAtMs }, { env
|
|
|
300
308
|
}
|
|
301
309
|
}
|
|
302
310
|
|
|
311
|
+
// Facts needed by the round-cap escape-hatch significant-change detector
|
|
312
|
+
// (#1103, #1126): the current head, the Copilot reviews (to find the last
|
|
313
|
+
// reviewed head), and the PR's changed files. Fetched only when the interpreter
|
|
314
|
+
// already resolved ROUND_CAP_CLEAN_FALLBACK, so this extra call is off the hot path.
|
|
315
|
+
async function fetchReopenCycleFacts({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
|
|
316
|
+
const result = await runChild(
|
|
317
|
+
ghCommand,
|
|
318
|
+
["pr", "view", String(pr), "--repo", repo, "--json", "headRefOid,reviews,files"],
|
|
319
|
+
env,
|
|
320
|
+
);
|
|
321
|
+
if (result.code !== 0) {
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
try {
|
|
325
|
+
return parseJsonText(result.stdout, { label: "gh pr view reopen-cycle facts" });
|
|
326
|
+
} catch {
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
303
331
|
export async function runHandoff(options, { env = process.env, ghCommand = "gh" } = {}) {
|
|
304
332
|
const runnerOwnership = await ensureAsyncRunnerOwnership({
|
|
305
333
|
repo: options.repo,
|
|
306
334
|
pr: options.pr,
|
|
307
335
|
env,
|
|
308
|
-
cwd:
|
|
336
|
+
cwd: resolveRepoRoot(process.cwd()),
|
|
309
337
|
claimIfMissing: true,
|
|
310
338
|
});
|
|
311
339
|
if (!runnerOwnership.ok) {
|
|
@@ -337,13 +365,22 @@ export async function runHandoff(options, { env = process.env, ghCommand = "gh"
|
|
|
337
365
|
{ repo: options.repo, pr: options.pr },
|
|
338
366
|
{ env, ghCommand },
|
|
339
367
|
);
|
|
340
|
-
const config = await loadDevLoopConfig({ repoRoot:
|
|
368
|
+
const config = await loadDevLoopConfig({ repoRoot: resolveRepoRoot(process.cwd()) });
|
|
341
369
|
if (config.errors?.length > 0) {
|
|
342
370
|
console.error("[copilot-pr-handoff] config warnings:", JSON.stringify(config.errors));
|
|
343
371
|
}
|
|
344
372
|
const refinementConfig = config.errors?.length > 0
|
|
345
373
|
? resolveRefinement({ version: 1 })
|
|
346
374
|
: resolveRefinement(config.config);
|
|
375
|
+
if (options.lightweight) {
|
|
376
|
+
// Compose (not replace) the round cap for light-dispatched PRs (#1210):
|
|
377
|
+
// min(lightMode.maxCopilotRounds ?? 1, refinement.maxCopilotRounds), so
|
|
378
|
+
// maxCopilotRounds: 0 still disables Copilot rounds everywhere.
|
|
379
|
+
refinementConfig.maxCopilotRounds = resolveEffectiveCopilotRoundCap(
|
|
380
|
+
config.errors?.length > 0 ? { version: 1 } : config.config,
|
|
381
|
+
{ lightweight: true },
|
|
382
|
+
);
|
|
383
|
+
}
|
|
347
384
|
let interpretation = interpretLoopState(snapshot, refinementConfig);
|
|
348
385
|
|
|
349
386
|
// Check for human comments since last subagent action
|
|
@@ -358,6 +395,12 @@ export async function runHandoff(options, { env = process.env, ghCommand = "gh"
|
|
|
358
395
|
const TERMINAL_STATES = new Set([STATE.NO_PR, STATE.DONE, STATE.BLOCKED_NEEDS_USER_DECISION]);
|
|
359
396
|
const humanCommentUnavailable = humanCommentCheck.error && !humanCommentCheck.paused;
|
|
360
397
|
if ((humanCommentCheck.paused || humanCommentUnavailable) && !TERMINAL_STATES.has(interpretation.state)) {
|
|
398
|
+
const runnerRelease = await releaseAsyncRunnerOwnership({
|
|
399
|
+
repo: options.repo,
|
|
400
|
+
pr: options.pr,
|
|
401
|
+
env,
|
|
402
|
+
cwd: resolveRepoRoot(process.cwd()),
|
|
403
|
+
});
|
|
361
404
|
return {
|
|
362
405
|
ok: true,
|
|
363
406
|
action: "stop",
|
|
@@ -373,6 +416,7 @@ export async function runHandoff(options, { env = process.env, ghCommand = "gh"
|
|
|
373
416
|
terminal: true,
|
|
374
417
|
snapshot,
|
|
375
418
|
runnerOwnership,
|
|
419
|
+
...(runnerRelease.status !== "skipped_no_async_run_id" ? { runnerRelease } : {}),
|
|
376
420
|
humanCommentPause: {
|
|
377
421
|
reason: humanCommentCheck.paused ? "human_comment_detected" : "human_comment_check_unavailable",
|
|
378
422
|
error: humanCommentCheck.error || undefined,
|
|
@@ -438,6 +482,76 @@ export async function runHandoff(options, { env = process.env, ghCommand = "gh"
|
|
|
438
482
|
}
|
|
439
483
|
}
|
|
440
484
|
|
|
485
|
+
// In-flight-rerequest race (#1165): the interpreter routes to
|
|
486
|
+
// ROUND_CAP_CLEAN_FALLBACK even when a Copilot review is REQUESTED and pending
|
|
487
|
+
// on the current head, because at the cap it treats any assignment as a stale
|
|
488
|
+
// leftover (copilot-loop-state.mjs). An in-flight request is usually a fresh
|
|
489
|
+
// review genuinely coming (e.g. a force-rerequest for a significant
|
|
490
|
+
// post-convergence change) — but it can also be a stale at-cap assignment
|
|
491
|
+
// mislabeled as requested, as detect-copilot-loop-state.mjs's own fallback
|
|
492
|
+
// notes. Proceeding to pre_approval_gate on a real in-flight review would skip
|
|
493
|
+
// it — the exact gate-integrity gap #1126 closes — and
|
|
494
|
+
// detect-pr-gate-coordination-state gates pre-approval here. The reopen escape
|
|
495
|
+
// hatch below can only recover the wait verdict via a SECOND, fail-silent gh
|
|
496
|
+
// fetch (fetchReopenCycleFacts) whose failure/compare-miss silently drops back
|
|
497
|
+
// to "proceed", diverging from detect (which reused already-validated facts).
|
|
498
|
+
// Fail closed instead: while the in-flight evidence stands, wait for it to
|
|
499
|
+
// resolve rather than stop. If it never lands, a --watch-status refresh
|
|
500
|
+
// (which skips this branch) re-resolves to the clean fallback, so this never
|
|
501
|
+
// dead-ends the loop.
|
|
502
|
+
const reviewRequestInFlight = snapshot.copilotReviewRequestStatus === "requested"
|
|
503
|
+
|| snapshot.copilotReviewRequestStatus === "already-requested";
|
|
504
|
+
if (!internalOnlySkipCopilot
|
|
505
|
+
&& options.watchStatus === undefined
|
|
506
|
+
&& interpretation.state === STATE.ROUND_CAP_CLEAN_FALLBACK
|
|
507
|
+
&& reviewRequestInFlight) {
|
|
508
|
+
interpretation = {
|
|
509
|
+
...interpretation,
|
|
510
|
+
state: STATE.WAITING_FOR_COPILOT_REVIEW,
|
|
511
|
+
nextAction: NEXT_ACTIONS[STATE.WAITING_FOR_COPILOT_REVIEW],
|
|
512
|
+
allowedTransitions: [...(TRANSITIONS[STATE.WAITING_FOR_COPILOT_REVIEW] || [])],
|
|
513
|
+
roundCapCleanEligible: false,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Round-cap escape hatch (#1103, #1126): the interpreter resolves
|
|
518
|
+
// ROUND_CAP_CLEAN_FALLBACK (stop, no re-request) at the cap. But when a
|
|
519
|
+
// SIGNIFICANT post-convergence change (new product/test-logic since the last
|
|
520
|
+
// Copilot review — not doc/comment-only) has landed, a new Copilot cycle is
|
|
521
|
+
// owed. Reopen it here via the SAME shared detector detect-pr-gate-coordination-state
|
|
522
|
+
// uses, so the two agree: both offer a re-request iff (cap reached AND
|
|
523
|
+
// significant post-convergence change). A doc-only change stays at the clean
|
|
524
|
+
// fallback (stop), unchanged.
|
|
525
|
+
let reopenedCapCycle = false;
|
|
526
|
+
if (!internalOnlySkipCopilot
|
|
527
|
+
&& options.watchStatus === undefined
|
|
528
|
+
&& interpretation.state === STATE.ROUND_CAP_CLEAN_FALLBACK) {
|
|
529
|
+
const reopenFacts = await fetchReopenCycleFacts(options, { env, ghCommand });
|
|
530
|
+
const significant = await detectPostConvergenceSignificantChange(
|
|
531
|
+
{
|
|
532
|
+
repo: options.repo,
|
|
533
|
+
pr: options.pr,
|
|
534
|
+
currentHeadSha: typeof reopenFacts?.headRefOid === "string" ? reopenFacts.headRefOid.trim() : null,
|
|
535
|
+
reviews: reopenFacts?.reviews,
|
|
536
|
+
changedFiles: reopenFacts?.files,
|
|
537
|
+
roundCapReached: true,
|
|
538
|
+
regularCopilotRounds: (snapshot.copilotReviewRoundCount ?? 0) > 0,
|
|
539
|
+
},
|
|
540
|
+
{ env, ghCommand },
|
|
541
|
+
);
|
|
542
|
+
if (significant) {
|
|
543
|
+
reopenedCapCycle = true;
|
|
544
|
+
interpretation = {
|
|
545
|
+
...interpretation,
|
|
546
|
+
state: STATE.READY_TO_REREQUEST_REVIEW,
|
|
547
|
+
nextAction: NEXT_ACTIONS[STATE.READY_TO_REREQUEST_REVIEW],
|
|
548
|
+
allowedTransitions: [...(TRANSITIONS[STATE.READY_TO_REREQUEST_REVIEW] || [])],
|
|
549
|
+
autoRerequestEligible: true,
|
|
550
|
+
roundCapCleanEligible: false,
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
441
555
|
let reviewRequestStatus;
|
|
442
556
|
const shouldRequestReview = !internalOnlySkipCopilot && options.watchStatus === undefined
|
|
443
557
|
&& (interpretation.state === STATE.PR_READY_NO_FEEDBACK
|
|
@@ -449,12 +563,35 @@ export async function runHandoff(options, { env = process.env, ghCommand = "gh"
|
|
|
449
563
|
repo: options.repo,
|
|
450
564
|
pr: options.pr,
|
|
451
565
|
sameHeadCleanConverged: interpretation.sameHeadCleanConverged,
|
|
566
|
+
// A reopened cap cycle was decided via the shared significant-change
|
|
567
|
+
// detector; tell the requester to honor it. performCopilotReviewRequest
|
|
568
|
+
// still refuses unless the head actually advanced past the last review
|
|
569
|
+
// (its hasNewCommits guard), so this cannot force an over-cap same-head request.
|
|
570
|
+
forceRerequestReview: reopenedCapCycle,
|
|
571
|
+
// Thread the light-dispatch signal so the requester enforces the same
|
|
572
|
+
// composed round cap this handoff resolved (#1210) — the enforcement
|
|
573
|
+
// backstop must never permit rounds the interpreter already forbids.
|
|
574
|
+
lightweight: options.lightweight,
|
|
452
575
|
},
|
|
453
576
|
{ env, ghCommand },
|
|
454
577
|
);
|
|
455
578
|
reviewRequestStatus = requestResult.status;
|
|
456
579
|
snapshot = applyConfirmedReviewRequest(snapshot, reviewRequestStatus);
|
|
457
580
|
interpretation = interpretLoopState(snapshot, refinementConfig);
|
|
581
|
+
// The re-interpretation re-hits the round cap (rounds still >= max) and would
|
|
582
|
+
// flip a reopened cycle back to ROUND_CAP_CLEAN_FALLBACK. A confirmed request
|
|
583
|
+
// for a significant new change is a genuine new wait cycle, so map it to the
|
|
584
|
+
// honest WAITING_FOR_COPILOT_REVIEW state (what a below-cap re-request yields).
|
|
585
|
+
if (reopenedCapCycle
|
|
586
|
+
&& (reviewRequestStatus === "requested" || reviewRequestStatus === "already-requested")) {
|
|
587
|
+
interpretation = {
|
|
588
|
+
...interpretation,
|
|
589
|
+
state: STATE.WAITING_FOR_COPILOT_REVIEW,
|
|
590
|
+
nextAction: NEXT_ACTIONS[STATE.WAITING_FOR_COPILOT_REVIEW],
|
|
591
|
+
allowedTransitions: [...(TRANSITIONS[STATE.WAITING_FOR_COPILOT_REVIEW] || [])],
|
|
592
|
+
roundCapCleanEligible: false,
|
|
593
|
+
};
|
|
594
|
+
}
|
|
458
595
|
}
|
|
459
596
|
const interpretationSummary = summarizeLoopInterpretation(interpretation, refinementConfig);
|
|
460
597
|
const effectiveReviewRequestStatus = reviewRequestStatus
|
|
@@ -517,6 +654,17 @@ export async function runHandoff(options, { env = process.env, ghCommand = "gh"
|
|
|
517
654
|
requestStatus: normalizedRequestStatus,
|
|
518
655
|
watchArgs: result.watchArgs,
|
|
519
656
|
});
|
|
657
|
+
if (result.terminal === true) {
|
|
658
|
+
const runnerRelease = await releaseAsyncRunnerOwnership({
|
|
659
|
+
repo: options.repo,
|
|
660
|
+
pr: options.pr,
|
|
661
|
+
env,
|
|
662
|
+
cwd: resolveRepoRoot(process.cwd()),
|
|
663
|
+
});
|
|
664
|
+
if (runnerRelease.status !== "skipped_no_async_run_id") {
|
|
665
|
+
result.runnerRelease = runnerRelease;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
520
668
|
return result;
|
|
521
669
|
}
|
|
522
670
|
export async function runCli(
|
|
@@ -24,6 +24,7 @@ import { DebtSignalSchema } from "@dev-loops/core/debt/signal";
|
|
|
24
24
|
import { clusterSignalsEnriched } from "@dev-loops/core/debt/cluster";
|
|
25
25
|
import { shapeFindings } from "@dev-loops/core/debt/shape";
|
|
26
26
|
import { createRemediationIssue } from "@dev-loops/core/debt/remediation-to-issue";
|
|
27
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
|
|
27
28
|
|
|
28
29
|
const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url));
|
|
29
30
|
|
|
@@ -45,9 +46,12 @@ Optional:
|
|
|
45
46
|
Output (stdout, JSON):
|
|
46
47
|
{ "ok": true, "signals": N, "findings": N, "remediationItems": N, "issues": [...], "summary": "..." }
|
|
47
48
|
|
|
49
|
+
${JQ_OUTPUT_USAGE}
|
|
50
|
+
|
|
48
51
|
Exit codes:
|
|
49
52
|
0 Success (all remediation issue creations succeeded)
|
|
50
|
-
1 Argument error, input validation failure, or issue creation failure
|
|
53
|
+
1 Argument error, input validation failure, or issue creation failure
|
|
54
|
+
2 Invalid --jq filter`.trim();
|
|
51
55
|
|
|
52
56
|
const parseError = buildParseError(USAGE);
|
|
53
57
|
|
|
@@ -160,6 +164,7 @@ export async function runCli(argv) {
|
|
|
160
164
|
input: { type: "string" },
|
|
161
165
|
repo: { type: "string" },
|
|
162
166
|
"dry-run": { type: "boolean" },
|
|
167
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
163
168
|
},
|
|
164
169
|
allowPositionals: true,
|
|
165
170
|
strict: false,
|
|
@@ -188,6 +193,7 @@ export async function runCli(argv) {
|
|
|
188
193
|
options.dryRun = true;
|
|
189
194
|
continue;
|
|
190
195
|
}
|
|
196
|
+
if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
|
|
191
197
|
throw parseError(`Unknown flag: ${token.rawName}`);
|
|
192
198
|
}
|
|
193
199
|
|
|
@@ -196,6 +202,14 @@ export async function runCli(argv) {
|
|
|
196
202
|
return { exitCode: 0 };
|
|
197
203
|
}
|
|
198
204
|
|
|
205
|
+
// Every early-exit failure below reports via the shared jq/silent contract on
|
|
206
|
+
// stderr (this script's existing error-output convention); `ok:false` maps to
|
|
207
|
+
// the existing exit 1 by default, and an invalid --jq filter still fails
|
|
208
|
+
// closed with exit 2 instead of being swallowed by this fixed exit code.
|
|
209
|
+
const failEarly = (payload) => ({
|
|
210
|
+
exitCode: emitResult(payload, { jq: options.jq, silent: options.silent, stdout: process.stderr, stderr: process.stderr }),
|
|
211
|
+
});
|
|
212
|
+
|
|
199
213
|
if (!options.input) {
|
|
200
214
|
throw parseError("Missing required flag: --input <path>");
|
|
201
215
|
}
|
|
@@ -208,20 +222,20 @@ export async function runCli(argv) {
|
|
|
208
222
|
try {
|
|
209
223
|
rawInput = await readFile(inputPath, "utf-8");
|
|
210
224
|
} catch (err) {
|
|
211
|
-
return {
|
|
225
|
+
return failEarly({ ok: false, error: `Cannot read input file: ${inputPath}`, detail: err.message });
|
|
212
226
|
}
|
|
213
227
|
|
|
214
228
|
let signals;
|
|
215
229
|
try {
|
|
216
230
|
signals = JSON.parse(rawInput);
|
|
217
231
|
} catch (err) {
|
|
218
|
-
return {
|
|
232
|
+
return failEarly({ ok: false, error: "Input file is not valid JSON", detail: err.message });
|
|
219
233
|
}
|
|
220
234
|
|
|
221
235
|
// Validate signals
|
|
222
236
|
const validation = validateSignals(signals);
|
|
223
237
|
if (!validation.ok) {
|
|
224
|
-
return
|
|
238
|
+
return failEarly(validation);
|
|
225
239
|
}
|
|
226
240
|
|
|
227
241
|
// Resolve repo
|
|
@@ -230,14 +244,14 @@ export async function runCli(argv) {
|
|
|
230
244
|
try {
|
|
231
245
|
repo = parseRepoSlug(options.repo);
|
|
232
246
|
} catch {
|
|
233
|
-
return {
|
|
247
|
+
return failEarly({ ok: false, error: `Invalid repo slug: ${options.repo}` });
|
|
234
248
|
}
|
|
235
249
|
} else {
|
|
236
250
|
repo = detectRepo();
|
|
237
251
|
}
|
|
238
252
|
|
|
239
253
|
if (!repo) {
|
|
240
|
-
return {
|
|
254
|
+
return failEarly({ ok: false, error: "Cannot detect repository. Pass --repo <owner/name>." });
|
|
241
255
|
}
|
|
242
256
|
|
|
243
257
|
// Run pipeline: cluster → score → shape
|
|
@@ -298,8 +312,9 @@ export async function runCli(argv) {
|
|
|
298
312
|
report.repo = `${repo.owner}/${repo.name}`;
|
|
299
313
|
|
|
300
314
|
const outputTarget = report.ok ? process.stdout : process.stderr;
|
|
301
|
-
|
|
302
|
-
|
|
315
|
+
return {
|
|
316
|
+
exitCode: emitResult(report, { jq: options.jq, silent: options.silent, stdout: outputTarget, stderr: process.stderr }),
|
|
317
|
+
};
|
|
303
318
|
}
|
|
304
319
|
|
|
305
320
|
// ============================================================================
|
|
@@ -307,10 +322,7 @@ export async function runCli(argv) {
|
|
|
307
322
|
// ============================================================================
|
|
308
323
|
|
|
309
324
|
if (isDirectCliRun(import.meta.url)) {
|
|
310
|
-
runCli(process.argv.slice(2)).then(({ exitCode
|
|
311
|
-
if (output) {
|
|
312
|
-
process.stderr.write(JSON.stringify(output) + "\n");
|
|
313
|
-
}
|
|
325
|
+
runCli(process.argv.slice(2)).then(({ exitCode }) => {
|
|
314
326
|
process.exitCode = exitCode;
|
|
315
327
|
}).catch((err) => {
|
|
316
328
|
process.stderr.write(`${formatCliError(err)}\n`);
|