dev-loops 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/.claude-plugin/plugin.json +1 -1
- package/.claude/agents/dev-loop.md +1 -1
- package/.claude/agents/refiner.md +1 -0
- package/.claude/skills/dev-loop/SKILL.md +16 -6
- package/.claude/skills/dev-loop/templates/slides-story-review.md +54 -0
- package/.claude/skills/docs/artifact-authority-contract.md +86 -31
- package/.claude/skills/docs/local-planning-flow.md +63 -0
- package/.claude/skills/docs/local-planning-worked-example.md +139 -0
- package/.claude/skills/docs/merge-preconditions.md +35 -0
- package/.claude/skills/docs/plan-file-contract.md +37 -0
- package/.claude/skills/docs/retrospective-checkpoint-contract.md +55 -7
- package/.claude/skills/docs/spike-mode-contract.md +237 -0
- package/CHANGELOG.md +43 -0
- package/README.md +1 -0
- package/agents/refiner.agent.md +1 -0
- package/package.json +5 -3
- package/scripts/github/capture-review-threads.mjs +20 -2
- package/scripts/github/probe-copilot-review.mjs +69 -3
- package/scripts/github/upsert-checkpoint-verdict.mjs +18 -3
- package/scripts/lib/jq-output.mjs +297 -0
- package/scripts/loop/check-retro-tooling.mjs +246 -0
- package/scripts/loop/copilot-pr-handoff.mjs +21 -3
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +35 -2
- package/scripts/loop/docs-grill-contract.mjs +70 -0
- package/scripts/loop/info.mjs +21 -2
- package/scripts/loop/pr-runner-coordination.mjs +20 -4
- package/scripts/loop/resolve-dev-loop-startup.mjs +176 -5
- package/scripts/loop/resolve-pr-conflicts.mjs +357 -0
- package/scripts/loop/run-watch-cycle.mjs +77 -3
- package/scripts/loop/slides-story-review-contract.mjs +123 -0
- package/scripts/pages/build-site.mjs +136 -0
- package/scripts/projects/add-queue-item.mjs +12 -2
- package/scripts/projects/list-queue-items.mjs +12 -2
- package/scripts/projects/move-queue-item.mjs +12 -2
- package/scripts/refine/_refine-helpers.mjs +20 -0
- package/scripts/refine/exit-spike.mjs +186 -0
- package/scripts/refine/promote-plan.mjs +387 -0
- package/scripts/refine/refine-plan-file.mjs +165 -0
- package/scripts/refine/validate-plan-file.mjs +64 -0
- package/scripts/refine/validate-spike-file.mjs +87 -0
- package/skills/dev-loop/SKILL.md +11 -1
- package/skills/dev-loop/templates/slides-story-review.md +54 -0
- package/skills/docs/artifact-authority-contract.md +86 -31
- package/skills/docs/local-planning-flow.md +63 -0
- package/skills/docs/local-planning-worked-example.md +139 -0
- package/skills/docs/merge-preconditions.md +35 -0
- package/skills/docs/plan-file-contract.md +37 -0
- package/skills/docs/retrospective-checkpoint-contract.md +55 -7
- package/skills/docs/spike-mode-contract.md +237 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-retro-tooling
|
|
4
|
+
*
|
|
5
|
+
* Deterministic, dependency-free verifier for the internal-tooling-only rule
|
|
6
|
+
* (issue #982). Given a transcript of the shell commands the agent ran during a
|
|
7
|
+
* dev-loop run, it detects AGENT-LEVEL raw escape-hatch calls that should have
|
|
8
|
+
* gone through internal dev-loops tooling:
|
|
9
|
+
* - `gh ...` (incl. `gh api`, `gh ... --jq`)
|
|
10
|
+
* - `python` / `python3`
|
|
11
|
+
* - `node -e` / `node --eval`
|
|
12
|
+
*
|
|
13
|
+
* These three are the same breach: reaching past the dev-loops tooling to read
|
|
14
|
+
* or parse tool output with a raw call. The verifier returns the list of
|
|
15
|
+
* violations so the retrospective author can record them in
|
|
16
|
+
* `behavioralReview.rawCallViolations` and set `behavioralReview.internalToolingOnly`.
|
|
17
|
+
*
|
|
18
|
+
* Input source (how the harness feeds it):
|
|
19
|
+
* Newline-delimited shell commands — one top-level command per line — as the
|
|
20
|
+
* agent actually invoked them. Pass via a file (`--transcript <path>`) or pipe
|
|
21
|
+
* on stdin. Each line is one Bash-tool invocation's command string.
|
|
22
|
+
*
|
|
23
|
+
* ALLOWED (NOT a violation):
|
|
24
|
+
* - dev-loops subcommands and `node scripts/....mjs` invocations. Those scripts
|
|
25
|
+
* legitimately call gh/GraphQL/etc. internally — that IS the tooling.
|
|
26
|
+
* - A small explicit allowlist of write-ops that have no internal wrapper today:
|
|
27
|
+
* `gh pr merge`, `gh pr ready`, `gh issue create`, `gh issue edit`. These are
|
|
28
|
+
* recorded as `allowedWriteOps` rather than violations so the gate is not
|
|
29
|
+
* blocked forever on an unavoidable gap. Document/close the gap with a wrapper
|
|
30
|
+
* to remove them from the allowlist.
|
|
31
|
+
*
|
|
32
|
+
* VIOLATION (agent-level raw call):
|
|
33
|
+
* - `gh ...` at the start of a command segment (start of line, or after
|
|
34
|
+
* `&&`, `||`, `|`, `;`) that is not in the write-op allowlist.
|
|
35
|
+
* - `python` / `python3` at the start of a command segment.
|
|
36
|
+
* - `node -e` / `node --eval` (inline eval) at the start of a command segment.
|
|
37
|
+
*
|
|
38
|
+
* Head normalization (before classifying a segment, fail-closed):
|
|
39
|
+
* - strips leading `NAME=value ` env-assignment prefixes (`GH_TOKEN=x gh api`)
|
|
40
|
+
* - strips a leading wrapper binary from {sudo, env, xargs, time, nice, command}
|
|
41
|
+
* and re-classifies the remainder (`sudo gh api`, `xargs gh api`, `env gh api`)
|
|
42
|
+
* - reduces a path-prefixed binary to its basename (`./node_modules/.bin/gh`,
|
|
43
|
+
* `/usr/bin/python3`) so the real tool is matched.
|
|
44
|
+
*
|
|
45
|
+
* Known limitations (honest):
|
|
46
|
+
* - Segment splitting is a simple top-level split on `&&`, `||`, `|`, `;` and
|
|
47
|
+
* does NOT fully parse shell quoting/substitution. A `;`/`|` inside a quoted
|
|
48
|
+
* argument can over-report (flags a harmless inner token). It catches the
|
|
49
|
+
* common raw-call forms (incl. the env/wrapper/path-prefixed ones above), but
|
|
50
|
+
* deeply obfuscated calls — command substitution `$(...)`, aliases, `eval` —
|
|
51
|
+
* may evade it. Prefer single-line, single-purpose commands in transcripts.
|
|
52
|
+
*/
|
|
53
|
+
import { readFileSync } from "node:fs";
|
|
54
|
+
import process from "node:process";
|
|
55
|
+
import { parseArgs } from "node:util";
|
|
56
|
+
import { isDirectCliRun } from "@dev-loops/core/cli/helpers";
|
|
57
|
+
|
|
58
|
+
const USAGE = `Usage: node scripts/loop/check-retro-tooling.mjs [--transcript <path>] [--json]
|
|
59
|
+
|
|
60
|
+
Reads a newline-delimited transcript of agent shell commands (from --transcript
|
|
61
|
+
or stdin) and reports agent-level raw gh/python/node -e calls (internal-tooling-only
|
|
62
|
+
rule, issue #982).
|
|
63
|
+
|
|
64
|
+
Options:
|
|
65
|
+
--transcript <path> File of newline-delimited commands (default: read stdin)
|
|
66
|
+
--json Emit machine-readable JSON (default: human summary)
|
|
67
|
+
|
|
68
|
+
Exit codes:
|
|
69
|
+
0 No violations
|
|
70
|
+
1 One or more violations found
|
|
71
|
+
2 Argument/runtime error`;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Write-ops that currently have no internal dev-loops wrapper. Recorded
|
|
75
|
+
* distinctly so the gate is not blocked forever on an unavoidable gap.
|
|
76
|
+
* Keep this set SMALL and explicit; remove an entry once a wrapper exists.
|
|
77
|
+
* @type {ReadonlyArray<RegExp>}
|
|
78
|
+
*/
|
|
79
|
+
const ALLOWED_WRITE_OPS = Object.freeze([
|
|
80
|
+
/^gh\s+pr\s+merge\b/,
|
|
81
|
+
/^gh\s+pr\s+ready\b/,
|
|
82
|
+
/^gh\s+issue\s+create\b/,
|
|
83
|
+
/^gh\s+issue\s+edit\b/,
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
/** Split a command line into top-level segments on &&, ||, |, ;. */
|
|
87
|
+
function splitSegments(line) {
|
|
88
|
+
return line
|
|
89
|
+
.split(/&&|\|\||\||;/g)
|
|
90
|
+
.map((s) => s.trim())
|
|
91
|
+
.filter((s) => s.length > 0);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Wrapper binaries whose first argument is the real command to classify. */
|
|
95
|
+
const WRAPPER_BINARIES = new Set(["sudo", "env", "xargs", "time", "nice", "command"]);
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Strip prefixes that hide the real command head, fail-closed (over-report is
|
|
99
|
+
* fine, under-report is the bug):
|
|
100
|
+
* - leading `NAME=value ` env-assignment tokens (any number)
|
|
101
|
+
* - leading wrapper binaries (`sudo`, `env`, `xargs`, ...) — recurse on the rest
|
|
102
|
+
* - a path-prefixed binary (`./x/gh`, `/usr/bin/python3`) → reduce head to its basename
|
|
103
|
+
* Returns the segment with a bare, classifiable command head.
|
|
104
|
+
*/
|
|
105
|
+
function normalizeSegmentHead(segment) {
|
|
106
|
+
let s = segment.trim();
|
|
107
|
+
// (a) strip leading env-assignment tokens: NAME=value. The value may be
|
|
108
|
+
// unquoted (`X=1`) or a single/double-quoted string that itself contains
|
|
109
|
+
// spaces (`X="a b"`); match the quoted form first so the space inside the
|
|
110
|
+
// quotes is not mistaken for the token separator (would under-report).
|
|
111
|
+
const ENV_ASSIGN = /^[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S*)\s+(?=\S)/;
|
|
112
|
+
while (ENV_ASSIGN.test(s)) {
|
|
113
|
+
s = s.replace(ENV_ASSIGN, "");
|
|
114
|
+
}
|
|
115
|
+
// (c) reduce a path-prefixed head to its basename so `.../gh` → `gh`
|
|
116
|
+
s = s.replace(/^(\S*\/)([^/\s]+)/, "$2");
|
|
117
|
+
// (b) strip a leading wrapper binary, then re-normalize the remainder
|
|
118
|
+
const head = s.split(/\s+/, 1)[0];
|
|
119
|
+
if (WRAPPER_BINARIES.has(head)) {
|
|
120
|
+
const rest = s.slice(head.length).trim();
|
|
121
|
+
if (rest.length > 0) return normalizeSegmentHead(rest);
|
|
122
|
+
}
|
|
123
|
+
return s;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Classify a single command segment.
|
|
128
|
+
* @returns {{ kind: "violation"|"allowedWriteOp"|"clean", tool?: string }}
|
|
129
|
+
*/
|
|
130
|
+
function classifySegment(rawSegment) {
|
|
131
|
+
const segment = normalizeSegmentHead(rawSegment);
|
|
132
|
+
// `node scripts/....mjs` (or any script path) is allowed tooling; only inline
|
|
133
|
+
// eval forms are violations. Check node first so script invocations pass.
|
|
134
|
+
if (/^node\b/.test(segment)) {
|
|
135
|
+
// Inline eval (`-e`/`--eval`) is a violation only before the script path:
|
|
136
|
+
// once a non-flag token (the script) appears, a later `--eval` is just a
|
|
137
|
+
// script argument, not Node's inline-eval mode (avoids false positives).
|
|
138
|
+
const tokens = segment.split(/\s+/).slice(1);
|
|
139
|
+
for (const tok of tokens) {
|
|
140
|
+
if (tok === "-e" || tok === "--eval" || /^--eval=/.test(tok)) {
|
|
141
|
+
return { kind: "violation", tool: "node -e" };
|
|
142
|
+
}
|
|
143
|
+
if (!tok.startsWith("-")) break; // script path reached
|
|
144
|
+
}
|
|
145
|
+
return { kind: "clean" };
|
|
146
|
+
}
|
|
147
|
+
if (/^gh\b/.test(segment)) {
|
|
148
|
+
if (ALLOWED_WRITE_OPS.some((re) => re.test(segment))) {
|
|
149
|
+
return { kind: "allowedWriteOp", tool: "gh" };
|
|
150
|
+
}
|
|
151
|
+
return { kind: "violation", tool: "gh" };
|
|
152
|
+
}
|
|
153
|
+
if (/^python3?\b/.test(segment)) {
|
|
154
|
+
return { kind: "violation", tool: /^python3\b/.test(segment) ? "python3" : "python" };
|
|
155
|
+
}
|
|
156
|
+
return { kind: "clean" };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Analyze a transcript of newline-delimited shell commands.
|
|
161
|
+
*
|
|
162
|
+
* @param {string} transcript
|
|
163
|
+
* @returns {{ violations: string[], allowedWriteOps: string[], internalToolingOnly: boolean }}
|
|
164
|
+
*/
|
|
165
|
+
export function analyzeTranscript(transcript) {
|
|
166
|
+
const violations = [];
|
|
167
|
+
const allowedWriteOps = [];
|
|
168
|
+
const lines = String(transcript ?? "").split(/\r?\n/);
|
|
169
|
+
for (const raw of lines) {
|
|
170
|
+
const line = raw.trim();
|
|
171
|
+
if (line.length === 0 || line.startsWith("#")) continue;
|
|
172
|
+
for (const segment of splitSegments(line)) {
|
|
173
|
+
const result = classifySegment(segment);
|
|
174
|
+
if (result.kind === "violation") {
|
|
175
|
+
violations.push(`${result.tool}: ${segment}`);
|
|
176
|
+
} else if (result.kind === "allowedWriteOp") {
|
|
177
|
+
allowedWriteOps.push(segment);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return { violations, allowedWriteOps, internalToolingOnly: violations.length === 0 };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function parseCliArgs(argv) {
|
|
185
|
+
let values;
|
|
186
|
+
try {
|
|
187
|
+
({ values } = parseArgs({
|
|
188
|
+
args: argv,
|
|
189
|
+
options: {
|
|
190
|
+
transcript: { type: "string" },
|
|
191
|
+
json: { type: "boolean" },
|
|
192
|
+
help: { type: "boolean", short: "h" },
|
|
193
|
+
},
|
|
194
|
+
strict: true,
|
|
195
|
+
allowPositionals: false,
|
|
196
|
+
}));
|
|
197
|
+
} catch (err) {
|
|
198
|
+
throw Object.assign(new Error(err instanceof Error ? err.message : String(err)), { usage: USAGE });
|
|
199
|
+
}
|
|
200
|
+
return values;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function readStdin() {
|
|
204
|
+
try {
|
|
205
|
+
return readFileSync(0, "utf8");
|
|
206
|
+
} catch {
|
|
207
|
+
return "";
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function run(argv, { stdout, stderr }) {
|
|
212
|
+
const values = parseCliArgs(argv);
|
|
213
|
+
if (values.help) {
|
|
214
|
+
stdout.write(`${USAGE}\n`);
|
|
215
|
+
return 0;
|
|
216
|
+
}
|
|
217
|
+
const transcript = values.transcript
|
|
218
|
+
? readFileSync(values.transcript, "utf8")
|
|
219
|
+
: readStdin();
|
|
220
|
+
|
|
221
|
+
const { violations, allowedWriteOps, internalToolingOnly } = analyzeTranscript(transcript);
|
|
222
|
+
|
|
223
|
+
if (values.json) {
|
|
224
|
+
stdout.write(`${JSON.stringify({ ok: internalToolingOnly, internalToolingOnly, rawCallViolations: violations, allowedWriteOps })}\n`);
|
|
225
|
+
} else if (internalToolingOnly) {
|
|
226
|
+
stdout.write(`internalToolingOnly: true — no agent-level raw gh/python/node -e calls found.\n`);
|
|
227
|
+
if (allowedWriteOps.length > 0) {
|
|
228
|
+
stdout.write(`Allowed write-ops (no wrapper yet): ${allowedWriteOps.length}\n`);
|
|
229
|
+
}
|
|
230
|
+
} else {
|
|
231
|
+
stderr.write(`internalToolingOnly: false — ${violations.length} raw-call violation(s):\n`);
|
|
232
|
+
for (const v of violations) stderr.write(` - ${v}\n`);
|
|
233
|
+
}
|
|
234
|
+
return internalToolingOnly ? 0 : 1;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
238
|
+
run(process.argv.slice(2), { stdout: process.stdout, stderr: process.stderr }).then(
|
|
239
|
+
(code) => { process.exitCode = typeof code === "number" ? code : 0; },
|
|
240
|
+
(error) => {
|
|
241
|
+
const usage = error instanceof Error && typeof error.usage === "string" ? `\n${error.usage}` : "";
|
|
242
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}${usage}\n`);
|
|
243
|
+
process.exitCode = 2;
|
|
244
|
+
},
|
|
245
|
+
);
|
|
246
|
+
}
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
enforceExternalHealthyWaitTimeout,
|
|
18
18
|
} from "@dev-loops/core/loop/timeout-policy";
|
|
19
19
|
import { parseArgs } from "node:util";
|
|
20
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
|
|
20
21
|
import {
|
|
21
22
|
DEFAULT_POLL_INTERVAL_MS,
|
|
22
23
|
COPILOT_REVIEW_WAIT_TIMEOUT_MS,
|
|
@@ -69,9 +70,11 @@ Error output (stderr, JSON):
|
|
|
69
70
|
{ "ok": false, "error": "...", "usage": "..." }
|
|
70
71
|
gh/runtime failures:
|
|
71
72
|
{ "ok": false, "error": "..." }
|
|
73
|
+
${JQ_OUTPUT_USAGE}
|
|
72
74
|
Exit codes:
|
|
73
75
|
0 Success
|
|
74
|
-
1 Argument error or gh failure
|
|
76
|
+
1 Argument error or gh failure
|
|
77
|
+
2 Invalid --jq filter`.trim();
|
|
75
78
|
const WATCH_STATES = new Set([
|
|
76
79
|
STATE.WAITING_FOR_COPILOT_REVIEW,
|
|
77
80
|
]);
|
|
@@ -134,6 +137,8 @@ export function parseHandoffCliArgs(argv, { cwd = process.cwd() } = {}) {
|
|
|
134
137
|
repo: undefined,
|
|
135
138
|
pr: undefined,
|
|
136
139
|
watchStatus: undefined,
|
|
140
|
+
jq: undefined,
|
|
141
|
+
silent: false,
|
|
137
142
|
};
|
|
138
143
|
const { tokens } = parseArgs({
|
|
139
144
|
args: [...argv],
|
|
@@ -142,6 +147,7 @@ export function parseHandoffCliArgs(argv, { cwd = process.cwd() } = {}) {
|
|
|
142
147
|
repo: { type: "string" },
|
|
143
148
|
pr: { type: "string" },
|
|
144
149
|
"watch-status": { type: "string" },
|
|
150
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
145
151
|
},
|
|
146
152
|
allowPositionals: true,
|
|
147
153
|
strict: false,
|
|
@@ -177,6 +183,14 @@ export function parseHandoffCliArgs(argv, { cwd = process.cwd() } = {}) {
|
|
|
177
183
|
options.watchStatus = watchStatus;
|
|
178
184
|
continue;
|
|
179
185
|
}
|
|
186
|
+
if (token.name === "jq") {
|
|
187
|
+
options.jq = requireTokenValue(token, parseError);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (token.name === "silent") {
|
|
191
|
+
options.silent = true;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
180
194
|
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
181
195
|
}
|
|
182
196
|
if (options.pr === undefined) {
|
|
@@ -519,10 +533,14 @@ export async function runCli(
|
|
|
519
533
|
return;
|
|
520
534
|
}
|
|
521
535
|
const result = await runHandoff(options, { env, ghCommand });
|
|
522
|
-
|
|
536
|
+
return emitResult(result, { jq: options.jq, silent: options.silent, stdout });
|
|
523
537
|
}
|
|
524
538
|
if (isDirectCliRun(import.meta.url)) {
|
|
525
|
-
runCli().
|
|
539
|
+
runCli().then((code) => {
|
|
540
|
+
if (typeof code === "number") {
|
|
541
|
+
process.exitCode = code;
|
|
542
|
+
}
|
|
543
|
+
}).catch((error) => {
|
|
526
544
|
process.stderr.write(`${formatCliError(error)}\n`);
|
|
527
545
|
process.exitCode = 1;
|
|
528
546
|
});
|
|
@@ -163,7 +163,7 @@ async function fetchRequestedReviewers({ repo, pr }, { env = process.env, ghComm
|
|
|
163
163
|
async function fetchPrFacts({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
|
|
164
164
|
const result = await runChild(
|
|
165
165
|
ghCommand,
|
|
166
|
-
["pr", "view", String(pr), "--repo", repo, "--json", "number,state,isDraft,headRefOid,mergeStateStatus,body,title,closingIssuesReferences,reviews,statusCheckRollup"],
|
|
166
|
+
["pr", "view", String(pr), "--repo", repo, "--json", "number,state,isDraft,headRefOid,mergeable,mergeStateStatus,body,title,closingIssuesReferences,reviews,statusCheckRollup"],
|
|
167
167
|
env,
|
|
168
168
|
);
|
|
169
169
|
if (result.code !== 0) {
|
|
@@ -172,6 +172,32 @@ async function fetchPrFacts({ repo, pr }, { env = process.env, ghCommand = "gh"
|
|
|
172
172
|
}
|
|
173
173
|
return parseJsonText(result.stdout, { label: "gh pr view" });
|
|
174
174
|
}
|
|
175
|
+
|
|
176
|
+
// GitHub computes `mergeable` asynchronously, so a freshly-pushed head briefly
|
|
177
|
+
// reads `UNKNOWN`. After the initial fetch, re-poll up to `maxPolls` more times
|
|
178
|
+
// while the value stays UNKNOWN (so at most 1 + maxPolls total fetches) before
|
|
179
|
+
// deciding; never treat a transient UNKNOWN as a pass — the caller fails closed
|
|
180
|
+
// to recheck if it never settles. (issue #980)
|
|
181
|
+
export async function fetchPrFactsWithSettledMergeable(
|
|
182
|
+
options,
|
|
183
|
+
{
|
|
184
|
+
env = process.env,
|
|
185
|
+
ghCommand = "gh",
|
|
186
|
+
maxPolls = 3,
|
|
187
|
+
pollDelayMs = 1500,
|
|
188
|
+
sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
189
|
+
fetch = fetchPrFacts,
|
|
190
|
+
} = {},
|
|
191
|
+
) {
|
|
192
|
+
let prData = await fetch(options, { env, ghCommand });
|
|
193
|
+
let polls = 0;
|
|
194
|
+
while (String(prData?.mergeable || "").toUpperCase() === "UNKNOWN" && polls < maxPolls) {
|
|
195
|
+
polls += 1;
|
|
196
|
+
await sleep(pollDelayMs);
|
|
197
|
+
prData = await fetch(options, { env, ghCommand });
|
|
198
|
+
}
|
|
199
|
+
return prData;
|
|
200
|
+
}
|
|
175
201
|
export function resolveLinkedIssueFromPr(prData) {
|
|
176
202
|
if (!prData || typeof prData !== "object") return null;
|
|
177
203
|
const closing = Array.isArray(prData.closingIssuesReferences) ? prData.closingIssuesReferences : [];
|
|
@@ -290,7 +316,7 @@ async function loadRetrospectiveCheckpoint(repoRoot) {
|
|
|
290
316
|
}
|
|
291
317
|
}
|
|
292
318
|
export async function loadPrGateCoordinationContext(options, runtime = {}) {
|
|
293
|
-
const prData = await
|
|
319
|
+
const prData = await fetchPrFactsWithSettledMergeable(options, runtime);
|
|
294
320
|
const currentHeadSha = typeof prData?.headRefOid === "string" && prData.headRefOid.trim().length > 0
|
|
295
321
|
? prData.headRefOid.trim()
|
|
296
322
|
: null;
|
|
@@ -349,6 +375,9 @@ export async function loadPrGateCoordinationContext(options, runtime = {}) {
|
|
|
349
375
|
const mergeStateStatus = typeof prData?.mergeStateStatus === "string" && prData.mergeStateStatus.trim().length > 0
|
|
350
376
|
? prData.mergeStateStatus.trim().toUpperCase()
|
|
351
377
|
: null;
|
|
378
|
+
const mergeable = typeof prData?.mergeable === "string" && prData.mergeable.trim().length > 0
|
|
379
|
+
? prData.mergeable.trim().toUpperCase()
|
|
380
|
+
: null;
|
|
352
381
|
const isDraft = Boolean(prData?.isDraft);
|
|
353
382
|
const isClosed = String(prData?.state || "").toUpperCase() === "CLOSED";
|
|
354
383
|
const isMerged = String(prData?.state || "").toUpperCase() === "MERGED";
|
|
@@ -361,6 +390,7 @@ export async function loadPrGateCoordinationContext(options, runtime = {}) {
|
|
|
361
390
|
pr: options.pr,
|
|
362
391
|
currentHeadSha,
|
|
363
392
|
mergeStateStatus,
|
|
393
|
+
mergeable,
|
|
364
394
|
conflictFiles,
|
|
365
395
|
prData,
|
|
366
396
|
snapshot,
|
|
@@ -396,6 +426,7 @@ export async function detectPrGateCoordinationState(options, runtime = {}) {
|
|
|
396
426
|
const draftGateConfig = resolveGateConfig(config, "draft");
|
|
397
427
|
const maxCopilotRounds = resolveRefinementConfig(config, "maxCopilotRounds");
|
|
398
428
|
const requireRetrospectiveGate = resolveWorkflowConfig(config, "requireRetrospectiveGate");
|
|
429
|
+
const requireRetrospectiveInternalTooling = resolveWorkflowConfig(config, "requireRetrospectiveInternalTooling");
|
|
399
430
|
const retrospectiveCheckpoint = await loadRetrospectiveCheckpoint(repoRoot);
|
|
400
431
|
const result = evaluatePrGateCoordination({
|
|
401
432
|
repo: context.repo,
|
|
@@ -406,6 +437,7 @@ export async function detectPrGateCoordinationState(options, runtime = {}) {
|
|
|
406
437
|
prMerged: String(context.prData?.state || "").toUpperCase() === "MERGED",
|
|
407
438
|
prTitle: context.prData?.title,
|
|
408
439
|
mergeStateStatus: context.mergeStateStatus,
|
|
440
|
+
mergeable: context.mergeable,
|
|
409
441
|
conflictFiles: context.conflictFiles,
|
|
410
442
|
lifecycleState: context.interpretation.state,
|
|
411
443
|
loopDisposition: context.disposition.loopDisposition,
|
|
@@ -415,6 +447,7 @@ export async function detectPrGateCoordinationState(options, runtime = {}) {
|
|
|
415
447
|
sameHeadCleanConverged: context.interpretation.sameHeadCleanConverged,
|
|
416
448
|
draftGateRequireCi: draftGateConfig.requireCi,
|
|
417
449
|
requireRetrospectiveGate,
|
|
450
|
+
requireRetrospectiveInternalTooling,
|
|
418
451
|
retrospectiveCheckpoint,
|
|
419
452
|
draftGate: context.gateEvidence.draftGate,
|
|
420
453
|
draftGateMarker: context.gateEvidence.draftGateMarker,
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Bounded docs-grill disposition classifier behind `dev-loop`. Sibling of
|
|
2
|
+
// scripts/loop/slides-story-review-contract.mjs: this one codifies the keep/fix
|
|
3
|
+
// rule for the autonomous docs-grill step (claims vs the actual contracts,
|
|
4
|
+
// code-vs-doc drift, stale references, contract-surface accuracy). Pure module,
|
|
5
|
+
// no I/O. See docs/docs-grill-step.md.
|
|
6
|
+
|
|
7
|
+
// What each finding asserts is wrong. `drift` covers any divergence between a
|
|
8
|
+
// claim/reference and the contract surface it points at; `stale_reference`
|
|
9
|
+
// covers a link/path/command that no longer resolves; `cosmetic` covers
|
|
10
|
+
// wording-only nits with no behavioral or reference impact.
|
|
11
|
+
export const DOCS_GRILL_FINDING_KINDS = Object.freeze([
|
|
12
|
+
'drift',
|
|
13
|
+
'stale_reference',
|
|
14
|
+
'cosmetic',
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
// The bounded disposition the step assigns to each finding.
|
|
18
|
+
export const DOCS_GRILL_DISPOSITIONS = Object.freeze([
|
|
19
|
+
'record_finding', // real drift between code/behavior and a contract claim — record it
|
|
20
|
+
'fix_in_place', // doc-only drift the loop can correct on this branch
|
|
21
|
+
'route_followup', // doc-only drift too large for this branch — route a follow-up
|
|
22
|
+
'ignore_cosmetic', // wording nit that does not justify a block or a fix here
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Classify one docs-grill finding into its keep/fix disposition.
|
|
27
|
+
*
|
|
28
|
+
* The keep/fix rule (docs/docs-grill-step.md):
|
|
29
|
+
* - real drift between code/behavior and a contract claim -> record_finding
|
|
30
|
+
* - doc-only drift the loop can correct here -> fix_in_place
|
|
31
|
+
* - doc-only drift too large for this branch -> route_followup
|
|
32
|
+
* - cosmetic wording nit -> ignore_cosmetic
|
|
33
|
+
*
|
|
34
|
+
* A coerced/invalid finding returns a structured invalid result rather than
|
|
35
|
+
* throwing (the module's contract is a structured result, never an exception).
|
|
36
|
+
*
|
|
37
|
+
* @param {object} finding
|
|
38
|
+
* @param {string} finding.kind - one of DOCS_GRILL_FINDING_KINDS
|
|
39
|
+
* @param {boolean} [finding.docOnly] - true when only docs (no code/behavior) diverge
|
|
40
|
+
* @param {boolean} [finding.fixableHere] - true when a doc-only fix is small enough for this branch
|
|
41
|
+
* @returns {{ ok: boolean, disposition?: string, status: string, reason: string, invalid?: string[] }}
|
|
42
|
+
*/
|
|
43
|
+
export function classifyDocsGrillFinding(finding = {}) {
|
|
44
|
+
if (!finding || typeof finding !== 'object') finding = {};
|
|
45
|
+
if (!DOCS_GRILL_FINDING_KINDS.includes(finding.kind)) {
|
|
46
|
+
return {
|
|
47
|
+
ok: false,
|
|
48
|
+
status: 'invalid_finding',
|
|
49
|
+
reason: 'unknown_finding_kind',
|
|
50
|
+
invalid: ['kind'],
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (finding.kind === 'cosmetic') {
|
|
55
|
+
return { ok: true, disposition: 'ignore_cosmetic', status: 'classified', reason: 'cosmetic_nit' };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// drift | stale_reference against live code/behavior is always recorded; the
|
|
59
|
+
// reason reflects the kind so downstream filtering stays accurate.
|
|
60
|
+
if (finding.docOnly !== true) {
|
|
61
|
+
const reason = finding.kind === 'stale_reference' ? 'stale_reference' : 'real_drift';
|
|
62
|
+
return { ok: true, disposition: 'record_finding', status: 'classified', reason };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Doc-only drift: fix it here when small, otherwise route a follow-up.
|
|
66
|
+
if (finding.fixableHere === true) {
|
|
67
|
+
return { ok: true, disposition: 'fix_in_place', status: 'classified', reason: 'doc_only_fixable_here' };
|
|
68
|
+
}
|
|
69
|
+
return { ok: true, disposition: 'route_followup', status: 'classified', reason: 'doc_only_too_large' };
|
|
70
|
+
}
|
package/scripts/loop/info.mjs
CHANGED
|
@@ -100,13 +100,32 @@ function formatCiDisplay(ciStatus, ciConclusion) {
|
|
|
100
100
|
return `CI ${ciStatus}`;
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
function formatMergeableDisplay(mergeable, mergeStateStatus) {
|
|
104
|
+
const m = typeof mergeable === "string" ? mergeable.toUpperCase() : null;
|
|
105
|
+
const s = typeof mergeStateStatus === "string" ? mergeStateStatus.toUpperCase() : null;
|
|
106
|
+
if (m === "CONFLICTING" || s === "DIRTY" || s === "CONFLICTING") {
|
|
107
|
+
return `❌ CONFLICTING${s ? ` (${s})` : ""} — resolve before any gate`;
|
|
108
|
+
}
|
|
109
|
+
if (s === "BEHIND") {
|
|
110
|
+
return "⚠️ BEHIND — update branch from base before any gate";
|
|
111
|
+
}
|
|
112
|
+
if (m === "UNKNOWN") {
|
|
113
|
+
return "⏳ UNKNOWN — GitHub still computing; recheck before proceeding";
|
|
114
|
+
}
|
|
115
|
+
if (m === "MERGEABLE") {
|
|
116
|
+
return `✅ MERGEABLE${s ? ` (${s})` : ""}`;
|
|
117
|
+
}
|
|
118
|
+
return s || m || "unknown";
|
|
119
|
+
}
|
|
120
|
+
|
|
103
121
|
function formatPrSummary(prData, handoffResult) {
|
|
104
122
|
const lines = [];
|
|
105
123
|
lines.push(`PR #${prData.number}: ${prData.title}`);
|
|
106
124
|
lines.push(` Branch: ${formatBranchDisplay(prData.headRefName, prData.baseRefName)}`);
|
|
107
125
|
lines.push(` State: ${prData.state}${prData.isDraft ? " (draft)" : ""}`);
|
|
108
126
|
lines.push(` Author: ${prData.author?.login || "unknown"}`);
|
|
109
|
-
|
|
127
|
+
lines.push(` Mergeable: ${formatMergeableDisplay(prData.mergeable, prData.mergeStateStatus)}`);
|
|
128
|
+
|
|
110
129
|
if (handoffResult?.snapshot) {
|
|
111
130
|
const s = handoffResult.snapshot;
|
|
112
131
|
if (s.ciStatus !== undefined) {
|
|
@@ -189,7 +208,7 @@ function formatIssueSummary(issueData, startupBundle, linkedPrData) {
|
|
|
189
208
|
}
|
|
190
209
|
|
|
191
210
|
function buildPrInfo(prNumber, repo, cwd) {
|
|
192
|
-
const prData = ghJson(["pr", "view", String(prNumber), "--repo", repo, "--json", "number,title,body,state,isDraft,headRefName,baseRefName,author,mergedAt,url,reviewRequests"], cwd);
|
|
211
|
+
const prData = ghJson(["pr", "view", String(prNumber), "--repo", repo, "--json", "number,title,body,state,isDraft,headRefName,baseRefName,author,mergedAt,mergeable,mergeStateStatus,url,reviewRequests"], cwd);
|
|
193
212
|
|
|
194
213
|
let handoffResult = null;
|
|
195
214
|
try {
|
|
@@ -5,6 +5,7 @@ import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helper
|
|
|
5
5
|
import { parsePrNumber, requireTokenValue } from "../_cli-primitives.mjs";
|
|
6
6
|
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
7
7
|
import { resolveRunId as resolveEnvRunId } from "@dev-loops/core/loop/run-context";
|
|
8
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
|
|
8
9
|
import {
|
|
9
10
|
assertRunnerOwnership,
|
|
10
11
|
claimRunnerOwnership,
|
|
@@ -23,6 +24,7 @@ If --run-id is omitted for claim/assert/release/takeover, DEVLOOPS_RUN_ID is use
|
|
|
23
24
|
Output:
|
|
24
25
|
stdout: { "ok": true, ... }
|
|
25
26
|
stderr: { "ok": false, "error": "...", ... }
|
|
27
|
+
${JQ_OUTPUT_USAGE}
|
|
26
28
|
Exit codes:
|
|
27
29
|
0 Success / clean stop-compatible result
|
|
28
30
|
1 Argument error or coordination conflict`.trim();
|
|
@@ -36,6 +38,8 @@ function parseCliArgs(argv) {
|
|
|
36
38
|
pr: undefined,
|
|
37
39
|
runId: undefined,
|
|
38
40
|
requireExisting: false,
|
|
41
|
+
jq: undefined,
|
|
42
|
+
silent: false,
|
|
39
43
|
};
|
|
40
44
|
const command = args.shift();
|
|
41
45
|
if (command === undefined || command === "--help" || command === "-h") {
|
|
@@ -51,6 +55,7 @@ function parseCliArgs(argv) {
|
|
|
51
55
|
pr: { type: "string" },
|
|
52
56
|
"run-id": { type: "string" },
|
|
53
57
|
"require-existing": { type: "boolean" },
|
|
58
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
54
59
|
},
|
|
55
60
|
allowPositionals: true,
|
|
56
61
|
strict: false,
|
|
@@ -83,6 +88,14 @@ function parseCliArgs(argv) {
|
|
|
83
88
|
options.requireExisting = true;
|
|
84
89
|
continue;
|
|
85
90
|
}
|
|
91
|
+
if (token.name === "jq") {
|
|
92
|
+
options.jq = requireTokenValue(token, parseError);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (token.name === "silent") {
|
|
96
|
+
options.silent = true;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
86
99
|
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
87
100
|
}
|
|
88
101
|
const validCommands = new Set(["status", "claim", "takeover", "assert", "release"]);
|
|
@@ -146,11 +159,14 @@ async function main() {
|
|
|
146
159
|
}
|
|
147
160
|
const result = await runPrRunnerCoordination(options, { env: process.env });
|
|
148
161
|
if (!result.ok) {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
162
|
+
// Preserve the established stderr-on-failure contract when not filtering.
|
|
163
|
+
if (options.jq === undefined && !options.silent) {
|
|
164
|
+
console.error(JSON.stringify(result));
|
|
165
|
+
process.exitCode = 1;
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
152
168
|
}
|
|
153
|
-
|
|
169
|
+
process.exitCode = emitResult(result, { jq: options.jq, silent: options.silent });
|
|
154
170
|
} catch (error) {
|
|
155
171
|
const payload = formatCliError(error, { usage: USAGE });
|
|
156
172
|
console.error(JSON.stringify(payload));
|