dev-loops 0.5.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/loop-auto.md +7 -0
- 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/loop-info.md +7 -0
- package/.claude/commands/loop-queue-status.md +24 -0
- package/.claude/commands/loop-start-spike.md +16 -0
- package/.claude/commands/loop-start.md +7 -0
- package/.claude/commands/loop-status.md +6 -0
- package/.claude/hooks/_bash-command-classify.mjs +333 -29
- package/.claude/hooks/_hook-decisions.mjs +138 -15
- package/.claude/hooks/_run-context.mjs +11 -4
- 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 +19 -10
- 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/release-runbook.md +45 -0
- package/.claude/skills/docs/retrospective-checkpoint-contract.md +90 -76
- package/.claude/skills/docs/ui-e2e-scoping-step.md +134 -0
- package/.claude/skills/docs/workflow-handoff-contract.md +39 -0
- package/.claude/skills/local-implementation/SKILL.md +26 -15
- package/.claude/skills/loop-grill/SKILL.md +163 -0
- package/AGENTS.md +1 -0
- package/CHANGELOG.md +125 -0
- package/README.md +29 -10
- package/agents/dev-loop.agent.md +11 -3
- 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 -40
- package/extension/README.md +4 -4
- package/extension/checks.ts +1 -5
- package/extension/harness-types.ts +1 -0
- package/extension/index.ts +16 -1
- package/extension/pi-extension-adapter.ts +2 -0
- package/extension/presentation.ts +16 -13
- package/lib/dev-loops-core.mjs +141 -0
- package/package.json +10 -8
- package/scripts/claude/generate-claude-assets.mjs +15 -1
- package/scripts/github/capture-review-threads.mjs +2 -9
- package/scripts/github/comment-issue.mjs +174 -0
- 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 +208 -0
- package/scripts/github/list-issues.mjs +184 -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 +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/_handoff-contract.mjs +1 -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 +179 -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 +13 -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 +255 -0
- 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 +179 -0
- 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 +125 -0
- package/skills/copilot-pr-followup/SKILL.md +24 -12
- package/skills/dev-loop/SKILL.md +22 -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/release-runbook.md +45 -0
- package/skills/docs/retrospective-checkpoint-contract.md +90 -76
- package/skills/docs/ui-e2e-scoping-step.md +134 -0
- package/skills/docs/workflow-handoff-contract.md +39 -0
- package/skills/local-implementation/SKILL.md +26 -15
- package/skills/loop-grill/SKILL.md +165 -0
- 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
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// Single source of truth (#1103, #1126) for "did a significant product/test-logic
|
|
2
|
+
// change land since the last Copilot review at the round cap". Both
|
|
3
|
+
// detect-pr-gate-coordination-state.mjs and copilot-pr-handoff.mjs consume this
|
|
4
|
+
// so they agree on the round-cap escape hatch: at the cap, a significant
|
|
5
|
+
// post-convergence change reopens a Copilot cycle (rerequest) instead of the
|
|
6
|
+
// clean fallback; a doc/comment-only change stays at the clean fallback.
|
|
7
|
+
//
|
|
8
|
+
// It lives in scripts/loop/ (not packages/core) because significance is derived
|
|
9
|
+
// from a `gh api .../compare` diff — gh I/O that does not belong in core.
|
|
10
|
+
import { extractReviewCommitSha, isCopilotLogin, parseJsonText } from "../_core-helpers.mjs";
|
|
11
|
+
import { runChild } from "../_cli-primitives.mjs";
|
|
12
|
+
|
|
13
|
+
export function getLatestSubmittedCopilotReviewHeadSha(reviews) {
|
|
14
|
+
const copilotSubmitted = (Array.isArray(reviews) ? reviews : [])
|
|
15
|
+
.filter((review) => {
|
|
16
|
+
const login = review?.author?.login;
|
|
17
|
+
const state = String(review?.state ?? "").toUpperCase();
|
|
18
|
+
return isCopilotLogin(login) && state !== "PENDING";
|
|
19
|
+
})
|
|
20
|
+
.map((review, index) => {
|
|
21
|
+
const submittedAt = review?.submittedAt ?? review?.submitted_at;
|
|
22
|
+
const submittedAtMs = typeof submittedAt === "string" ? Date.parse(submittedAt) : Number.NaN;
|
|
23
|
+
return { review, submittedAtMs, index };
|
|
24
|
+
})
|
|
25
|
+
.sort((left, right) => {
|
|
26
|
+
const leftValid = !Number.isNaN(left.submittedAtMs);
|
|
27
|
+
const rightValid = !Number.isNaN(right.submittedAtMs);
|
|
28
|
+
if (leftValid && rightValid) {
|
|
29
|
+
return right.submittedAtMs - left.submittedAtMs;
|
|
30
|
+
}
|
|
31
|
+
if (leftValid !== rightValid) {
|
|
32
|
+
return leftValid ? -1 : 1;
|
|
33
|
+
}
|
|
34
|
+
return right.index - left.index;
|
|
35
|
+
});
|
|
36
|
+
const latest = copilotSubmitted[0]?.review;
|
|
37
|
+
const sha = extractReviewCommitSha(latest);
|
|
38
|
+
return typeof sha === "string" && sha.trim().length > 0 ? sha.trim() : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function isTrivialDocumentationOnlyPath(filePath) {
|
|
42
|
+
if (typeof filePath !== "string") return true;
|
|
43
|
+
const normalized = filePath.trim().toLowerCase();
|
|
44
|
+
if (normalized.length === 0) return true;
|
|
45
|
+
if (normalized.startsWith("docs/")) return true;
|
|
46
|
+
return normalized.endsWith(".md")
|
|
47
|
+
|| normalized.endsWith(".mdx")
|
|
48
|
+
|| normalized.endsWith(".txt")
|
|
49
|
+
|| normalized.endsWith(".rst")
|
|
50
|
+
|| normalized.endsWith(".adoc");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Extensions whose comment syntax we can classify with confidence (`//`, `/* */`).
|
|
54
|
+
const JS_TS_EXTENSIONS = [".mjs", ".cjs", ".js", ".jsx", ".ts", ".tsx", ".mts", ".cts"];
|
|
55
|
+
|
|
56
|
+
// Classifies one line's content given the current block-comment state.
|
|
57
|
+
// Returns { code, state }: code=true when the line carries anything that is not
|
|
58
|
+
// comment/blank; state = inBlockComment AFTER the line.
|
|
59
|
+
// Known ceilings (both err toward significance or genuinely-trivial cases only):
|
|
60
|
+
// - a line STARTING with `//` or `/*` inside a string literal is misread as a
|
|
61
|
+
// comment — such a line still has to literally begin with the token, which
|
|
62
|
+
// real code lines almost never do;
|
|
63
|
+
// - a `/*` opener sitting AFTER code on the same line (`foo(); /* start`) is
|
|
64
|
+
// not tracked (we return code=true immediately) — follow-up `*` body lines
|
|
65
|
+
// then read as code → significant, the safe direction.
|
|
66
|
+
function classifyLine(content, inBlock) {
|
|
67
|
+
let rest = content;
|
|
68
|
+
let state = inBlock;
|
|
69
|
+
while (true) {
|
|
70
|
+
if (state) {
|
|
71
|
+
const close = rest.indexOf("*/");
|
|
72
|
+
if (close === -1) return { code: false, state: true };
|
|
73
|
+
rest = rest.slice(close + 2);
|
|
74
|
+
state = false;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const trimmed = rest.trim();
|
|
78
|
+
if (trimmed.length === 0) return { code: false, state };
|
|
79
|
+
if (trimmed.startsWith("//")) return { code: false, state };
|
|
80
|
+
if (trimmed.startsWith("/*")) {
|
|
81
|
+
rest = trimmed.slice(2);
|
|
82
|
+
state = true;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
// Anything else is code — including a bare `*`-leading line with NO open
|
|
86
|
+
// block observed: generator signatures (`*items() {`) and operator
|
|
87
|
+
// continuations (`* b`) start with `*` and are real code.
|
|
88
|
+
return { code: true, state };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Content-level significance filter (#1137). A JS/TS file change is "comment-only"
|
|
93
|
+
// when EVERY added/removed line in its patch is blank OR a comment line. Such
|
|
94
|
+
// changes (JSDoc/inline-comment tweaks) must NOT reopen a Copilot round past the
|
|
95
|
+
// cap. Comment rules:
|
|
96
|
+
// - a line starting with `//` is a comment;
|
|
97
|
+
// - `/*` opens block-comment state, `*/` closes it; state is tracked across
|
|
98
|
+
// ALL patch lines (changed AND context) and RESET at every `@@` hunk header
|
|
99
|
+
// — a block opened before the hunk is unobservable, so a `*`-leading line
|
|
100
|
+
// with no observed opener in the same hunk is treated as CODE (conservative:
|
|
101
|
+
// bare `*` counts as comment ONLY inside an observed open `/* ... */` block).
|
|
102
|
+
// Conservative by construction — every ambiguous case returns false (= NOT
|
|
103
|
+
// classifiable as comment-only; the file remains subject to the existing
|
|
104
|
+
// size/count thresholds, it is NOT auto-escalated to significant):
|
|
105
|
+
// - patch missing (binary / oversized to diff) → false
|
|
106
|
+
// - extension we cannot classify (non-JS/TS code) → false
|
|
107
|
+
// - a changed line mixing code + comment → false
|
|
108
|
+
// - a patch with no parseable added/removed lines → false
|
|
109
|
+
// Input contract: GitHub compare `files[].patch` starts at `@@` and never
|
|
110
|
+
// contains `---`/`+++` file headers, so `+`/`-` prefixes are always content
|
|
111
|
+
// (`+++counter;` is the real added line `++counter;`, not a header).
|
|
112
|
+
// Note: line-scanner heuristic, upgrade to a real tokenizer only if string-literal misreads ever bite.
|
|
113
|
+
export function isCommentOnlyFileChange(file) {
|
|
114
|
+
const filename = typeof file?.filename === "string" ? file.filename : "";
|
|
115
|
+
const lower = filename.toLowerCase();
|
|
116
|
+
if (!JS_TS_EXTENSIONS.some((ext) => lower.endsWith(ext))) {
|
|
117
|
+
// Not a classifiable code file (or a doc path handled elsewhere) → treat as code.
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
const patch = file?.patch;
|
|
121
|
+
if (typeof patch !== "string" || patch.length === 0) {
|
|
122
|
+
// No patch (binary / too large) → cannot prove trivial → treat as code.
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
let sawChangedLine = false;
|
|
126
|
+
let inBlock = false;
|
|
127
|
+
for (const line of patch.split("\n")) {
|
|
128
|
+
if (line.startsWith("@@")) {
|
|
129
|
+
inBlock = false; // hunk boundary: the gap is unobserved → reset state
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (line.startsWith("\\")) continue; // ""
|
|
133
|
+
const marker = line[0];
|
|
134
|
+
const content = line.slice(1);
|
|
135
|
+
if (marker === "+" || marker === "-") {
|
|
136
|
+
sawChangedLine = true;
|
|
137
|
+
const { code, state } = classifyLine(content, inBlock);
|
|
138
|
+
if (code) return false;
|
|
139
|
+
inBlock = state;
|
|
140
|
+
} else {
|
|
141
|
+
// Context line: never classified as changed content, but it advances
|
|
142
|
+
// block-comment state so an opener/closer visible in the hunk is honored.
|
|
143
|
+
inBlock = classifyLine(content, inBlock).state;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// A patch with zero parseable changed lines is ambiguous → treat as code.
|
|
147
|
+
return sawChangedLine;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function detectPostConvergenceSignificantChange(
|
|
151
|
+
{ repo, pr, currentHeadSha, reviews, changedFiles, roundCapReached, regularCopilotRounds },
|
|
152
|
+
{ env = process.env, ghCommand = "gh" } = {},
|
|
153
|
+
) {
|
|
154
|
+
if (!roundCapReached || !regularCopilotRounds) {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
if (!Array.isArray(changedFiles) || changedFiles.length === 0) {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
if (!currentHeadSha) {
|
|
161
|
+
// No usable current head → the compare call would be doomed; fail closed
|
|
162
|
+
// without issuing a wasted gh API request.
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
const lastReviewedHeadSha = getLatestSubmittedCopilotReviewHeadSha(reviews);
|
|
166
|
+
if (!lastReviewedHeadSha || lastReviewedHeadSha === currentHeadSha) {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
const compareResult = await runChild(
|
|
170
|
+
ghCommand,
|
|
171
|
+
["api", `repos/${repo}/compare/${lastReviewedHeadSha}...${currentHeadSha}`],
|
|
172
|
+
env,
|
|
173
|
+
);
|
|
174
|
+
if (compareResult.code !== 0) {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
let payload;
|
|
178
|
+
try {
|
|
179
|
+
payload = parseJsonText(compareResult.stdout, { label: "gh compare" });
|
|
180
|
+
} catch {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
const rawFiles = Array.isArray(payload?.files) ? payload.files : [];
|
|
184
|
+
if (rawFiles.length === 0) {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
// Content-aware filter (#1137): drop comment/JSDoc-only JS/TS changes BEFORE the
|
|
188
|
+
// existing size/count thresholds so trivial comment fixes no longer reopen a
|
|
189
|
+
// Copilot round past the cap. Doc paths and un-classifiable files fall through
|
|
190
|
+
// unchanged (isCommentOnlyFileChange returns false for them), preserving the
|
|
191
|
+
// prior threshold behavior for everything else.
|
|
192
|
+
const files = rawFiles.filter((file) => !isCommentOnlyFileChange(file));
|
|
193
|
+
if (files.length === 0) {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
const hasNonDocChanges = files.some((file) => !isTrivialDocumentationOnlyPath(file?.filename));
|
|
197
|
+
if (!hasNonDocChanges) {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
const totalChangedLines = files.reduce((sum, file) => {
|
|
201
|
+
const changes = Number(file?.changes);
|
|
202
|
+
if (Number.isFinite(changes) && changes > 0) {
|
|
203
|
+
return sum + changes;
|
|
204
|
+
}
|
|
205
|
+
const additions = Number(file?.additions);
|
|
206
|
+
const deletions = Number(file?.deletions);
|
|
207
|
+
const fallback = (Number.isFinite(additions) ? additions : 0) + (Number.isFinite(deletions) ? deletions : 0);
|
|
208
|
+
return sum + (fallback > 0 ? fallback : 0);
|
|
209
|
+
}, 0);
|
|
210
|
+
return totalChangedLines >= 20 || files.length >= 2;
|
|
211
|
+
}
|
|
@@ -610,3 +610,73 @@ export async function ensureAsyncRunnerOwnership({
|
|
|
610
610
|
}
|
|
611
611
|
return claimRunnerOwnership({ repo, pr, runId, cwd, mode: "claim" });
|
|
612
612
|
}
|
|
613
|
+
/**
|
|
614
|
+
* Env-aware, best-effort release for the run-completion/stop path (issue #1109).
|
|
615
|
+
*
|
|
616
|
+
* Mirrors {@link ensureAsyncRunnerOwnership}: a no-op when no async run id is
|
|
617
|
+
* present, so it is harness-agnostic — Claude Code with no DEVLOOPS_RUN_ID yields
|
|
618
|
+
* `skipped_no_async_run_id` and never touches the coordination file.
|
|
619
|
+
*
|
|
620
|
+
* Non-fatal by contract: a release conflict (the claim is owned by another run,
|
|
621
|
+
* or no record remains) is swallowed into an `ok:true` result so a failed release
|
|
622
|
+
* can never block a stop/checkpoint. Fail-closed competitor semantics are
|
|
623
|
+
* preserved — {@link releaseRunnerOwnership} only clears a claim THIS run owns, so
|
|
624
|
+
* a genuinely active competing run's claim is left intact.
|
|
625
|
+
*
|
|
626
|
+
* Resolves to one of `skipped_no_async_run_id` (no run id present),
|
|
627
|
+
* `release_skipped` (release conflict swallowed), `release_error` (release threw),
|
|
628
|
+
* or the passthrough `releaseRunnerOwnership` result — `released` when this run's
|
|
629
|
+
* claim was cleared, or `release_noop` when the target has no coordination record.
|
|
630
|
+
*/
|
|
631
|
+
export async function releaseAsyncRunnerOwnership({
|
|
632
|
+
repo,
|
|
633
|
+
pr,
|
|
634
|
+
env = process.env,
|
|
635
|
+
cwd = process.cwd(),
|
|
636
|
+
} = {}) {
|
|
637
|
+
const filePath = defaultRunnerCoordinationFilePathForTarget({ repo, pr }, cwd);
|
|
638
|
+
const runId = normalizeRunId(resolveRunId(env));
|
|
639
|
+
if (runId === null) {
|
|
640
|
+
return {
|
|
641
|
+
ok: true,
|
|
642
|
+
status: "skipped_no_async_run_id",
|
|
643
|
+
repo: normalizeRepoSlug(repo),
|
|
644
|
+
pr: normalizePr(pr),
|
|
645
|
+
runId: null,
|
|
646
|
+
activeRun: null,
|
|
647
|
+
exitSignals: [],
|
|
648
|
+
filePath,
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
try {
|
|
652
|
+
const released = await releaseRunnerOwnership({ repo, pr, runId, cwd });
|
|
653
|
+
if (released.ok) {
|
|
654
|
+
return released;
|
|
655
|
+
}
|
|
656
|
+
return {
|
|
657
|
+
ok: true,
|
|
658
|
+
status: "release_skipped",
|
|
659
|
+
repo: released.repo ?? normalizeRepoSlug(repo),
|
|
660
|
+
pr: released.pr ?? normalizePr(pr),
|
|
661
|
+
runId,
|
|
662
|
+
activeRun: released.activeRun ?? null,
|
|
663
|
+
exitSignals: released.exitSignals ?? [],
|
|
664
|
+
skippedReason: released.error,
|
|
665
|
+
message: released.message,
|
|
666
|
+
filePath: released.filePath ?? filePath,
|
|
667
|
+
};
|
|
668
|
+
} catch (error) {
|
|
669
|
+
return {
|
|
670
|
+
ok: true,
|
|
671
|
+
status: "release_error",
|
|
672
|
+
repo: normalizeRepoSlug(repo),
|
|
673
|
+
pr: normalizePr(pr),
|
|
674
|
+
runId,
|
|
675
|
+
activeRun: null,
|
|
676
|
+
exitSignals: [],
|
|
677
|
+
skippedReason: "release_threw",
|
|
678
|
+
message: error instanceof Error ? error.message : String(error),
|
|
679
|
+
filePath,
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared worktree-relative repo-root / ledger-checkout resolver (issue #1052,
|
|
3
|
+
* folds #1019 and #1050). ONE shared resolver: derive repo root from the
|
|
4
|
+
* checkout under operation (git-toplevel, not ambient cwd) for config reads,
|
|
5
|
+
* and enumerate ALL checkouts (main + every worktree) for ledger reads so a
|
|
6
|
+
* ledger written in any worktree is visible regardless of which checkout runs
|
|
7
|
+
* the check.
|
|
8
|
+
*
|
|
9
|
+
* #1050: the pre-PR-worktree flow means the session cwd is a DIFFERENT checkout
|
|
10
|
+
* than the PR worktree. Ledgers get written cwd-relative in the PR worktree but
|
|
11
|
+
* read cwd-relative from the session checkout, so they diverge and clean gates
|
|
12
|
+
* false-block on "missing pre-merge gate evidence". resolveLedgerCheckouts
|
|
13
|
+
* enumerates every checkout so a ledger written in one is found from any.
|
|
14
|
+
*
|
|
15
|
+
* #1019: .devloops is read at EXACTLY <repoRoot>/.devloops with no upward walk.
|
|
16
|
+
* Reading it from process.cwd() (a subdir or sibling checkout) silently falls
|
|
17
|
+
* back to defaults (maxCopilotRounds -> 5). resolveRepoRoot derives the root
|
|
18
|
+
* from the checkout's git-toplevel so config resolves correctly.
|
|
19
|
+
*/
|
|
20
|
+
import { execFileSync } from "node:child_process";
|
|
21
|
+
import { parseAllWorktreePaths, parseMainWorktreePath } from "@dev-loops/core/loop/worktree-guard";
|
|
22
|
+
|
|
23
|
+
export function resolveRepoRoot(cwd, { gitCommand = "git" } = {}) {
|
|
24
|
+
try {
|
|
25
|
+
return execFileSync(gitCommand, ["rev-parse", "--show-toplevel"], {
|
|
26
|
+
cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
|
|
27
|
+
}).trim() || cwd;
|
|
28
|
+
} catch {
|
|
29
|
+
// git failed or cwd is not inside a git repo (or git is unavailable) -> fall back to cwd.
|
|
30
|
+
return cwd;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function resolveLedgerCheckouts(cwd, { gitCommand = "git" } = {}) {
|
|
35
|
+
const roots = [];
|
|
36
|
+
const add = (p) => { if (typeof p === "string" && p.length > 0 && !roots.includes(p)) roots.push(p); };
|
|
37
|
+
add(resolveRepoRoot(cwd, { gitCommand }));
|
|
38
|
+
try {
|
|
39
|
+
const listing = execFileSync(gitCommand, ["worktree", "list"], {
|
|
40
|
+
cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
|
|
41
|
+
});
|
|
42
|
+
add(parseMainWorktreePath(listing));
|
|
43
|
+
for (const p of parseAllWorktreePaths(listing)) add(p);
|
|
44
|
+
} catch { /* git failed, not inside a git repo, or git unavailable: cwd-toplevel is all we have */ }
|
|
45
|
+
if (roots.length === 0) add(cwd);
|
|
46
|
+
return roots;
|
|
47
|
+
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* dev-loops loop build-envelope --input resolver-output.json
|
|
13
13
|
*
|
|
14
14
|
* Or via npx:
|
|
15
|
-
* npx dev-loops loop build-envelope --input resolver-output.json
|
|
15
|
+
* npx dev-loops@<version> loop build-envelope --input resolver-output.json
|
|
16
16
|
*/
|
|
17
17
|
import { readFile } from "node:fs/promises";
|
|
18
18
|
import { detectRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
@@ -23,6 +23,8 @@ import { buildDevLoopHandoffEnvelope } from "@dev-loops/core/loop/handoff-envelo
|
|
|
23
23
|
import { loadDevLoopConfig } from "@dev-loops/core/config";
|
|
24
24
|
import { createPiAdapter } from "@dev-loops/core/harness";
|
|
25
25
|
import { parseArgs } from "node:util";
|
|
26
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
|
|
27
|
+
import { SANCTIONED_COMMANDS } from "./sanctioned-commands.mjs";
|
|
26
28
|
|
|
27
29
|
const USAGE = `Usage: build-handoff-envelope.mjs --input <path>
|
|
28
30
|
Build a deterministic handoff envelope from startup resolver output and settings.
|
|
@@ -30,7 +32,7 @@ Required:
|
|
|
30
32
|
--input <path> Path to resolver output JSON (from resolve-dev-loop-startup.mjs)
|
|
31
33
|
Optional:
|
|
32
34
|
--gate-state <json> Gate state JSON string
|
|
33
|
-
{ currentHeadSha?, ciStatus?, unresolvedThreadCount?, copilotRoundCount? }
|
|
35
|
+
{ currentHeadSha?, ciStatus?, unresolvedThreadCount?, copilotRoundCount?, retrospectiveFindings? }
|
|
34
36
|
--overrides <json> Overrides JSON string
|
|
35
37
|
{ mergeAuthorized?, preferLocal?, scopeConstraint?, customStopAt? }
|
|
36
38
|
--repo <owner/name> Repository slug override (falls back to bundle.repoSlug or bundle.repo)
|
|
@@ -41,9 +43,11 @@ Error output (stderr, JSON):
|
|
|
41
43
|
{ "ok": false, "error": "...", "usage": "..." }
|
|
42
44
|
Runtime failures:
|
|
43
45
|
{ "ok": false, "error": "..." }
|
|
46
|
+
${JQ_OUTPUT_USAGE}
|
|
44
47
|
Exit codes:
|
|
45
48
|
0 Success
|
|
46
|
-
1 Argument error or runtime failure
|
|
49
|
+
1 Argument error or runtime failure
|
|
50
|
+
2 Invalid --jq filter`.trim();
|
|
47
51
|
|
|
48
52
|
const parseError = buildParseError(USAGE);
|
|
49
53
|
|
|
@@ -73,6 +77,7 @@ export function parseBuildHandoffEnvelopeCliArgs(argv) {
|
|
|
73
77
|
"gate-state": { type: "string" },
|
|
74
78
|
overrides: { type: "string" },
|
|
75
79
|
repo: { type: "string" },
|
|
80
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
76
81
|
},
|
|
77
82
|
allowPositionals: true,
|
|
78
83
|
strict: false,
|
|
@@ -105,6 +110,7 @@ export function parseBuildHandoffEnvelopeCliArgs(argv) {
|
|
|
105
110
|
options.repo = requireTokenValue(token, parseError);
|
|
106
111
|
continue;
|
|
107
112
|
}
|
|
113
|
+
if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
|
|
108
114
|
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
109
115
|
}
|
|
110
116
|
|
|
@@ -143,6 +149,10 @@ export async function buildHandoffEnvelopeCli(
|
|
|
143
149
|
// Build options for envelope builder
|
|
144
150
|
const envelopeOptions = {};
|
|
145
151
|
|
|
152
|
+
// Carry the canonical sanctioned operation → wrapper command map (issue #1081)
|
|
153
|
+
// into every emitted envelope by DEFAULT so spawned subagents never re-derive it.
|
|
154
|
+
envelopeOptions.sanctionedCommands = SANCTIONED_COMMANDS;
|
|
155
|
+
|
|
146
156
|
// Repo slug: explicit --repo, then resolver output bundle, then git remote
|
|
147
157
|
const bundleSlug = resolverOutput?.bundle?.repoSlug ?? resolverOutput?.bundle?.repo ?? null;
|
|
148
158
|
const repoSlug = options.repo ?? bundleSlug ?? detectRepoSlug(repoRoot);
|
|
@@ -186,7 +196,7 @@ export async function runCli(
|
|
|
186
196
|
|
|
187
197
|
try {
|
|
188
198
|
const envelope = await buildHandoffEnvelopeCli(options, { adapter });
|
|
189
|
-
|
|
199
|
+
process.exitCode = emitResult(envelope, { jq: options.jq, silent: options.silent, stdout, stderr });
|
|
190
200
|
} catch (err) {
|
|
191
201
|
const msg = formatCliError(err);
|
|
192
202
|
stderr.write(`${msg}\n`);
|
|
@@ -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) => {
|