dev-loops 0.7.1 → 0.8.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 +7 -7
- package/.claude/agents/fixer.md +1 -0
- package/.claude/agents/refiner.md +4 -4
- package/.claude/agents/review.md +10 -13
- package/.claude/commands/loop-continue.md +2 -1
- package/.claude/commands/loop-enqueue.md +9 -2
- package/.claude/commands/loop-info.md +1 -1
- package/.claude/commands/loop-start-spike.md +1 -1
- package/.claude/hooks/_bash-command-classify.mjs +7 -6
- package/.claude/hooks/_hook-decisions.mjs +5 -4
- package/.claude/skills/copilot-pr-followup/SKILL.md +42 -28
- package/.claude/skills/dev-loop/SKILL.md +8 -8
- package/.claude/skills/docs/acceptance-criteria-verification.md +13 -4
- package/.claude/skills/docs/anti-patterns.md +6 -5
- package/.claude/skills/docs/artifact-authority-contract.md +17 -14
- package/.claude/skills/docs/confirmation-rules.md +2 -1
- package/.claude/skills/docs/contract-style-guide.md +37 -0
- package/.claude/skills/docs/copilot-loop-operations.md +21 -15
- package/.claude/skills/docs/cross-harness-regression-contract.md +2 -2
- 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 +37 -22
- package/.claude/skills/docs/merge-preconditions.md +17 -13
- package/.claude/skills/docs/pr-lifecycle-contract.md +45 -36
- package/.claude/skills/docs/public-dev-loop-contract.md +61 -44
- package/.claude/skills/docs/retrospective-checkpoint-contract.md +25 -9
- 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/validation-policy.md +4 -5
- package/.claude/skills/local-implementation/SKILL.md +49 -143
- package/.claude/skills/loop-grill/SKILL.md +34 -14
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +36 -0
- package/README.md +95 -189
- package/agents/dev-loop.agent.md +8 -8
- package/agents/fixer.agent.md +1 -0
- package/agents/refiner.agent.md +4 -4
- package/agents/review.agent.md +10 -13
- package/extension/README.md +5 -4
- package/package.json +7 -5
- 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 +1169 -0
- package/scripts/github/_gate-names.mjs +5 -0
- package/scripts/github/_review-thread-mutations.mjs +5 -2
- package/scripts/github/capture-review-threads.mjs +2 -2
- package/scripts/github/create-pr.mjs +119 -10
- package/scripts/github/detect-checkpoint-evidence.mjs +109 -24
- package/scripts/github/edit-issue.mjs +259 -0
- package/scripts/github/list-review-threads.mjs +277 -0
- package/scripts/github/post-gate-findings.mjs +6 -3
- package/scripts/github/probe-ci-status.mjs +18 -0
- package/scripts/github/probe-copilot-review.mjs +24 -3
- package/scripts/github/reconcile-draft-gate.mjs +13 -13
- package/scripts/github/request-copilot-review.mjs +109 -42
- package/scripts/github/upsert-checkpoint-verdict.mjs +98 -27
- package/scripts/github/verify-briefing-prefixes.mjs +382 -0
- package/scripts/github/verify-fresh-review-context.mjs +78 -3
- package/scripts/github/wait-pr-checks.mjs +171 -0
- package/scripts/github/write-gate-context.mjs +220 -10
- package/scripts/github/write-gate-findings-log.mjs +54 -2
- package/scripts/loop/_post-convergence-change.mjs +2 -2
- package/scripts/loop/_pr-runner-coordination.mjs +112 -13
- package/scripts/loop/check-retro-tooling.mjs +14 -9
- package/scripts/loop/copilot-pr-handoff.mjs +47 -16
- package/scripts/loop/detect-change-scope.mjs +2 -2
- package/scripts/loop/detect-copilot-loop-state.mjs +34 -14
- package/scripts/loop/detect-internal-only-pr.mjs +6 -6
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +152 -18
- package/scripts/loop/detect-refinement-grill-state.mjs +136 -0
- package/scripts/loop/resolve-dev-loop-startup.mjs +145 -8
- package/scripts/loop/run-watch-cycle.mjs +42 -7
- package/scripts/loop/sanctioned-commands.mjs +3 -0
- package/scripts/loop/validate-pr-body-spec.mjs +21 -5
- package/scripts/pages/build-state-atlas.mjs +65 -48
- package/scripts/projects/_resolve-project.mjs +1 -148
- package/scripts/projects/add-queue-item.mjs +87 -4
- package/scripts/projects/list-queue-items.mjs +3 -377
- package/scripts/projects/move-queue-item.mjs +3 -410
- package/scripts/projects/reorder-queue-item.mjs +3 -22
- package/skills/copilot-pr-followup/SKILL.md +42 -28
- package/skills/dev-loop/SKILL.md +3 -3
- package/skills/docs/acceptance-criteria-verification.md +13 -4
- package/skills/docs/anti-patterns.md +6 -5
- package/skills/docs/artifact-authority-contract.md +17 -14
- package/skills/docs/confirmation-rules.md +2 -1
- package/skills/docs/contract-style-guide.md +37 -0
- package/skills/docs/copilot-loop-operations.md +21 -15
- package/skills/docs/cross-harness-regression-contract.md +2 -2
- 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 +37 -22
- package/skills/docs/merge-preconditions.md +17 -13
- package/skills/docs/pr-lifecycle-contract.md +45 -36
- package/skills/docs/public-dev-loop-contract.md +61 -44
- package/skills/docs/required-rules.json +165 -0
- package/skills/docs/retrospective-checkpoint-contract.md +25 -9
- 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/validation-policy.md +4 -5
- package/skills/local-implementation/SKILL.md +49 -143
- package/skills/loop-grill/SKILL.md +38 -17
- package/scripts/docs/validate-no-duplicate-rules.mjs +0 -250
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Canonical gate vocabulary — the single source of truth for gate names shared
|
|
2
|
+
// across the gate-review tooling. Imported by write-gate-context.mjs (artifact
|
|
3
|
+
// gate validation) and verify-briefing-prefixes.mjs (wrong-gate scope check) so
|
|
4
|
+
// the two can never drift.
|
|
5
|
+
export const GATE_NAMES = ["draft_gate", "pre_approval_gate"];
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { isCopilotLogin, parseReviewThreads } from "../_core-helpers.mjs";
|
|
2
|
+
import { isCopilotLogin, parseReviewThreads, sanitizeCopilotSummonTokens } from "../_core-helpers.mjs";
|
|
3
3
|
import { fetchGithubReviewThreadsPayload } from "./capture-review-threads.mjs";
|
|
4
4
|
export const MIN_DISMISSAL_REASON_LENGTH = 30;
|
|
5
5
|
export function hasCommitShaReference(text) {
|
|
@@ -175,12 +175,15 @@ export async function replyAndMaybeResolve(
|
|
|
175
175
|
} else {
|
|
176
176
|
await validateReplyTarget({ repo, pr, commentId, threadId }, { env, ghCommand });
|
|
177
177
|
}
|
|
178
|
+
// Neutralize any bare @copilot/`/copilot`* tokens the reply text quotes (e.g.
|
|
179
|
+
// a dismissal reason citing the anti-summon rule) so posting this reply can
|
|
180
|
+
// never arm request-copilot-review.mjs's anti-summon guard.
|
|
178
181
|
const reply = parseReplyPayload(await postReply(
|
|
179
182
|
{
|
|
180
183
|
repo,
|
|
181
184
|
pr,
|
|
182
185
|
commentId,
|
|
183
|
-
body,
|
|
186
|
+
body: sanitizeCopilotSummonTokens(body),
|
|
184
187
|
},
|
|
185
188
|
{ env, ghCommand },
|
|
186
189
|
));
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
readInput,
|
|
10
10
|
} from "../_core-helpers.mjs";
|
|
11
11
|
import { parseArgs } from "node:util";
|
|
12
|
-
import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
|
|
12
|
+
import { parsePrNumber, requireTokenValue, runChild as defaultRunChild } from "../_cli-primitives.mjs";
|
|
13
13
|
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
14
14
|
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
|
|
15
15
|
export const REVIEW_THREADS_QUERY = [
|
|
@@ -118,7 +118,7 @@ export function parseCaptureCliArgs(argv) {
|
|
|
118
118
|
}
|
|
119
119
|
export async function fetchGithubReviewThreadsPayload(
|
|
120
120
|
{ repo, pr },
|
|
121
|
-
{ env = process.env, ghCommand = "gh" } = {},
|
|
121
|
+
{ env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {},
|
|
122
122
|
) {
|
|
123
123
|
const { owner, name } = parseRepoSlug(repo);
|
|
124
124
|
const result = await runChild(
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
4
|
import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
|
|
5
|
+
import { runChild as _runChild } from "../_cli-primitives.mjs";
|
|
6
|
+
import { resolveSettings, applyDevloopsBoard } from "../projects/_resolve-project.mjs";
|
|
7
|
+
import { main as addQueueItemMain } from "../projects/add-queue-item.mjs";
|
|
8
|
+
import { loadStateColumnMap, LOGICAL_COLUMN } from "@dev-loops/core/loop/queue-board-sync";
|
|
5
9
|
const USAGE = `Usage: create-pr.mjs [gh pr create args...]
|
|
6
10
|
Canonical PR-creation wrapper around \`gh pr create\`. Every PR opened through this
|
|
7
11
|
tool is ALWAYS a draft and is self-assigned by default. Never call raw \`gh pr create\`.
|
|
@@ -11,6 +15,16 @@ Behavior:
|
|
|
11
15
|
- honors an explicit \`--assignee <login>\` / \`-a <login>\` when supplied (no default injected)
|
|
12
16
|
- rejects \`--ready\` before invoking \`gh\`
|
|
13
17
|
- detects missing \`Closes #N\` / \`Fixes #N\` in \`--body\` or \`--body-file\` content (non-fatal stderr warning)
|
|
18
|
+
- \`--lightweight\` (consumed here in every form — bare or \`=true/1/false/0\`, last
|
|
19
|
+
occurrence wins — never forwarded to \`gh\`): when an explicit \`--body\`/
|
|
20
|
+
\`--body-file\` also carries no \`Closes #N\`/\`Fixes #N\`, the new PR is issue-less
|
|
21
|
+
lightweight and is auto-enqueued as a board PR item in the configured In Progress column
|
|
22
|
+
(reuses \`queue.projectNumber\` / \`queue.boardTitle\` from \`.devloops\`, same as the queue
|
|
23
|
+
scripts). Requires an explicit \`--repo owner/name\` (space or = form). A trailing stdout
|
|
24
|
+
line reports the outcome: \`{"board":{"enqueued":bool,...}}\`. No board configured, no
|
|
25
|
+
\`--repo\`, no explicit body source, or an enqueue error is a non-fatal no-op (noted in
|
|
26
|
+
that line; exit code unaffected). Omitting \`--lightweight\`, or a body that already
|
|
27
|
+
carries a closing keyword (tracker-backed), never calls the board.
|
|
14
28
|
- forwards every other argument to \`gh pr create\` unchanged
|
|
15
29
|
- preserves the underlying \`gh pr create\` stdout, stderr, and exit code
|
|
16
30
|
Examples:
|
|
@@ -26,8 +40,17 @@ Exit codes:
|
|
|
26
40
|
N same non-zero exit code returned by \`gh pr create\``.trim();
|
|
27
41
|
const parseError = buildParseError(USAGE);
|
|
28
42
|
const READY_FLAG_PATTERN = /^--ready(?:$|=)/u;
|
|
43
|
+
// Bare and inline-boolean forms, mirroring DRAFT_FLAG_PATTERN below: every
|
|
44
|
+
// matching token is consumed (never forwarded to gh, which rejects unknown
|
|
45
|
+
// flags), and the LAST occurrence decides — bare or =true/=1 enables,
|
|
46
|
+
// anything else (=false, =0, ...) disables.
|
|
47
|
+
const LIGHTWEIGHT_FLAG_PATTERN = /^--lightweight(?:=(.*))?$/iu;
|
|
48
|
+
// Both `--repo owner/name` and `--repo=owner/name` — gh accepts either form.
|
|
49
|
+
const REPO_FLAG_PATTERN = /^--repo(?:$|=)/u;
|
|
50
|
+
const PR_URL_NUMBER_PATTERN = /\/pull\/(\d+)(?:\D|$)/u;
|
|
29
51
|
const DRAFT_FLAG_PATTERN = /^--draft(?:=(.*))?$/iu;
|
|
30
|
-
|
|
52
|
+
// Shared inline-boolean truthiness for --draft= and --lightweight= values.
|
|
53
|
+
const TRUE_FLAG_VALUE_PATTERN = /^(?:true|1)$/iu;
|
|
31
54
|
// Detect both the long `--assignee`/`--assignee=<login>` forms and the `-a`
|
|
32
55
|
// short flag that `gh pr create` documents, so an explicit assignee in either
|
|
33
56
|
// form suppresses the `--assignee @me` default (otherwise a caller passing
|
|
@@ -56,8 +79,7 @@ async function resolveBody(args) {
|
|
|
56
79
|
}
|
|
57
80
|
return null; // unreadable → warn
|
|
58
81
|
}
|
|
59
|
-
|
|
60
|
-
const body = await resolveBody(args);
|
|
82
|
+
function warnMissingClosingKeyword(body) {
|
|
61
83
|
if (body === null) return; // no --body or --body-file, skip
|
|
62
84
|
if (!detectClosingKeyword(body)) {
|
|
63
85
|
process.stderr.write(
|
|
@@ -66,6 +88,48 @@ async function warnMissingClosingKeyword(args) {
|
|
|
66
88
|
);
|
|
67
89
|
}
|
|
68
90
|
}
|
|
91
|
+
// A plain string value for a single-value flag, in both the space form
|
|
92
|
+
// (`--repo owner/name`) and the inline form (`--repo=owner/name`); unlike
|
|
93
|
+
// resolveBody, never reads a file. Returns null when the flag is absent.
|
|
94
|
+
function getFlagValue(args, flagPattern) {
|
|
95
|
+
const idx = args.findIndex((token) => flagPattern.test(token));
|
|
96
|
+
if (idx === -1) return null;
|
|
97
|
+
const eq = args[idx].indexOf("=");
|
|
98
|
+
if (eq !== -1) return args[idx].slice(eq + 1);
|
|
99
|
+
return idx + 1 < args.length ? args[idx + 1] : null;
|
|
100
|
+
}
|
|
101
|
+
function parsePrNumberFromOutput(stdout) {
|
|
102
|
+
const match = PR_URL_NUMBER_PATTERN.exec(stdout ?? "");
|
|
103
|
+
return match ? Number(match[1]) : null;
|
|
104
|
+
}
|
|
105
|
+
// Auto-enqueue an issue-less lightweight PR as a board PR item in the configured
|
|
106
|
+
// In Progress column. Reuses the same .devloops queue.projectNumber /
|
|
107
|
+
// queue.boardTitle resolution and add-queue-item's idempotent add — never
|
|
108
|
+
// reimplements the board API calls. Never throws: an unconfigured board, a
|
|
109
|
+
// missing --repo, an unparsed PR number, or an enqueue failure are all
|
|
110
|
+
// non-fatal no-ops reported in the returned shape.
|
|
111
|
+
export async function enqueueIssuelessLightweightPr({ repo, prNumber, cwd, env, runChild }) {
|
|
112
|
+
if (!repo) return { enqueued: false, reason: "repo-not-specified" };
|
|
113
|
+
if (!Number.isInteger(prNumber) || prNumber < 1) return { enqueued: false, reason: "pr-number-not-parsed" };
|
|
114
|
+
const settings = resolveSettings(cwd);
|
|
115
|
+
if (!settings?.project && !settings?.title) {
|
|
116
|
+
return { enqueued: false, reason: "no-board-configured" };
|
|
117
|
+
}
|
|
118
|
+
const { columnNames, error: columnError } = loadStateColumnMap(cwd);
|
|
119
|
+
if (columnError) {
|
|
120
|
+
return { enqueued: false, reason: `config-error: ${columnError}` };
|
|
121
|
+
}
|
|
122
|
+
const args = { repo, item: prNumber };
|
|
123
|
+
applyDevloopsBoard(args, cwd);
|
|
124
|
+
args.column = columnNames[LOGICAL_COLUMN.IN_PROGRESS];
|
|
125
|
+
try {
|
|
126
|
+
const result = await addQueueItemMain(args, { env, runChild, cwd });
|
|
127
|
+
const { itemId, prNumber: itemPrNumber, status, alreadyPresent } = result.item;
|
|
128
|
+
return { enqueued: true, itemId, prNumber: itemPrNumber, status, alreadyPresent };
|
|
129
|
+
} catch (err) {
|
|
130
|
+
return { enqueued: false, reason: `enqueue-error: ${err instanceof Error ? err.message : String(err)}` };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
69
133
|
export function buildCreatePrArgs(argv) {
|
|
70
134
|
const args = [...argv];
|
|
71
135
|
if (args.includes("--help") || args.includes("-h")) {
|
|
@@ -79,7 +143,7 @@ export function buildCreatePrArgs(argv) {
|
|
|
79
143
|
}
|
|
80
144
|
const draftTokens = args.filter((token) => DRAFT_FLAG_PATTERN.test(token));
|
|
81
145
|
const lastDraftToken = draftTokens.length > 0 ? draftTokens.at(-1) : null;
|
|
82
|
-
const lastDraftSuppliesDraft = lastDraftToken === "--draft" || (typeof lastDraftToken === "string" &&
|
|
146
|
+
const lastDraftSuppliesDraft = lastDraftToken === "--draft" || (typeof lastDraftToken === "string" && TRUE_FLAG_VALUE_PATTERN.test(lastDraftToken.slice("--draft=".length)));
|
|
83
147
|
const hasAssignee = args.some((token) => ASSIGNEE_FLAG_PATTERN.test(token));
|
|
84
148
|
return {
|
|
85
149
|
help: false,
|
|
@@ -92,26 +156,71 @@ export function buildCreatePrArgs(argv) {
|
|
|
92
156
|
],
|
|
93
157
|
};
|
|
94
158
|
}
|
|
95
|
-
|
|
159
|
+
// captureStdout tees gh's stdout to a buffer (still writing it straight through to
|
|
160
|
+
// process.stdout, unbuffered) so the PR URL can be parsed once gh exits, without
|
|
161
|
+
// changing what a caller/terminal sees. Only enabled for the issue-less lightweight
|
|
162
|
+
// path; every other caller keeps the plain "inherit" byte-identical behavior.
|
|
163
|
+
export function spawnCreatePr(ghArgs, { ghCommand = "gh", env = process.env } = {}, { captureStdout = false } = {}) {
|
|
96
164
|
return new Promise((resolve, reject) => {
|
|
165
|
+
// `gh pr create` never reads stdin (body comes from --body/--body-file), so
|
|
166
|
+
// the captured path ignores it rather than inheriting: inheriting a
|
|
167
|
+
// never-closing stdin (e.g. an in-process test runner) would hang any child
|
|
168
|
+
// that waits for stdin to end.
|
|
97
169
|
const child = spawn(ghCommand, ghArgs, {
|
|
98
170
|
env,
|
|
99
|
-
stdio: "inherit",
|
|
171
|
+
stdio: captureStdout ? ["ignore", "pipe", "inherit"] : "inherit",
|
|
100
172
|
});
|
|
173
|
+
let stdout = "";
|
|
174
|
+
if (captureStdout) {
|
|
175
|
+
child.stdout.on("data", (chunk) => {
|
|
176
|
+
stdout += chunk;
|
|
177
|
+
process.stdout.write(chunk);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
101
180
|
child.on("error", reject);
|
|
102
181
|
child.on("close", (code) => {
|
|
103
|
-
resolve(typeof code === "number" ? code : 1);
|
|
182
|
+
resolve({ code: typeof code === "number" ? code : 1, stdout });
|
|
104
183
|
});
|
|
105
184
|
});
|
|
106
185
|
}
|
|
107
186
|
export async function main(argv = process.argv.slice(2), runtime = {}) {
|
|
108
|
-
|
|
187
|
+
// Last occurrence wins, same as the --draft handling in buildCreatePrArgs.
|
|
188
|
+
const lastLightweightToken = argv.filter((token) => LIGHTWEIGHT_FLAG_PATTERN.test(token)).at(-1) ?? null;
|
|
189
|
+
const lightweight = lastLightweightToken === "--lightweight" ||
|
|
190
|
+
(typeof lastLightweightToken === "string" && TRUE_FLAG_VALUE_PATTERN.test(lastLightweightToken.slice("--lightweight=".length)));
|
|
191
|
+
const forwardedArgv = argv.filter((token) => !LIGHTWEIGHT_FLAG_PATTERN.test(token));
|
|
192
|
+
const { help, ghArgs } = buildCreatePrArgs(forwardedArgv);
|
|
109
193
|
if (help) {
|
|
110
194
|
process.stdout.write(`${USAGE}\n`);
|
|
111
195
|
return 0;
|
|
112
196
|
}
|
|
113
|
-
await
|
|
114
|
-
|
|
197
|
+
const body = await resolveBody(forwardedArgv);
|
|
198
|
+
warnMissingClosingKeyword(body);
|
|
199
|
+
// Issue-less lightweight: caller signals lightweight AND an explicit body
|
|
200
|
+
// source (--body/--body-file) carries no closing keyword. A tracker-backed
|
|
201
|
+
// lightweight PR (closing keyword present) never reaches the board — its
|
|
202
|
+
// issue already owns the board entry. A null body (no explicit source) is
|
|
203
|
+
// NOT classified issue-less: the body may come from elsewhere (editor,
|
|
204
|
+
// template) and could carry a closing keyword this wrapper never saw, so it
|
|
205
|
+
// fails toward not enqueuing and reports body-not-provided instead.
|
|
206
|
+
const issueLess = lightweight && body !== null && !detectClosingKeyword(body);
|
|
207
|
+
const { code, stdout } = await spawnCreatePr(ghArgs, runtime, { captureStdout: issueLess });
|
|
208
|
+
if (lightweight && code === 0 && (issueLess || body === null)) {
|
|
209
|
+
const board = issueLess
|
|
210
|
+
? await enqueueIssuelessLightweightPr({
|
|
211
|
+
repo: getFlagValue(forwardedArgv, REPO_FLAG_PATTERN),
|
|
212
|
+
prNumber: parsePrNumberFromOutput(stdout),
|
|
213
|
+
cwd: runtime.cwd ?? process.cwd(),
|
|
214
|
+
env: runtime.env ?? process.env,
|
|
215
|
+
runChild: runtime.runChild ?? _runChild,
|
|
216
|
+
})
|
|
217
|
+
: { enqueued: false, reason: "body-not-provided" };
|
|
218
|
+
if (!board.enqueued) {
|
|
219
|
+
process.stderr.write(`[create-pr] Board note: PR not enqueued (${board.reason}).\n`);
|
|
220
|
+
}
|
|
221
|
+
process.stdout.write(`${JSON.stringify({ board })}\n`);
|
|
222
|
+
}
|
|
223
|
+
return code;
|
|
115
224
|
}
|
|
116
225
|
if (isDirectCliRun(import.meta.url)) {
|
|
117
226
|
try {
|
|
@@ -11,11 +11,11 @@ import {
|
|
|
11
11
|
} from "../_core-helpers.mjs";
|
|
12
12
|
import { access, readFile } from "node:fs/promises";
|
|
13
13
|
import path from "node:path";
|
|
14
|
-
import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
|
|
14
|
+
import { parsePrNumber, requireTokenValue, runChild as defaultRunChild } from "../_cli-primitives.mjs";
|
|
15
15
|
import { fetchGithubReviewThreadsPayload } from "./capture-review-threads.mjs";
|
|
16
16
|
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
17
|
-
import { FANOUT_PROVENANCE_MIN_REVIEWERS, GATE_FULL_LABEL, loadDevLoopConfig, resolveGateConfig, resolveLightMode, resolveRequireFanoutEvidence, resolveRequireFanoutProvenance } from "@dev-loops/core/config";
|
|
18
|
-
import { FANOUT_UNAVAILABLE_MESSAGE, provenanceConsistencyError } from "@dev-loops/core/loop/gate-fanin";
|
|
17
|
+
import { FANOUT_PROVENANCE_MIN_REVIEWERS, GATE_FULL_LABEL, loadDevLoopConfig, resolveGateAngleContract, resolveGateConfig, resolveLightMode, resolveRejectForeignAngles, resolveRequireFanoutEvidence, resolveRequireFanoutProvenance } from "@dev-loops/core/config";
|
|
18
|
+
import { FANOUT_UNAVAILABLE_MESSAGE, checkFanoutAngleCoverage, provenanceConsistencyError } from "@dev-loops/core/loop/gate-fanin";
|
|
19
19
|
import { detectMergeBaseScope, isEligibleForLightMode } from "../loop/detect-change-scope.mjs";
|
|
20
20
|
import { buildLogPath } from "./write-gate-findings-log.mjs";
|
|
21
21
|
import { ensureAsyncRunnerOwnership } from "../loop/_pr-runner-coordination.mjs";
|
|
@@ -148,7 +148,7 @@ export function parseDetectCheckpointEvidenceCliArgs(argv) {
|
|
|
148
148
|
}
|
|
149
149
|
return options;
|
|
150
150
|
}
|
|
151
|
-
async function runGhJson(args, { env, ghCommand }) {
|
|
151
|
+
async function runGhJson(args, { env, ghCommand, runChild = defaultRunChild }) {
|
|
152
152
|
const result = await runChild(ghCommand, args, env);
|
|
153
153
|
if (result.code !== 0) {
|
|
154
154
|
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
@@ -240,6 +240,7 @@ function normalizeGateMarkerSummary(summary) {
|
|
|
240
240
|
}
|
|
241
241
|
export function buildPreMergeGateCheck(evidence, unresolvedThreadCount = null, staleRunnerCheck = null, fanoutEnforcement = null) {
|
|
242
242
|
const failures = [];
|
|
243
|
+
const warnings = [];
|
|
243
244
|
if (!(evidence.draftGate.visible && evidence.draftGate.verdict === "clean")) {
|
|
244
245
|
failures.push("missing visible clean draft_gate comment");
|
|
245
246
|
}
|
|
@@ -310,6 +311,42 @@ export function buildPreMergeGateCheck(evidence, unresolvedThreadCount = null, s
|
|
|
310
311
|
);
|
|
311
312
|
}
|
|
312
313
|
}
|
|
314
|
+
// Angle-coverage enforcement: independent of requireFanoutProvenance.
|
|
315
|
+
// When the gate configures mandatory angles, a fanout_fanin ledger MUST
|
|
316
|
+
// record internally-consistent provenance — otherwise a shadow ledger
|
|
317
|
+
// that simply omits provenance would bypass mandatory-angle coverage.
|
|
318
|
+
// Recorded provenance is then re-validated: perAngle must cover every
|
|
319
|
+
// mandatory angle and (default) stay within the configured pool. Gates
|
|
320
|
+
// without mandatory angles keep today's behavior (absent provenance adds
|
|
321
|
+
// no failure; that stricter gap is requireFanoutProvenance's opt-in).
|
|
322
|
+
if (gate.executionMode === "fanout_fanin") {
|
|
323
|
+
const mandatoryAngles = gate.mandatoryAngles ?? [];
|
|
324
|
+
const provValid = gate.provenance != null && provenanceConsistencyError(gate.provenance) === null;
|
|
325
|
+
if (mandatoryAngles.length > 0 && !provValid) {
|
|
326
|
+
failures.push(
|
|
327
|
+
`${gate.name}: mandatory angle coverage is configured (${mandatoryAngles.join(", ")}) but the findings-log ledger records no valid fan-out provenance to verify it against; write the ledger with --provenance covering the mandatory angles; ${FANOUT_UNAVAILABLE_MESSAGE}`,
|
|
328
|
+
);
|
|
329
|
+
} else if (provValid) {
|
|
330
|
+
const { missingMandatory, foreignAngles } = checkFanoutAngleCoverage(gate.provenance.perAngle, {
|
|
331
|
+
mandatoryAngles,
|
|
332
|
+
pool: gate.anglePool ?? null,
|
|
333
|
+
});
|
|
334
|
+
if (missingMandatory.length > 0) {
|
|
335
|
+
failures.push(
|
|
336
|
+
`${gate.name}: fan-out provenance is missing mandatory angle(s): ${missingMandatory.join(", ")}; ${FANOUT_UNAVAILABLE_MESSAGE}`,
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
if (foreignAngles.length > 0) {
|
|
340
|
+
const message = `${gate.name}: fan-out provenance names angle(s) outside the configured pool: ${foreignAngles.join(", ")}`;
|
|
341
|
+
if (fanoutEnforcement.rejectForeignAngles ?? true) {
|
|
342
|
+
failures.push(`${message}; ${FANOUT_UNAVAILABLE_MESSAGE}`);
|
|
343
|
+
} else {
|
|
344
|
+
// rejectForeignAngles: false is WARNING mode, not silence.
|
|
345
|
+
warnings.push(`${message} (gates.rejectForeignAngles is false; recorded as a warning)`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
313
350
|
}
|
|
314
351
|
}
|
|
315
352
|
if (typeof unresolvedThreadCount === "number" && unresolvedThreadCount !== 0) {
|
|
@@ -327,6 +364,8 @@ export function buildPreMergeGateCheck(evidence, unresolvedThreadCount = null, s
|
|
|
327
364
|
return {
|
|
328
365
|
ok: failures.length === 0,
|
|
329
366
|
failures,
|
|
367
|
+
// Additive: only present when non-empty, preserving the existing {ok, failures} shape.
|
|
368
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
330
369
|
};
|
|
331
370
|
}
|
|
332
371
|
async function ledgerExists(fullPath) {
|
|
@@ -354,14 +393,27 @@ async function ledgerExistsInAny(checkouts, ledgerPath) {
|
|
|
354
393
|
* Read the recorded fan-out `provenance` object from a ledger across the
|
|
355
394
|
* enumerated checkouts. Mirrors ledgerExistsInAny's "ANY checkout satisfies"
|
|
356
395
|
* semantics: prefers the FIRST checkout whose ledger provenance actually
|
|
357
|
-
* SATISFIES enforcement
|
|
358
|
-
*
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
*
|
|
396
|
+
* SATISFIES the FULL active enforcement — internally consistent, meeting the
|
|
397
|
+
* distinctReviewers floor when requireFanoutProvenance is on, AND passing the
|
|
398
|
+
* gate's angle contract (mandatory-angle coverage; pool membership when
|
|
399
|
+
* foreign angles are rejected) — so a stale checkout's below-floor,
|
|
400
|
+
* provenance-less, or angle-contract-failing ledger cannot SHADOW a valid one
|
|
401
|
+
* in the PR worktree (which would falsely fail closed). Falls back to the
|
|
402
|
+
* first non-null provenance (for a useful diagnostic message) only when NO
|
|
403
|
+
* checkout satisfies, and null only when none is present. Called whenever
|
|
404
|
+
* requireFanoutProvenance is enabled OR the gate's verdict is fanout_fanin —
|
|
405
|
+
* inline verdicts never trigger this read.
|
|
363
406
|
*/
|
|
364
|
-
async function readLedgerProvenanceInAny(checkouts, ledgerPath) {
|
|
407
|
+
async function readLedgerProvenanceInAny(checkouts, ledgerPath, criteria = {}) {
|
|
408
|
+
const { requireProvenance = false, mandatoryAngles = [], anglePool = null, rejectForeignAngles = true } = criteria;
|
|
409
|
+
const satisfies = (prov) => {
|
|
410
|
+
if (provenanceConsistencyError(prov) !== null) return false;
|
|
411
|
+
if (requireProvenance && prov.distinctReviewers < FANOUT_PROVENANCE_MIN_REVIEWERS) return false;
|
|
412
|
+
const { missingMandatory, foreignAngles } = checkFanoutAngleCoverage(prov.perAngle, { mandatoryAngles, pool: anglePool });
|
|
413
|
+
if (missingMandatory.length > 0) return false;
|
|
414
|
+
if (foreignAngles.length > 0 && rejectForeignAngles) return false;
|
|
415
|
+
return true;
|
|
416
|
+
};
|
|
365
417
|
let firstNonNull = null;
|
|
366
418
|
for (const root of checkouts) {
|
|
367
419
|
const full = path.resolve(root, ledgerPath);
|
|
@@ -369,8 +421,8 @@ async function readLedgerProvenanceInAny(checkouts, ledgerPath) {
|
|
|
369
421
|
const parsed = JSON.parse(await readFile(full, "utf8"));
|
|
370
422
|
const prov = parsed && typeof parsed === "object" ? parsed.provenance : null;
|
|
371
423
|
if (prov == null) continue; // ledger present but no provenance — keep scanning.
|
|
372
|
-
if (
|
|
373
|
-
return prov; // satisfying ledger — prefer it over any earlier
|
|
424
|
+
if (satisfies(prov)) {
|
|
425
|
+
return prov; // satisfying ledger — prefer it over any earlier failing one.
|
|
374
426
|
}
|
|
375
427
|
if (firstNonNull === null) firstNonNull = prov; // remember for diagnostics.
|
|
376
428
|
} catch {
|
|
@@ -413,17 +465,33 @@ export async function buildFanoutEnforcement({ repo, pr, currentHeadSha, draftGa
|
|
|
413
465
|
// Provenance enforcement is opt-in and layered ON TOP of fan-out evidence: it
|
|
414
466
|
// only takes effect while evidence enforcement (above) is active.
|
|
415
467
|
const requireProvenance = resolveRequireFanoutProvenance(config);
|
|
468
|
+
// Angle-coverage enforcement (mandatory angles + pool membership) is layered
|
|
469
|
+
// independently of requireProvenance: it re-validates whatever provenance a
|
|
470
|
+
// fanout_fanin ledger actually recorded, regardless of that opt-in flag.
|
|
471
|
+
const rejectForeignAngles = resolveRejectForeignAngles(config);
|
|
416
472
|
// Light-mode facts (#1174): the threshold that a re-derived merge-base scope
|
|
417
473
|
// must fall under for an inline verdict to be accepted. null when lightMode is
|
|
418
474
|
// disabled → no inline verdict can ever be accepted (scopeUnderThreshold stays
|
|
419
475
|
// false), preserving today's rejection.
|
|
420
476
|
const lightThreshold = resolveLightMode(config);
|
|
421
477
|
const lightMode = lightThreshold != null;
|
|
422
|
-
const
|
|
423
|
-
const
|
|
478
|
+
const draftGateConfig = resolveGateConfig(config, "draft");
|
|
479
|
+
const preApprovalGateConfig = resolveGateConfig(config, "preApproval");
|
|
480
|
+
// Shared angle-contract resolver (exclude-filtered mandatory angles +
|
|
481
|
+
// additive-aware pool) — the same contract the write paths enforce. The
|
|
482
|
+
// field names here (`mandatoryAngles`/`anglePool`) are exactly what
|
|
483
|
+
// buildPreMergeGateCheck reads off each gate entry.
|
|
484
|
+
const buildAngleFields = (gateKey) => {
|
|
485
|
+
const { mandatoryAngles, pool } = resolveGateAngleContract(config, gateKey);
|
|
486
|
+
return { mandatoryAngles, anglePool: pool };
|
|
487
|
+
};
|
|
488
|
+
const GATE_ANGLE_CONFIG = {
|
|
489
|
+
draft_gate: buildAngleFields("draft"),
|
|
490
|
+
pre_approval_gate: buildAngleFields("preApproval"),
|
|
491
|
+
};
|
|
424
492
|
const gateSpecs = [
|
|
425
|
-
{ name: "draft_gate", marker: draftGateMarker, required:
|
|
426
|
-
{ name: "pre_approval_gate", marker: preApprovalGateMarker, required:
|
|
493
|
+
{ name: "draft_gate", marker: draftGateMarker, required: draftGateConfig.required },
|
|
494
|
+
{ name: "pre_approval_gate", marker: preApprovalGateMarker, required: preApprovalGateConfig.required },
|
|
427
495
|
].filter((spec) => spec.required && spec.marker.visible);
|
|
428
496
|
const gates = [];
|
|
429
497
|
const checkouts = resolveLedgerCheckouts(cwd);
|
|
@@ -444,6 +512,13 @@ export async function buildFanoutEnforcement({ repo, pr, currentHeadSha, draftGa
|
|
|
444
512
|
const scope = detectMergeBaseScope({ base: baseRef, head: headSha, cwd: repoRoot });
|
|
445
513
|
scopeUnderThreshold = scope.ok === true && isEligibleForLightMode(scope, lightThreshold);
|
|
446
514
|
}
|
|
515
|
+
// Read ledger provenance for ANY fanout_fanin gate (not just when
|
|
516
|
+
// requireProvenance is on): angle-coverage enforcement re-validates
|
|
517
|
+
// whatever provenance is recorded independently of that opt-in flag.
|
|
518
|
+
// The selection criteria mirror the full active enforcement so a stale
|
|
519
|
+
// checkout's contract-failing ledger cannot shadow a passing one.
|
|
520
|
+
const readProvenance = requireProvenance || spec.marker.executionMode === "fanout_fanin";
|
|
521
|
+
const angleFields = GATE_ANGLE_CONFIG[spec.name];
|
|
447
522
|
gates.push({
|
|
448
523
|
name: spec.name,
|
|
449
524
|
executionMode: spec.marker.executionMode ?? null,
|
|
@@ -451,12 +526,15 @@ export async function buildFanoutEnforcement({ repo, pr, currentHeadSha, draftGa
|
|
|
451
526
|
scopeUnderThreshold,
|
|
452
527
|
ledgerPath,
|
|
453
528
|
ledgerExists: await ledgerExistsInAny(checkouts, ledgerPath),
|
|
454
|
-
provenance:
|
|
529
|
+
provenance: readProvenance
|
|
530
|
+
? await readLedgerProvenanceInAny(checkouts, ledgerPath, { requireProvenance, rejectForeignAngles, ...angleFields })
|
|
531
|
+
: null,
|
|
532
|
+
...angleFields,
|
|
455
533
|
});
|
|
456
534
|
}
|
|
457
|
-
return { required: true, requireProvenance, lightMode, hasFullLabel, gates };
|
|
535
|
+
return { required: true, requireProvenance, rejectForeignAngles, lightMode, hasFullLabel, gates };
|
|
458
536
|
}
|
|
459
|
-
export async function detectCheckpointEvidence(options, { env = process.env, ghCommand = "gh", cwd = process.cwd() } = {}) {
|
|
537
|
+
export async function detectCheckpointEvidence(options, { env = process.env, ghCommand = "gh", runChild = defaultRunChild, cwd = process.cwd() } = {}) {
|
|
460
538
|
const runnerOwnership = await ensureAsyncRunnerOwnership({
|
|
461
539
|
repo: options.repo,
|
|
462
540
|
pr: options.pr,
|
|
@@ -480,8 +558,8 @@ export async function detectCheckpointEvidence(options, { env = process.env, ghC
|
|
|
480
558
|
error.staleRunner = staleRunnerDetection;
|
|
481
559
|
throw error;
|
|
482
560
|
}
|
|
483
|
-
const prPayload = await runGhJson(["pr", "view", String(options.pr), "--repo", options.repo, "--json", "headRefOid"], { env, ghCommand });
|
|
484
|
-
const commentsPayload = normalizeIssueCommentsPayload(await runGhJson(["api", "--paginate", "--slurp", `repos/${options.repo}/issues/${options.pr}/comments?per_page=100`], { env, ghCommand }));
|
|
561
|
+
const prPayload = await runGhJson(["pr", "view", String(options.pr), "--repo", options.repo, "--json", "headRefOid"], { env, ghCommand, runChild });
|
|
562
|
+
const commentsPayload = normalizeIssueCommentsPayload(await runGhJson(["api", "--paginate", "--slurp", `repos/${options.repo}/issues/${options.pr}/comments?per_page=100`], { env, ghCommand, runChild }));
|
|
485
563
|
const currentHeadSha = typeof prPayload?.headRefOid === "string" && prPayload.headRefOid.trim().length > 0
|
|
486
564
|
? prPayload.headRefOid.trim()
|
|
487
565
|
: null;
|
|
@@ -493,7 +571,7 @@ export async function detectCheckpointEvidence(options, { env = process.env, ghC
|
|
|
493
571
|
// as a PR review rather than an issue comment (root cause 3 from issue #692).
|
|
494
572
|
let prReviews = [];
|
|
495
573
|
try {
|
|
496
|
-
const reviewsRaw = await runGhJson(["api", "--paginate", "--slurp", `repos/${options.repo}/pulls/${options.pr}/reviews?per_page=100`], { env, ghCommand });
|
|
574
|
+
const reviewsRaw = await runGhJson(["api", "--paginate", "--slurp", `repos/${options.repo}/pulls/${options.pr}/reviews?per_page=100`], { env, ghCommand, runChild });
|
|
497
575
|
prReviews = normalizePrReviewsPayload(reviewsRaw);
|
|
498
576
|
} catch {
|
|
499
577
|
// Graceful fallback: PR reviews fetch failure is non-fatal.
|
|
@@ -523,7 +601,7 @@ export async function detectCheckpointEvidence(options, { env = process.env, ghC
|
|
|
523
601
|
);
|
|
524
602
|
if (config != null && resolveRequireFanoutEvidence(config) && resolveLightMode(config) != null && anyInlineVerdict) {
|
|
525
603
|
try {
|
|
526
|
-
const lightFacts = await runGhJson(["pr", "view", String(options.pr), "--repo", options.repo, "--json", "baseRefOid,labels"], { env, ghCommand });
|
|
604
|
+
const lightFacts = await runGhJson(["pr", "view", String(options.pr), "--repo", options.repo, "--json", "baseRefOid,labels"], { env, ghCommand, runChild });
|
|
527
605
|
baseRef = typeof lightFacts?.baseRefOid === "string" && lightFacts.baseRefOid.trim().length > 0
|
|
528
606
|
? lightFacts.baseRefOid.trim()
|
|
529
607
|
: null;
|
|
@@ -614,6 +692,13 @@ async function main() {
|
|
|
614
692
|
process.exitCode = 1;
|
|
615
693
|
return;
|
|
616
694
|
}
|
|
695
|
+
// Warnings (e.g. foreign angles under gates.rejectForeignAngles: false) do
|
|
696
|
+
// not fail the check but must not pass silently. Suppressed under --silent.
|
|
697
|
+
if (Array.isArray(preMergeGateCheck.warnings) && !options.silent) {
|
|
698
|
+
for (const warning of preMergeGateCheck.warnings) {
|
|
699
|
+
process.stderr.write(`WARNING: ${warning}\n`);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
617
702
|
process.exitCode = emitResult(output, { jq: options.jq, silent: options.silent });
|
|
618
703
|
} catch (error) {
|
|
619
704
|
if (error && typeof error === "object" && "staleRunner" in error && error.staleRunner) {
|