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,171 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
|
|
4
|
+
import { parseNonNegativeInteger, parsePositiveInteger, parsePrNumber, requireTokenValue } from "../_cli-primitives.mjs";
|
|
5
|
+
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
6
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
|
|
7
|
+
import {
|
|
8
|
+
DEFAULT_POLL_INTERVAL_MS,
|
|
9
|
+
COPILOT_REVIEW_WAIT_TIMEOUT_MS,
|
|
10
|
+
} from "@dev-loops/core/loop/policy-constants";
|
|
11
|
+
// Reuse probe-ci-status.mjs's watch engine verbatim (polling, heartbeats, the
|
|
12
|
+
// not-yet-registered-check grace/race guard, head-change detection) instead of
|
|
13
|
+
// re-implementing it. This wrapper only adds seconds-based flags and a direct
|
|
14
|
+
// process-exit-code contract for shell/scripted callers.
|
|
15
|
+
import { watchCiStatus } from "./probe-ci-status.mjs";
|
|
16
|
+
|
|
17
|
+
const DEFAULT_TIMEOUT_SECONDS = Math.floor(COPILOT_REVIEW_WAIT_TIMEOUT_MS / 1000);
|
|
18
|
+
const DEFAULT_POLL_SECONDS = Math.floor(DEFAULT_POLL_INTERVAL_MS / 1000);
|
|
19
|
+
|
|
20
|
+
const USAGE = `Usage: wait-pr-checks.mjs --repo <owner/name> --pr <number> [--timeout <seconds>] [--poll <seconds>]
|
|
21
|
+
Block until the current head SHA's CI checks/statuses settle, or the wait
|
|
22
|
+
budget elapses. Replaces hand-rolled \`until [ "$(gh pr checks | grep -c pending)" = 0 ]\`
|
|
23
|
+
shell loops with a single deterministic call.
|
|
24
|
+
Required:
|
|
25
|
+
--repo <owner/name> Repository slug (e.g. owner/repo)
|
|
26
|
+
--pr <number> Pull request number
|
|
27
|
+
Optional:
|
|
28
|
+
--timeout <seconds> Total wait budget (default ${DEFAULT_TIMEOUT_SECONDS}; 0 = single
|
|
29
|
+
immediate check, no wait)
|
|
30
|
+
--poll <seconds> Delay between polls (default ${DEFAULT_POLL_SECONDS})
|
|
31
|
+
Not-yet-registered-check race guard:
|
|
32
|
+
A freshly pushed head with zero registered checks does NOT settle green on
|
|
33
|
+
the first poll — a provider may post its first check a beat after the push.
|
|
34
|
+
The watcher requires either an observed check/status, or a stable empty
|
|
35
|
+
result across two consecutive polls, before treating the head as genuinely
|
|
36
|
+
check-less. A repo with no CI at all still settles after that grace instead
|
|
37
|
+
of hanging to timeout.
|
|
38
|
+
Output (stdout, JSON, the final per-check summary):
|
|
39
|
+
{ "ok": true, "status": "success"|"failure"|"pending"|"timeout"|"changed",
|
|
40
|
+
"settled": bool, "ciStatus": "success"|"failure"|"pending"|"none",
|
|
41
|
+
"failedChecks": [{ "name": "...", "conclusion"?: "..." }], "headSha": "...", "attempts": N }
|
|
42
|
+
"changed" means the head SHA advanced during the wait; re-baseline and re-run.
|
|
43
|
+
Diagnostic output (stderr):
|
|
44
|
+
{ "ok": true, "type": "watch_heartbeat", "elapsedMs": N, "totalBudgetMs": N, "poll": N, "maxPolls": N }
|
|
45
|
+
{ "ok": false, "error": "...", "usage"?: "..." }
|
|
46
|
+
${JQ_OUTPUT_USAGE}
|
|
47
|
+
Exit codes (default output, no --jq/--silent):
|
|
48
|
+
0 Green (status "success")
|
|
49
|
+
1 Red (status "failure"), or an argument/gh/runtime error
|
|
50
|
+
2 Not settled (status "timeout"/"changed"/"pending")
|
|
51
|
+
With --jq/--silent, the standard jq-output contract above applies instead
|
|
52
|
+
(0/1 by result truthiness; 2 reserved for an invalid --jq filter).`.trim();
|
|
53
|
+
|
|
54
|
+
const parseError = buildParseError(USAGE);
|
|
55
|
+
|
|
56
|
+
export function parseWaitPrChecksCliArgs(argv) {
|
|
57
|
+
const { tokens } = parseArgs({
|
|
58
|
+
args: [...argv],
|
|
59
|
+
options: {
|
|
60
|
+
help: { type: "boolean", short: "h" },
|
|
61
|
+
repo: { type: "string" },
|
|
62
|
+
pr: { type: "string" },
|
|
63
|
+
timeout: { type: "string" },
|
|
64
|
+
poll: { type: "string" },
|
|
65
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
66
|
+
},
|
|
67
|
+
allowPositionals: true,
|
|
68
|
+
strict: false,
|
|
69
|
+
tokens: true,
|
|
70
|
+
});
|
|
71
|
+
const options = {
|
|
72
|
+
help: false,
|
|
73
|
+
repo: undefined,
|
|
74
|
+
pr: undefined,
|
|
75
|
+
timeoutMs: DEFAULT_TIMEOUT_SECONDS * 1000,
|
|
76
|
+
pollIntervalMs: DEFAULT_POLL_SECONDS * 1000,
|
|
77
|
+
};
|
|
78
|
+
for (const token of tokens) {
|
|
79
|
+
if (token.kind === "positional") {
|
|
80
|
+
throw parseError(`Unknown argument: ${token.value}`);
|
|
81
|
+
}
|
|
82
|
+
if (token.kind !== "option") {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (token.name === "help") {
|
|
86
|
+
options.help = true;
|
|
87
|
+
return options;
|
|
88
|
+
}
|
|
89
|
+
if (token.name === "repo") {
|
|
90
|
+
options.repo = requireTokenValue(token, parseError).trim();
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (token.name === "pr") {
|
|
94
|
+
options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (token.name === "timeout") {
|
|
98
|
+
// Canonical-digits validation (shared /^\d+$/ primitives): rejects
|
|
99
|
+
// JS-coercible spellings like 1e3, 0x10, "1.0", or " 5 ".
|
|
100
|
+
options.timeoutMs = parseNonNegativeInteger(requireTokenValue(token, parseError), "--timeout", parseError) * 1000;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (token.name === "poll") {
|
|
104
|
+
options.pollIntervalMs = parsePositiveInteger(requireTokenValue(token, parseError), "--poll", parseError) * 1000;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
|
|
108
|
+
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
109
|
+
}
|
|
110
|
+
if (options.repo === undefined || options.pr === undefined) {
|
|
111
|
+
throw parseError("Waiting on PR checks requires both --repo <owner/name> and --pr <number>");
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
parseRepoSlug(options.repo);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
throw parseError(error instanceof Error ? error.message : String(error));
|
|
117
|
+
}
|
|
118
|
+
return options;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Direct process-exit-code contract for shell/scripted callers — this is the
|
|
122
|
+
// tool's reason to exist alongside `dev-loops loop watch-ci`, whose CLI always
|
|
123
|
+
// exits 0 and expects callers to branch on the JSON `status` field instead.
|
|
124
|
+
export function exitCodeForWaitResult(result) {
|
|
125
|
+
if (result.status === "success") return 0;
|
|
126
|
+
if (result.status === "failure") return 1;
|
|
127
|
+
return 2;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function runCli(
|
|
131
|
+
argv = process.argv.slice(2),
|
|
132
|
+
{
|
|
133
|
+
stdout = process.stdout,
|
|
134
|
+
stderr = process.stderr,
|
|
135
|
+
env = process.env,
|
|
136
|
+
ghCommand = "gh",
|
|
137
|
+
delayImpl = undefined,
|
|
138
|
+
now = undefined,
|
|
139
|
+
} = {},
|
|
140
|
+
) {
|
|
141
|
+
const options = parseWaitPrChecksCliArgs(argv);
|
|
142
|
+
if (options.help) {
|
|
143
|
+
stdout.write(`${USAGE}\n`);
|
|
144
|
+
return 0;
|
|
145
|
+
}
|
|
146
|
+
const result = await watchCiStatus(
|
|
147
|
+
{ repo: options.repo, pr: options.pr, timeoutMs: options.timeoutMs, pollIntervalMs: options.pollIntervalMs },
|
|
148
|
+
{
|
|
149
|
+
env,
|
|
150
|
+
ghCommand,
|
|
151
|
+
...(delayImpl ? { delayImpl } : {}),
|
|
152
|
+
...(now ? { now } : {}),
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
if (options.jq !== undefined || options.silent) {
|
|
156
|
+
return emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr, ok: result.status === "success" });
|
|
157
|
+
}
|
|
158
|
+
stdout.write(`${JSON.stringify(result)}\n`);
|
|
159
|
+
return exitCodeForWaitResult(result);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
163
|
+
runCli().then((code) => {
|
|
164
|
+
if (typeof code === "number") {
|
|
165
|
+
process.exitCode = code;
|
|
166
|
+
}
|
|
167
|
+
}).catch((error) => {
|
|
168
|
+
process.stderr.write(`${formatCliError(error)}\n`);
|
|
169
|
+
process.exitCode = 1;
|
|
170
|
+
});
|
|
171
|
+
}
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
* <tmpRoot>/gate-context/<repo-slug>/pr-<N>/<gate>-<headSha>.json
|
|
30
30
|
*/
|
|
31
31
|
import { execFileSync } from "node:child_process";
|
|
32
|
+
import { createHash } from "node:crypto";
|
|
32
33
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
33
34
|
import path from "node:path";
|
|
34
35
|
import { parseArgs } from "node:util";
|
|
@@ -38,6 +39,7 @@ import { resolveGateAnglesDynamic } from "@dev-loops/core/config";
|
|
|
38
39
|
import { parsePrNumber, requireTokenValue } from "../_cli-primitives.mjs";
|
|
39
40
|
import { formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
|
|
40
41
|
import { buildAdjacentBundle, DEFAULT_MAX_FILE_BYTES } from "./build-adjacent-bundle.mjs";
|
|
42
|
+
import { GATE_NAMES } from "./_gate-names.mjs";
|
|
41
43
|
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
|
|
42
44
|
|
|
43
45
|
/**
|
|
@@ -117,8 +119,10 @@ Optional:
|
|
|
117
119
|
--branch <name> Source branch name
|
|
118
120
|
--touched-files <json> JSON array of changed file path strings (separate from the diff-derived scope.changedFiles)
|
|
119
121
|
--base <ref> Git ref to diff against (git diff <ref>...HEAD); populates scope.diffPath, scope.changedFiles, and adjacentCode (the full build-once bundle). Without it, the CLI emits an explicit thin briefing (scope.diffSource="none") — see docs/gate-review-sub-loop-contract.md.
|
|
120
|
-
--acceptance-criteria <ptr> Pointer to acceptance criteria (issue ref, doc path, URL)
|
|
122
|
+
--acceptance-criteria <ptr> Pointer to acceptance criteria (issue ref, doc path, URL); also used as the linked-issue label in the rendered briefing prefix
|
|
121
123
|
--validation-posture <text> Short description of the validation posture
|
|
124
|
+
--pr-body <text> PR description text, inlined into the rendered briefing prefix
|
|
125
|
+
--issue-body <text> Linked-issue body text, inlined into the briefing prefix under --acceptance-criteria's label; omitted entirely when absent
|
|
122
126
|
--tmp-root <path> Root tmp directory (default: tmp/)
|
|
123
127
|
|
|
124
128
|
${JQ_OUTPUT_USAGE}
|
|
@@ -129,7 +133,7 @@ function parseError(message) {
|
|
|
129
133
|
}
|
|
130
134
|
|
|
131
135
|
function normalizeGate(value) {
|
|
132
|
-
const gates = new Set(
|
|
136
|
+
const gates = new Set(GATE_NAMES);
|
|
133
137
|
const normalized = String(value).trim().toLowerCase();
|
|
134
138
|
return gates.has(normalized) ? normalized : null;
|
|
135
139
|
}
|
|
@@ -149,6 +153,9 @@ function normalizeHeadSha(value) {
|
|
|
149
153
|
// with our own "<base>...HEAD" triple-dot construction). Everything else —
|
|
150
154
|
// including HEAD@{upstream}, main@{1}, tag-peel v1.0.0^{commit}, HEAD~3 — is
|
|
151
155
|
// accepted.
|
|
156
|
+
// Revisit toward an explicit allowlist if `base` ever reaches a shell (or any
|
|
157
|
+
// call without execFileSync's no-shell argv guarantee), or if a malformed ref
|
|
158
|
+
// shape is found slipping past these checks into `git diff`.
|
|
152
159
|
function normalizeBaseRef(value) {
|
|
153
160
|
const trimmed = String(value).trim();
|
|
154
161
|
if (trimmed.length === 0 || trimmed.startsWith("-") || trimmed.includes("..")) return null;
|
|
@@ -231,6 +238,8 @@ export function parseWriteGateContextCliArgs(argv) {
|
|
|
231
238
|
base: { type: "string" },
|
|
232
239
|
"acceptance-criteria": { type: "string" },
|
|
233
240
|
"validation-posture": { type: "string" },
|
|
241
|
+
"pr-body": { type: "string" },
|
|
242
|
+
"issue-body": { type: "string" },
|
|
234
243
|
"tmp-root": { type: "string" },
|
|
235
244
|
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
236
245
|
},
|
|
@@ -250,6 +259,8 @@ export function parseWriteGateContextCliArgs(argv) {
|
|
|
250
259
|
base: null,
|
|
251
260
|
acceptanceCriteria: null,
|
|
252
261
|
validationPosture: null,
|
|
262
|
+
prBody: null,
|
|
263
|
+
issueBody: null,
|
|
253
264
|
tmpRoot: "tmp",
|
|
254
265
|
};
|
|
255
266
|
for (const token of tokens) {
|
|
@@ -312,6 +323,14 @@ export function parseWriteGateContextCliArgs(argv) {
|
|
|
312
323
|
options.validationPosture = requireTokenValue(token, parseError).trim();
|
|
313
324
|
continue;
|
|
314
325
|
}
|
|
326
|
+
if (token.name === "pr-body") {
|
|
327
|
+
options.prBody = requireTokenValue(token, parseError);
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (token.name === "issue-body") {
|
|
331
|
+
options.issueBody = requireTokenValue(token, parseError);
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
315
334
|
if (token.name === "tmp-root") {
|
|
316
335
|
options.tmpRoot = requireTokenValue(token, parseError).trim();
|
|
317
336
|
continue;
|
|
@@ -360,8 +379,8 @@ export function buildGateContextPath({ repo, pr, gate, headSha, tmpRoot = "tmp"
|
|
|
360
379
|
* @returns {{ pr: number, gate: string, headSha: string }}
|
|
361
380
|
*/
|
|
362
381
|
function validatePathSegments({ pr, gate, headSha }) {
|
|
363
|
-
if (gate
|
|
364
|
-
throw new Error(`--gate segment ${JSON.stringify(gate)} is unsafe (expected
|
|
382
|
+
if (!GATE_NAMES.includes(gate)) {
|
|
383
|
+
throw new Error(`--gate segment ${JSON.stringify(gate)} is unsafe (expected ${GATE_NAMES.join(" or ")})`);
|
|
365
384
|
}
|
|
366
385
|
// Require a CANONICAL positive integer: the trimmed string must be all digits
|
|
367
386
|
// (`/^\d+$/`) and > 0. This mirrors the CLI's parsePrNumber rule so the path
|
|
@@ -426,6 +445,137 @@ export function buildGateDiffPath({ repo, pr, gate, headSha, tmpRoot = "tmp" })
|
|
|
426
445
|
return path.join(tmpRoot, "gate-context", repoSlug, `pr-${safePr}`, `${safeGate}-${safeSha}.diff`);
|
|
427
446
|
}
|
|
428
447
|
|
|
448
|
+
/**
|
|
449
|
+
* Build the deterministic path for the rendered invariant briefing prefix
|
|
450
|
+
* (GATE-EXEC-BRIEFING-PREFIX): the byte-identical block every per-angle
|
|
451
|
+
* reviewer of this gate pass is seeded with, before their angle-specific
|
|
452
|
+
* suffix. Mirrors buildGateContextPath/buildGateDiffPath. Exported so the
|
|
453
|
+
* fan-out reviewers and `verify-fresh-review-context.mjs --prefix-file` agree
|
|
454
|
+
* on the path with the context-builder.
|
|
455
|
+
*
|
|
456
|
+
* @param {object} input
|
|
457
|
+
* @param {string} input.repo — owner/name
|
|
458
|
+
* @param {number|string} input.pr
|
|
459
|
+
* @param {string} input.gate — draft_gate | pre_approval_gate
|
|
460
|
+
* @param {string} input.headSha
|
|
461
|
+
* @param {string} [input.tmpRoot] — default "tmp"
|
|
462
|
+
* @returns {string} relative briefing-prefix path
|
|
463
|
+
*/
|
|
464
|
+
export function buildGateBriefingPrefixPath({ repo, pr, gate, headSha, tmpRoot = "tmp" }) {
|
|
465
|
+
const repoSlug = repoSlugFor(repo);
|
|
466
|
+
const { pr: safePr, gate: safeGate, headSha: safeSha } = validatePathSegments({ pr, gate, headSha });
|
|
467
|
+
return path.join(tmpRoot, "gate-context", repoSlug, `pr-${safePr}`, `${safeGate}-${safeSha}.briefing-prefix.txt`);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Size cap (bytes) above which the rendered briefing prefix falls back to
|
|
472
|
+
* pointer mode for the diff section (a scope.diffPath reference when present,
|
|
473
|
+
* else an explicit unavailable-pointer disclosure) instead of inlining the
|
|
474
|
+
* diff text in a fenced block. No `gates.*` config knob exists for this
|
|
475
|
+
* yet — a named constant is the right size for a single fixed threshold;
|
|
476
|
+
* promote to config only if a real need for tuning it emerges.
|
|
477
|
+
*/
|
|
478
|
+
export const BRIEFING_PREFIX_INLINE_DIFF_CAP_BYTES = 200 * 1024;
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Render the invariant briefing-prefix text (GATE-EXEC-BRIEFING-PREFIX):
|
|
482
|
+
* header (repo/PR/head/gate/worktree + the mandatory verify-fresh-review-context.mjs
|
|
483
|
+
* instruction), PR body, linked-issue body (when present), the full diff at the
|
|
484
|
+
* reviewed head (inlined up to `capBytes`, else a pointer to `diffPath`), and a
|
|
485
|
+
* changed-files/adjacent-code summary — in that fixed order. Pure and
|
|
486
|
+
* deterministic: identical input always renders identical bytes, so two builds
|
|
487
|
+
* at the same head produce a byte-identical prefix (the fan-out's shared-prefix
|
|
488
|
+
* requirement).
|
|
489
|
+
*
|
|
490
|
+
* @param {object} input
|
|
491
|
+
* @param {string} input.repo
|
|
492
|
+
* @param {number|string} input.pr
|
|
493
|
+
* @param {string} input.gate
|
|
494
|
+
* @param {string} input.headSha
|
|
495
|
+
* @param {string} input.worktreeRoot — absolute path reviewers run in
|
|
496
|
+
* @param {string} input.contextPath — the sibling JSON artifact path
|
|
497
|
+
* @param {string} input.briefingPrefixPath — this rendered file's own path
|
|
498
|
+
* @param {string|null} [input.prBody]
|
|
499
|
+
* @param {string|null} [input.issueRef] — label for the linked-issue section (e.g. "#877")
|
|
500
|
+
* @param {string|null} [input.issueBody] — omit the whole section when null/empty
|
|
501
|
+
* @param {string|null} [input.diffOutput] — full diff text, when captured
|
|
502
|
+
* @param {string|null} [input.diffPath] — persisted `.diff` pointer (pointer-mode fallback)
|
|
503
|
+
* @param {string[]} [input.changedFiles]
|
|
504
|
+
* @param {object|null} [input.adjacentCode] — buildAdjacentBundle output
|
|
505
|
+
* @param {number} [input.capBytes] — default BRIEFING_PREFIX_INLINE_DIFF_CAP_BYTES
|
|
506
|
+
* @returns {{ text: string, prefixMode: "inline"|"pointer", diffBytes: number }}
|
|
507
|
+
*/
|
|
508
|
+
export function renderBriefingPrefix({
|
|
509
|
+
repo, pr, gate, headSha, worktreeRoot, contextPath, briefingPrefixPath,
|
|
510
|
+
prBody = null, issueRef = null, issueBody = null,
|
|
511
|
+
diffOutput = null, diffPath = null, changedFiles = [], adjacentCode = null,
|
|
512
|
+
capBytes = BRIEFING_PREFIX_INLINE_DIFF_CAP_BYTES,
|
|
513
|
+
}) {
|
|
514
|
+
const hasDiffText = typeof diffOutput === "string" && diffOutput.length > 0;
|
|
515
|
+
const diffBytes = hasDiffText ? Buffer.byteLength(diffOutput, "utf8") : 0;
|
|
516
|
+
const prefixMode = hasDiffText && diffBytes > capBytes ? "pointer" : "inline";
|
|
517
|
+
|
|
518
|
+
const lines = [];
|
|
519
|
+
lines.push("# Gate Review Briefing — invariant prefix (GATE-EXEC-BRIEFING-PREFIX)");
|
|
520
|
+
lines.push("");
|
|
521
|
+
lines.push(`repo: ${repo}`);
|
|
522
|
+
lines.push(`pr: #${pr}`);
|
|
523
|
+
lines.push(`gate: ${gate}`);
|
|
524
|
+
lines.push(`head: ${headSha}`);
|
|
525
|
+
lines.push(`worktree: ${worktreeRoot}`);
|
|
526
|
+
lines.push(`prefixMode: ${prefixMode}`);
|
|
527
|
+
lines.push("");
|
|
528
|
+
lines.push(
|
|
529
|
+
`Mandatory: before doing any angle-specific work, run \`node scripts/github/verify-fresh-review-context.mjs --scope ${gate.replace(/_/g, "-")}-<your-angle> --context-path ${contextPath} --prefix-file ${briefingPrefixPath}\`. Refuse to proceed on contamination or a missing artifact.`,
|
|
530
|
+
);
|
|
531
|
+
lines.push("");
|
|
532
|
+
lines.push("## PR body");
|
|
533
|
+
lines.push("");
|
|
534
|
+
lines.push(typeof prBody === "string" && prBody.trim().length > 0 ? prBody.trim() : "(no PR body provided)");
|
|
535
|
+
lines.push("");
|
|
536
|
+
const hasIssueBody = typeof issueBody === "string" && issueBody.trim().length > 0;
|
|
537
|
+
if (hasIssueBody) {
|
|
538
|
+
lines.push(`## Linked issue${issueRef ? ` ${issueRef}` : ""}`);
|
|
539
|
+
lines.push("");
|
|
540
|
+
lines.push(issueBody.trim());
|
|
541
|
+
lines.push("");
|
|
542
|
+
}
|
|
543
|
+
lines.push(`## Diff at reviewed head (${headSha})`);
|
|
544
|
+
lines.push("");
|
|
545
|
+
if (!hasDiffText) {
|
|
546
|
+
lines.push("(no diff text captured for this bundle)");
|
|
547
|
+
} else if (prefixMode === "inline") {
|
|
548
|
+
lines.push("```diff");
|
|
549
|
+
lines.push(diffOutput.endsWith("\n") ? diffOutput.slice(0, -1) : diffOutput);
|
|
550
|
+
lines.push("```");
|
|
551
|
+
} else {
|
|
552
|
+
lines.push(
|
|
553
|
+
`Diff exceeds the ${capBytes}-byte inline cap (${diffBytes} bytes) — pointer mode. Read the full diff from:`,
|
|
554
|
+
);
|
|
555
|
+
lines.push(` ${diffPath ?? "(diff pointer unavailable — re-derive with git diff)"}`);
|
|
556
|
+
}
|
|
557
|
+
lines.push("");
|
|
558
|
+
lines.push("## Changed files + adjacent-code summary");
|
|
559
|
+
lines.push("");
|
|
560
|
+
const files = Array.isArray(changedFiles) ? changedFiles : [];
|
|
561
|
+
lines.push(`Changed files (${files.length}):`);
|
|
562
|
+
for (const f of files) lines.push(`- ${f}`);
|
|
563
|
+
const adjacentFiles = adjacentCode && Array.isArray(adjacentCode.files)
|
|
564
|
+
? adjacentCode.files.filter((f) => f.role !== "changed")
|
|
565
|
+
: [];
|
|
566
|
+
if (adjacentCode) {
|
|
567
|
+
lines.push(`Adjacent files (${adjacentFiles.length}):`);
|
|
568
|
+
for (const f of adjacentFiles) lines.push(`- ${f.path} (${f.role})`);
|
|
569
|
+
lines.push(
|
|
570
|
+
`Stripped: ${adjacentCode.stripped?.length ?? 0}, Truncated: ${adjacentCode.truncated?.length ?? 0}, Missing: ${adjacentCode.missing?.length ?? 0}`,
|
|
571
|
+
);
|
|
572
|
+
} else {
|
|
573
|
+
lines.push("Adjacent files (0): (no adjacent-code bundle for this briefing)");
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
return { text: lines.join("\n") + "\n", prefixMode, diffBytes };
|
|
577
|
+
}
|
|
578
|
+
|
|
429
579
|
/**
|
|
430
580
|
* Parse `git diff --name-status` output into full repo-relative changed file
|
|
431
581
|
* paths. Handles rename/copy entries (R100 old new, C75 old new) by recording
|
|
@@ -500,6 +650,12 @@ export function buildGateContextArtifact(options) {
|
|
|
500
650
|
if (options.adjacentCode && typeof options.adjacentCode === "object") {
|
|
501
651
|
artifact.adjacentCode = options.adjacentCode;
|
|
502
652
|
}
|
|
653
|
+
// Whether the rendered briefing prefix inlined the reviewed-head diff (in a
|
|
654
|
+
// fenced block) or fell back to the diffPath pointer (size cap). Only set
|
|
655
|
+
// when a caller actually rendered a prefix (writeGateContext does, always).
|
|
656
|
+
if (typeof options.prefixMode === "string" && options.prefixMode.length > 0) {
|
|
657
|
+
artifact.prefixMode = options.prefixMode;
|
|
658
|
+
}
|
|
503
659
|
return artifact;
|
|
504
660
|
}
|
|
505
661
|
|
|
@@ -520,7 +676,7 @@ export function buildGateContextArtifact(options) {
|
|
|
520
676
|
* @param {string} input.tmpRoot
|
|
521
677
|
* @param {number} [input.maxFileBytes]
|
|
522
678
|
* @param {{ repoRoot: string }} opts
|
|
523
|
-
* @returns {Promise<{ diffPath: string|null, changedFiles: string[], adjacentCode: object|null }>}
|
|
679
|
+
* @returns {Promise<{ diffPath: string|null, changedFiles: string[], adjacentCode: object|null, diffOutput: string|null }>}
|
|
524
680
|
*/
|
|
525
681
|
async function resolveDiffScope({ diff, repo, pr, gate, headSha, tmpRoot, maxFileBytes }, { repoRoot }) {
|
|
526
682
|
const diffOutput = diff?.diffOutput;
|
|
@@ -562,7 +718,7 @@ async function resolveDiffScope({ diff, repo, pr, gate, headSha, tmpRoot, maxFil
|
|
|
562
718
|
}
|
|
563
719
|
}
|
|
564
720
|
|
|
565
|
-
return { diffPath, changedFiles, adjacentCode };
|
|
721
|
+
return { diffPath, changedFiles, adjacentCode, diffOutput: typeof diffOutput === "string" ? diffOutput : null };
|
|
566
722
|
}
|
|
567
723
|
|
|
568
724
|
/**
|
|
@@ -647,6 +803,19 @@ export function captureDiffFromBase(base, { repoRoot, maxBuffer = 64 * 1024 * 10
|
|
|
647
803
|
return { nameStatusOutput, diffOutput };
|
|
648
804
|
}
|
|
649
805
|
|
|
806
|
+
/**
|
|
807
|
+
* Write both the JSON context artifact AND its sibling rendered briefing
|
|
808
|
+
* prefix (GATE-EXEC-BRIEFING-PREFIX): the byte-identical invariant block every
|
|
809
|
+
* per-angle reviewer of this gate pass is seeded with. The prefix's
|
|
810
|
+
* `prefixMode` (inline|pointer, from the diff-size cap) is recorded on the
|
|
811
|
+
* JSON artifact so both files stay in sync.
|
|
812
|
+
*
|
|
813
|
+
* @param {object} options — parsed CLI options shape, optionally carrying
|
|
814
|
+
* `diffOutput`, `prBody`, `issueBody` (all null-safe; a caller with none of
|
|
815
|
+
* these still gets a valid — if thin — prefix).
|
|
816
|
+
* @param {{ repoRoot?: string }} [runtime]
|
|
817
|
+
* @returns {Promise<{ ok: boolean, path: string, artifact: object, prefixPath: string, prefixHash: string, prefixMode: "inline"|"pointer" }>}
|
|
818
|
+
*/
|
|
650
819
|
export async function writeGateContext(options, { repoRoot = process.cwd() } = {}) {
|
|
651
820
|
const contextPath = buildGateContextPath({
|
|
652
821
|
repo: options.repo,
|
|
@@ -655,14 +824,49 @@ export async function writeGateContext(options, { repoRoot = process.cwd() } = {
|
|
|
655
824
|
headSha: options.headSha,
|
|
656
825
|
tmpRoot: options.tmpRoot || "tmp",
|
|
657
826
|
});
|
|
827
|
+
const briefingPrefixPath = buildGateBriefingPrefixPath({
|
|
828
|
+
repo: options.repo,
|
|
829
|
+
pr: options.pr,
|
|
830
|
+
gate: options.gate,
|
|
831
|
+
headSha: options.headSha,
|
|
832
|
+
tmpRoot: options.tmpRoot || "tmp",
|
|
833
|
+
});
|
|
834
|
+
const rendered = renderBriefingPrefix({
|
|
835
|
+
repo: options.repo,
|
|
836
|
+
pr: options.pr,
|
|
837
|
+
gate: options.gate,
|
|
838
|
+
headSha: options.headSha,
|
|
839
|
+
worktreeRoot: path.resolve(repoRoot),
|
|
840
|
+
contextPath,
|
|
841
|
+
briefingPrefixPath,
|
|
842
|
+
prBody: options.prBody ?? null,
|
|
843
|
+
issueRef: options.acceptanceCriteria ?? null,
|
|
844
|
+
issueBody: options.issueBody ?? null,
|
|
845
|
+
diffOutput: options.diffOutput ?? null,
|
|
846
|
+
diffPath: options.diffPath ?? null,
|
|
847
|
+
changedFiles: options.changedFiles ?? [],
|
|
848
|
+
adjacentCode: options.adjacentCode ?? null,
|
|
849
|
+
});
|
|
850
|
+
|
|
658
851
|
const fullPath = path.resolve(repoRoot, contextPath);
|
|
659
852
|
const artifact = {
|
|
660
|
-
...buildGateContextArtifact(options),
|
|
853
|
+
...buildGateContextArtifact({ ...options, prefixMode: rendered.prefixMode }),
|
|
661
854
|
loggedAt: new Date().toISOString(),
|
|
662
855
|
};
|
|
856
|
+
// Write ORDER matters: the sibling briefing prefix goes first and the JSON
|
|
857
|
+
// artifact last, so the artifact's existence is the completion marker for
|
|
858
|
+
// the whole set. Downstream consumers (readGateContext, the reviewers'
|
|
859
|
+
// --context-path guard) key on the JSON — a prefix-write failure must not
|
|
860
|
+
// leave a complete-looking artifact pointing at a missing prefix file.
|
|
861
|
+
const fullPrefixPath = path.resolve(repoRoot, briefingPrefixPath);
|
|
862
|
+
await mkdir(path.dirname(fullPrefixPath), { recursive: true });
|
|
863
|
+
await writeFile(fullPrefixPath, rendered.text, "utf8");
|
|
864
|
+
const prefixHash = createHash("sha256").update(rendered.text, "utf8").digest("hex");
|
|
865
|
+
|
|
663
866
|
await mkdir(path.dirname(fullPath), { recursive: true });
|
|
664
867
|
await writeFile(fullPath, JSON.stringify(artifact, null, 2) + "\n", "utf8");
|
|
665
|
-
|
|
868
|
+
|
|
869
|
+
return { ok: true, path: contextPath, artifact, prefixPath: briefingPrefixPath, prefixHash, prefixMode: rendered.prefixMode };
|
|
666
870
|
}
|
|
667
871
|
|
|
668
872
|
/**
|
|
@@ -685,10 +889,12 @@ export async function writeGateContext(options, { repoRoot = process.cwd() } = {
|
|
|
685
889
|
* @param {string[]} [input.touchedFiles]
|
|
686
890
|
* @param {string|null} [input.acceptanceCriteria]
|
|
687
891
|
* @param {string|null} [input.validationPosture]
|
|
892
|
+
* @param {string|null} [input.prBody] — PR description text, inlined into the rendered briefing prefix
|
|
893
|
+
* @param {string|null} [input.issueBody] — linked-issue body text, inlined under `acceptanceCriteria`'s label; omitted when absent
|
|
688
894
|
* @param {number} [input.maxFileBytes] — per-file cap for the adjacent-code bundle (default DEFAULT_MAX_FILE_BYTES)
|
|
689
895
|
* @param {string} [input.tmpRoot]
|
|
690
896
|
* @param {{ repoRoot?: string }} [opts]
|
|
691
|
-
* @returns {Promise<{ ok: boolean, path: string, artifact: object, resolver: object }>}
|
|
897
|
+
* @returns {Promise<{ ok: boolean, path: string, artifact: object, prefixPath: string, prefixHash: string, prefixMode: "inline"|"pointer", resolver: object }>}
|
|
692
898
|
*
|
|
693
899
|
* The artifact additionally carries a deterministic, neutral `adjacentCode`
|
|
694
900
|
* bundle (#895) when changed files are present: 1-hop import in/out-edges of the
|
|
@@ -707,7 +913,7 @@ export async function buildGateContext(input, { repoRoot = process.cwd() } = {})
|
|
|
707
913
|
// Diff-derived scope: persisted FULL diff (scope.diffPath), parsed
|
|
708
914
|
// scope.changedFiles, and the neutral adjacentCode bundle, all built ONCE by
|
|
709
915
|
// the shared resolveDiffScope helper (also used by the CLI --base path, #1140).
|
|
710
|
-
const { diffPath, changedFiles, adjacentCode } = await resolveDiffScope(
|
|
916
|
+
const { diffPath, changedFiles, adjacentCode, diffOutput } = await resolveDiffScope(
|
|
711
917
|
{ diff: input.diff, repo: input.repo, pr: input.pr, gate: input.gate, headSha: input.headSha, tmpRoot, maxFileBytes: input.maxFileBytes },
|
|
712
918
|
{ repoRoot },
|
|
713
919
|
);
|
|
@@ -724,9 +930,12 @@ export async function buildGateContext(input, { repoRoot = process.cwd() } = {})
|
|
|
724
930
|
touchedFiles: input.touchedFiles ?? [],
|
|
725
931
|
changedFiles,
|
|
726
932
|
diffPath,
|
|
933
|
+
diffOutput,
|
|
727
934
|
adjacentCode,
|
|
728
935
|
acceptanceCriteria: input.acceptanceCriteria ?? null,
|
|
729
936
|
validationPosture: input.validationPosture ?? null,
|
|
937
|
+
prBody: input.prBody ?? null,
|
|
938
|
+
issueBody: input.issueBody ?? null,
|
|
730
939
|
tmpRoot,
|
|
731
940
|
},
|
|
732
941
|
{ repoRoot },
|
|
@@ -800,6 +1009,7 @@ export async function main(argv = process.argv.slice(2), { repoRoot = process.cw
|
|
|
800
1009
|
options.changedFiles = scope.changedFiles;
|
|
801
1010
|
options.diffPath = scope.diffPath;
|
|
802
1011
|
options.adjacentCode = scope.adjacentCode;
|
|
1012
|
+
options.diffOutput = scope.diffOutput;
|
|
803
1013
|
options.diffSource = "base";
|
|
804
1014
|
} else {
|
|
805
1015
|
process.stderr.write("[write-gate-context] warning: no --base given; emitting a THIN briefing (scope.diffPath=null, scope.changedFiles=[], no adjacentCode). Pass --base <ref> for the full build-once bundle.\n");
|
|
@@ -5,7 +5,8 @@ import { parseArgs } from "node:util";
|
|
|
5
5
|
import { parsePrNumber, requireTokenValue } from "../_cli-primitives.mjs";
|
|
6
6
|
import { formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
|
|
7
7
|
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
|
|
8
|
-
import { provenanceConsistencyError } from "@dev-loops/core/loop/gate-fanin";
|
|
8
|
+
import { checkFanoutAngleCoverage, provenanceConsistencyError } from "@dev-loops/core/loop/gate-fanin";
|
|
9
|
+
import { loadDevLoopConfig, resolveGateAngleContract, resolveRejectForeignAngles } from "@dev-loops/core/config";
|
|
9
10
|
const USAGE = `Usage: write-gate-findings-log.mjs --repo <owner/name> --pr <number> --gate <draft_gate|pre_approval_gate> --head-sha <sha> --verdict <clean|findings_present|blocked> --findings <json> [--tmp-root <path>]
|
|
10
11
|
Write a durable <gate>-<headSha>.json log under deterministic tmp/ paths.
|
|
11
12
|
Required:
|
|
@@ -30,6 +31,7 @@ function normalizeGate(value) {
|
|
|
30
31
|
const normalized = String(value).trim().toLowerCase();
|
|
31
32
|
return gates.has(normalized) ? normalized : null;
|
|
32
33
|
}
|
|
34
|
+
const GATE_CONFIG_KEY = { draft_gate: "draft", pre_approval_gate: "preApproval" };
|
|
33
35
|
function normalizeVerdict(value) {
|
|
34
36
|
const verdicts = new Set(["clean", "findings_present", "blocked"]);
|
|
35
37
|
const normalized = String(value).trim().toLowerCase();
|
|
@@ -148,6 +150,42 @@ export function parseProvenanceJson(raw) {
|
|
|
148
150
|
}
|
|
149
151
|
return normalized;
|
|
150
152
|
}
|
|
153
|
+
/**
|
|
154
|
+
* Validate recorded provenance.perAngle against the gate's configured angle
|
|
155
|
+
* contract (mandatoryAngles + pool, resolved from .devloops/defaults). A
|
|
156
|
+
* missing mandatory angle always fails the write. A foreign (out-of-pool)
|
|
157
|
+
* angle fails the write unless `gates.rejectForeignAngles: false`, in which
|
|
158
|
+
* case it is returned as a warning instead. Throws a `parseError`-shaped Error
|
|
159
|
+
* (matching this module's other validation failures) on a fail-closed
|
|
160
|
+
* rejection.
|
|
161
|
+
*
|
|
162
|
+
* @param {{ perAngle: Array<{ angle: string }> }} provenance
|
|
163
|
+
* @param {"draft_gate"|"pre_approval_gate"} gate
|
|
164
|
+
* @param {{ repoRoot?: string }} [options]
|
|
165
|
+
* @returns {Promise<{ warning: string|null }>}
|
|
166
|
+
*/
|
|
167
|
+
export async function checkProvenanceAngleCoverage(provenance, gate, { repoRoot = process.cwd() } = {}) {
|
|
168
|
+
const { config } = await loadDevLoopConfig({ repoRoot });
|
|
169
|
+
const gateKey = GATE_CONFIG_KEY[gate];
|
|
170
|
+
const { mandatoryAngles, pool } = resolveGateAngleContract(config, gateKey);
|
|
171
|
+
const { missingMandatory, foreignAngles } = checkFanoutAngleCoverage(provenance.perAngle, {
|
|
172
|
+
mandatoryAngles,
|
|
173
|
+
pool,
|
|
174
|
+
});
|
|
175
|
+
if (missingMandatory.length > 0) {
|
|
176
|
+
throw parseError(
|
|
177
|
+
`--provenance.perAngle is missing mandatory angle(s) for ${gate}: ${missingMandatory.join(", ")} (configured in gates.${gateKey}.mandatoryAngles)`,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
if (foreignAngles.length > 0) {
|
|
181
|
+
const message = `--provenance.perAngle names angle(s) outside the configured pool for ${gate}: ${foreignAngles.join(", ")}`;
|
|
182
|
+
if (resolveRejectForeignAngles(config)) {
|
|
183
|
+
throw parseError(`${message} (add them to gates.${gateKey}.angles, or set gates.rejectForeignAngles: false to warn instead of fail)`);
|
|
184
|
+
}
|
|
185
|
+
return { warning: `${message} (gates.rejectForeignAngles is false; recorded as a warning)` };
|
|
186
|
+
}
|
|
187
|
+
return { warning: null };
|
|
188
|
+
}
|
|
151
189
|
export function parseWriteGateFindingsLogCliArgs(argv) {
|
|
152
190
|
const { tokens } = parseArgs({
|
|
153
191
|
args: [...argv],
|
|
@@ -250,6 +288,12 @@ export function buildLogPath({ repo, pr, gate, headSha, tmpRoot }) {
|
|
|
250
288
|
export async function writeGateFindingsLog(options, { repoRoot = process.cwd() } = {}) {
|
|
251
289
|
const findings = parseFindingsJson(options.findings);
|
|
252
290
|
const provenance = options.provenance === undefined ? undefined : parseProvenanceJson(options.provenance);
|
|
291
|
+
// Angle-coverage enforcement (fail-closed on missing mandatory angles / foreign
|
|
292
|
+
// angles) only applies when provenance is actually recorded — provenance
|
|
293
|
+
// remains optional and additive (inline_single_agent writes never carry it).
|
|
294
|
+
const angleCoverage = provenance !== undefined
|
|
295
|
+
? await checkProvenanceAngleCoverage(provenance, options.gate, { repoRoot })
|
|
296
|
+
: { warning: null };
|
|
253
297
|
const logPath = buildLogPath({
|
|
254
298
|
repo: options.repo,
|
|
255
299
|
pr: options.pr,
|
|
@@ -276,7 +320,9 @@ export async function writeGateFindingsLog(options, { repoRoot = process.cwd() }
|
|
|
276
320
|
}
|
|
277
321
|
await mkdir(path.dirname(fullPath), { recursive: true });
|
|
278
322
|
await writeFile(fullPath, JSON.stringify(log, null, 2) + "\n", "utf8");
|
|
279
|
-
return
|
|
323
|
+
return angleCoverage.warning
|
|
324
|
+
? { ok: true, path: logPath, log, warning: angleCoverage.warning }
|
|
325
|
+
: { ok: true, path: logPath, log };
|
|
280
326
|
}
|
|
281
327
|
async function main() {
|
|
282
328
|
let options;
|
|
@@ -293,6 +339,12 @@ async function main() {
|
|
|
293
339
|
}
|
|
294
340
|
try {
|
|
295
341
|
const result = await writeGateFindingsLog(options);
|
|
342
|
+
// rejectForeignAngles: false is WARNING mode, not silence — surface the
|
|
343
|
+
// angle-coverage warning on stderr too (the JSON result carries it as
|
|
344
|
+
// `warning`). Suppressed under --silent.
|
|
345
|
+
if (result.warning && !options.silent) {
|
|
346
|
+
process.stderr.write(`WARNING: ${result.warning}\n`);
|
|
347
|
+
}
|
|
296
348
|
process.exitCode = emitResult(result, { jq: options.jq, silent: options.silent });
|
|
297
349
|
} catch (error) {
|
|
298
350
|
process.stderr.write(JSON.stringify({
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// It lives in scripts/loop/ (not packages/core) because significance is derived
|
|
9
9
|
// from a `gh api .../compare` diff — gh I/O that does not belong in core.
|
|
10
10
|
import { extractReviewCommitSha, isCopilotLogin, parseJsonText } from "../_core-helpers.mjs";
|
|
11
|
-
import { runChild } from "../_cli-primitives.mjs";
|
|
11
|
+
import { runChild as defaultRunChild } from "../_cli-primitives.mjs";
|
|
12
12
|
|
|
13
13
|
export function getLatestSubmittedCopilotReviewHeadSha(reviews) {
|
|
14
14
|
const copilotSubmitted = (Array.isArray(reviews) ? reviews : [])
|
|
@@ -149,7 +149,7 @@ export function isCommentOnlyFileChange(file) {
|
|
|
149
149
|
|
|
150
150
|
export async function detectPostConvergenceSignificantChange(
|
|
151
151
|
{ repo, pr, currentHeadSha, reviews, changedFiles, roundCapReached, regularCopilotRounds },
|
|
152
|
-
{ env = process.env, ghCommand = "gh" } = {},
|
|
152
|
+
{ env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {},
|
|
153
153
|
) {
|
|
154
154
|
if (!roundCapReached || !regularCopilotRounds) {
|
|
155
155
|
return false;
|