dev-loops 0.5.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/.claude-plugin/plugin.json +1 -1
- package/.claude/agents/dev-loop.md +2 -1
- package/.claude/agents/review.md +2 -1
- package/.claude/commands/loop-auto.md +7 -0
- package/.claude/commands/loop-continue.md +15 -0
- package/.claude/commands/loop-enqueue.md +22 -0
- package/.claude/commands/loop-grill.md +17 -0
- package/.claude/commands/loop-info.md +7 -0
- package/.claude/commands/loop-queue-status.md +24 -0
- package/.claude/commands/loop-start-spike.md +16 -0
- package/.claude/commands/loop-start.md +7 -0
- package/.claude/commands/loop-status.md +6 -0
- package/.claude/hooks/_bash-command-classify.mjs +333 -29
- package/.claude/hooks/_hook-decisions.mjs +138 -15
- package/.claude/hooks/_run-context.mjs +11 -4
- package/.claude/hooks/pre-tool-use-bash-gate.mjs +36 -15
- package/.claude/skills/copilot-pr-followup/SKILL.md +24 -12
- package/.claude/skills/dev-loop/SKILL.md +19 -10
- package/.claude/skills/docs/acceptance-criteria-verification.md +6 -2
- package/.claude/skills/docs/anti-patterns.md +2 -1
- package/.claude/skills/docs/artifact-authority-contract.md +22 -4
- package/.claude/skills/docs/copilot-loop-operations.md +1 -1
- package/.claude/skills/docs/cross-harness-regression-contract.md +60 -0
- package/.claude/skills/docs/issue-intake-procedure.md +1 -0
- package/.claude/skills/docs/local-planning-flow.md +1 -1
- package/.claude/skills/docs/local-planning-worked-example.md +1 -1
- package/.claude/skills/docs/merge-preconditions.md +17 -1
- package/.claude/skills/docs/plan-file-contract.md +1 -1
- package/.claude/skills/docs/public-dev-loop-contract.md +1 -1
- package/.claude/skills/docs/release-runbook.md +45 -0
- package/.claude/skills/docs/retrospective-checkpoint-contract.md +90 -76
- package/.claude/skills/docs/ui-e2e-scoping-step.md +134 -0
- package/.claude/skills/docs/workflow-handoff-contract.md +39 -0
- package/.claude/skills/local-implementation/SKILL.md +26 -15
- package/.claude/skills/loop-grill/SKILL.md +163 -0
- package/AGENTS.md +1 -0
- package/CHANGELOG.md +125 -0
- package/README.md +29 -10
- package/agents/dev-loop.agent.md +11 -3
- package/agents/developer.agent.md +1 -1
- package/agents/docs.agent.md +1 -1
- package/agents/fixer.agent.md +1 -1
- package/agents/quality.agent.md +1 -1
- package/agents/refiner.agent.md +1 -1
- package/agents/review.agent.md +3 -2
- package/cli/index.mjs +35 -40
- package/extension/README.md +4 -4
- package/extension/checks.ts +1 -5
- package/extension/harness-types.ts +1 -0
- package/extension/index.ts +16 -1
- package/extension/pi-extension-adapter.ts +2 -0
- package/extension/presentation.ts +16 -13
- package/lib/dev-loops-core.mjs +141 -0
- package/package.json +10 -8
- package/scripts/claude/generate-claude-assets.mjs +15 -1
- package/scripts/github/capture-review-threads.mjs +2 -9
- package/scripts/github/comment-issue.mjs +174 -0
- package/scripts/github/create-label.mjs +133 -0
- package/scripts/github/detect-checkpoint-evidence.mjs +170 -14
- package/scripts/github/detect-linked-issue-pr.mjs +22 -6
- package/scripts/github/edit-pr.mjs +259 -0
- package/scripts/github/fetch-ci-logs.mjs +208 -0
- package/scripts/github/list-issues.mjs +184 -0
- package/scripts/github/manage-sub-issues.mjs +46 -10
- package/scripts/github/offer-human-handoff.mjs +8 -5
- package/scripts/github/post-gate-findings.mjs +14 -2
- package/scripts/github/probe-ci-status.mjs +8 -2
- package/scripts/github/probe-copilot-review.mjs +21 -14
- package/scripts/github/ready-for-review.mjs +26 -8
- package/scripts/github/reconcile-draft-gate.mjs +7 -3
- package/scripts/github/reply-resolve-review-thread.mjs +16 -5
- package/scripts/github/reply-resolve-review-threads.mjs +69 -7
- package/scripts/github/request-copilot-review.mjs +8 -2
- package/scripts/github/resolve-handoff-candidates.mjs +7 -3
- package/scripts/github/resolve-tracker-local-spec.mjs +9 -3
- package/scripts/github/stage-reviewer-draft.mjs +10 -2
- package/scripts/github/tick-verified-checkboxes.mjs +202 -0
- package/scripts/github/upsert-checkpoint-verdict.mjs +31 -10
- package/scripts/github/verify-fresh-review-context.mjs +150 -31
- package/scripts/github/view-pr.mjs +150 -0
- package/scripts/github/write-gate-context.mjs +248 -61
- package/scripts/github/write-gate-findings-log.mjs +75 -1
- package/scripts/lib/jq-output.mjs +18 -0
- package/scripts/loop/_handoff-contract.mjs +1 -0
- package/scripts/loop/_post-convergence-change.mjs +211 -0
- package/scripts/loop/_pr-runner-coordination.mjs +70 -0
- package/scripts/loop/_repo-root-resolver.mjs +47 -0
- package/scripts/loop/build-handoff-envelope.mjs +14 -4
- package/scripts/loop/check-retro-tooling.mjs +14 -3
- package/scripts/loop/checkpoint-contract.mjs +7 -4
- package/scripts/loop/cleanup-worktree.mjs +12 -3
- package/scripts/loop/conductor-monitor.mjs +12 -18
- package/scripts/loop/copilot-pr-handoff.mjs +135 -14
- package/scripts/loop/debt-remediate.mjs +24 -12
- package/scripts/loop/detect-change-scope.mjs +36 -7
- package/scripts/loop/detect-copilot-loop-state.mjs +11 -5
- package/scripts/loop/detect-copilot-session-activity.mjs +7 -3
- package/scripts/loop/detect-initial-copilot-pr-state.mjs +7 -3
- package/scripts/loop/detect-internal-only-pr.mjs +8 -2
- package/scripts/loop/detect-issue-refinement-artifact.mjs +6 -3
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +179 -66
- package/scripts/loop/detect-reviewer-loop-state.mjs +12 -2
- package/scripts/loop/detect-tracker-first-loop-state.mjs +9 -1
- package/scripts/loop/detect-tracker-pr-state.mjs +10 -3
- package/scripts/loop/ensure-worktree.mjs +8 -3
- package/scripts/loop/info.mjs +12 -4
- package/scripts/loop/inspect-run-viewer/constants.mjs +4 -18
- package/scripts/loop/inspect-run-viewer/vendor/mermaid.min.js +3405 -0
- package/scripts/loop/inspect-run-viewer-ci-changes.mjs +18 -6
- package/scripts/loop/inspect-run-viewer.mjs +67 -4
- package/scripts/loop/inspect-run.mjs +8 -2
- package/scripts/loop/outer-loop.mjs +8 -4
- package/scripts/loop/pr-runner-coordination.mjs +2 -9
- package/scripts/loop/pre-commit-branch-guard.mjs +16 -10
- package/scripts/loop/pre-flight-gate.mjs +9 -9
- package/scripts/loop/pre-pr-ready-gate.mjs +8 -4
- package/scripts/loop/pre-write-remote-freshness-guard.mjs +13 -4
- package/scripts/loop/provision-worktree.mjs +74 -3
- package/scripts/loop/resolve-dev-loop-startup.mjs +76 -7
- package/scripts/loop/resolve-gate-dispatch.mjs +134 -0
- package/scripts/loop/resolve-pr-conflicts.mjs +14 -7
- package/scripts/loop/run-conductor-cycle.mjs +13 -2
- package/scripts/loop/run-queue.mjs +18 -9
- package/scripts/loop/run-refinement-audit.mjs +8 -9
- package/scripts/loop/run-watch-cycle.mjs +2 -9
- package/scripts/loop/sanctioned-commands.mjs +104 -0
- package/scripts/loop/steer-loop.mjs +29 -16
- package/scripts/loop/validate-pr-body-spec.mjs +207 -0
- package/scripts/loop/watch-initial-copilot-pr.mjs +8 -3
- package/scripts/pages/build-site.mjs +59 -8
- package/scripts/pages/build-state-atlas.mjs +441 -0
- package/scripts/projects/_resolve-project.mjs +148 -0
- package/scripts/projects/add-queue-item.mjs +60 -54
- package/scripts/projects/archive-done-items.mjs +49 -84
- package/scripts/projects/ensure-queue-board.mjs +10 -36
- package/scripts/projects/list-queue-items.mjs +116 -65
- package/scripts/projects/move-queue-item.mjs +28 -49
- package/scripts/projects/reconcile-queue.mjs +253 -0
- package/scripts/projects/reorder-queue-item.mjs +41 -47
- package/scripts/projects/resolve-active-board-item.mjs +255 -0
- package/scripts/projects/sync-item-status.mjs +15 -10
- package/scripts/refine/_refine-helpers.mjs +21 -5
- package/scripts/refine/exit-spike.mjs +18 -8
- package/scripts/refine/promote-plan.mjs +22 -16
- package/scripts/refine/prose-linkage-detector.mjs +1 -1
- package/scripts/refine/refine-plan-file.mjs +13 -4
- package/scripts/refine/refinement-completeness-checker.mjs +1 -1
- package/scripts/refine/scaffold-spike-file.mjs +179 -0
- package/scripts/refine/scope-boundary-cross-checker.mjs +1 -1
- package/scripts/refine/tree-integrity-validator.mjs +1 -1
- package/scripts/refine/validate-plan-file.mjs +1 -1
- package/scripts/refine/validate-spike-file.mjs +1 -1
- package/scripts/refine/verify.mjs +11 -6
- package/scripts/release/assert-core-dependency-version.mjs +123 -0
- package/scripts/release/extract-changelog-section.mjs +125 -0
- package/skills/copilot-pr-followup/SKILL.md +24 -12
- package/skills/dev-loop/SKILL.md +22 -6
- package/skills/docs/acceptance-criteria-verification.md +6 -2
- package/skills/docs/anti-patterns.md +2 -1
- package/skills/docs/artifact-authority-contract.md +22 -4
- package/skills/docs/copilot-loop-operations.md +1 -1
- package/skills/docs/cross-harness-regression-contract.md +60 -0
- package/skills/docs/issue-intake-procedure.md +1 -0
- package/skills/docs/local-planning-flow.md +1 -1
- package/skills/docs/local-planning-worked-example.md +1 -1
- package/skills/docs/merge-preconditions.md +17 -1
- package/skills/docs/plan-file-contract.md +1 -1
- package/skills/docs/public-dev-loop-contract.md +1 -1
- package/skills/docs/release-runbook.md +45 -0
- package/skills/docs/retrospective-checkpoint-contract.md +90 -76
- package/skills/docs/ui-e2e-scoping-step.md +134 -0
- package/skills/docs/workflow-handoff-contract.md +39 -0
- package/skills/local-implementation/SKILL.md +26 -15
- package/skills/loop-grill/SKILL.md +165 -0
- package/scripts/loop/conductor.mjs +0 -233
- package/scripts/loop/detect-stale-runner.mjs +0 -265
- package/scripts/loop/pre-push-main-guard.mjs +0 -117
|
@@ -14,7 +14,7 @@ import fs from "node:fs";
|
|
|
14
14
|
import path from "node:path";
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
16
|
|
|
17
|
-
import { transformAgent, transformSkill, stripPiOnlyBlocks } from "@dev-loops/core/claude/asset-generation";
|
|
17
|
+
import { transformAgent, transformSkill, transformCommand, stripPiOnlyBlocks } from "@dev-loops/core/claude/asset-generation";
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* Collect the generated assets as { target, content } pairs (target is repo-relative).
|
|
@@ -45,6 +45,19 @@ export function collectGeneratedAssets({ repoRoot = process.cwd() } = {}) {
|
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
// Direct slash commands (#972): thin generated wrappers over the public dev-loop contract,
|
|
49
|
+
// one `.claude/commands/<name>.md` per `commands/<name>.command.md` source. No routing logic.
|
|
50
|
+
const commandsDir = path.join(repoRoot, "commands");
|
|
51
|
+
if (fs.existsSync(commandsDir)) {
|
|
52
|
+
for (const entry of fs.readdirSync(commandsDir).sort()) {
|
|
53
|
+
if (!entry.endsWith(".command.md")) continue;
|
|
54
|
+
const source = `commands/${entry}`;
|
|
55
|
+
const raw = fs.readFileSync(path.join(repoRoot, source), "utf8");
|
|
56
|
+
const base = entry.slice(0, -".command.md".length);
|
|
57
|
+
assets.push({ target: `.claude/commands/${base}.md`, content: transformCommand({ source, raw, version }) });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
48
61
|
const skillsDir = path.join(repoRoot, "skills");
|
|
49
62
|
if (fs.existsSync(skillsDir)) {
|
|
50
63
|
for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
@@ -171,6 +184,7 @@ function listFilesRecursive(repoRoot, rel) {
|
|
|
171
184
|
function listExistingAssetFiles(repoRoot) {
|
|
172
185
|
const files = [
|
|
173
186
|
...listFilesRecursive(repoRoot, ".claude/agents"),
|
|
187
|
+
...listFilesRecursive(repoRoot, ".claude/commands"),
|
|
174
188
|
...listFilesRecursive(repoRoot, ".claude/skills"),
|
|
175
189
|
];
|
|
176
190
|
// `.claude/hooks/` mixes hand-authored scripts (hooks.json, _hook-io.mjs, the three hook
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
import { parseArgs } from "node:util";
|
|
12
12
|
import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
|
|
13
13
|
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
14
|
-
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
|
|
14
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
|
|
15
15
|
export const REVIEW_THREADS_QUERY = [
|
|
16
16
|
"query($owner: String!, $name: String!, $pr: Int!) {",
|
|
17
17
|
" repository(owner: $owner, name: $name) {",
|
|
@@ -103,14 +103,7 @@ export function parseCaptureCliArgs(argv) {
|
|
|
103
103
|
options.pr = parsePrNumber(requireTokenValue(token));
|
|
104
104
|
continue;
|
|
105
105
|
}
|
|
106
|
-
if (token
|
|
107
|
-
options.jq = requireTokenValue(token);
|
|
108
|
-
continue;
|
|
109
|
-
}
|
|
110
|
-
if (token.name === "silent") {
|
|
111
|
-
options.silent = true;
|
|
112
|
-
continue;
|
|
113
|
-
}
|
|
106
|
+
if (matchJqOutputToken(token, options, (t) => requireTokenValue(t))) continue;
|
|
114
107
|
throw new Error(`Unknown argument: ${token.rawName}`);
|
|
115
108
|
}
|
|
116
109
|
const hasLiveArgs = options.repo !== undefined || options.pr !== undefined;
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
|
|
4
|
+
import { parseIssueNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
|
|
5
|
+
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
6
|
+
import { parseArgs } from "node:util";
|
|
7
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
|
|
8
|
+
|
|
9
|
+
const USAGE = `Usage: comment-issue.mjs --repo <owner/name> --issue <number> (--body <text> | --body-file <path>)
|
|
10
|
+
Post a comment on a GitHub issue. Thin wrapper over \`gh issue comment\` — use this
|
|
11
|
+
instead of an agent-level raw \`gh issue comment\` so the loop's internal-tooling
|
|
12
|
+
record stays clean (#993; the read-side siblings are list-issues.mjs / fetch-ci-logs.mjs).
|
|
13
|
+
Required:
|
|
14
|
+
--repo <owner/name> Repository slug (e.g. owner/repo)
|
|
15
|
+
--issue <number> Issue number to comment on
|
|
16
|
+
--body <text> Comment body as a single argument
|
|
17
|
+
--body-file <path> Read the comment body from a file (preserves
|
|
18
|
+
newlines; alternative to --body; - reads stdin)
|
|
19
|
+
Output (stdout, JSON):
|
|
20
|
+
{ "ok": true, "repo": "owner/repo", "issue": 17, "commentUrl": "https://github.com/owner/repo/issues/17#issuecomment-123" }
|
|
21
|
+
Error output (stderr, JSON):
|
|
22
|
+
{ "ok": false, "error": "...", "usage"?: "..." }
|
|
23
|
+
${JQ_OUTPUT_USAGE}
|
|
24
|
+
Exit codes:
|
|
25
|
+
0 Success
|
|
26
|
+
1 Argument error or gh failure
|
|
27
|
+
2 Invalid --jq filter`.trim();
|
|
28
|
+
const parseError = buildParseError(USAGE);
|
|
29
|
+
|
|
30
|
+
export function parseCommentIssueCliArgs(argv) {
|
|
31
|
+
const { tokens } = parseArgs({
|
|
32
|
+
args: [...argv],
|
|
33
|
+
options: {
|
|
34
|
+
help: { type: "boolean", short: "h" },
|
|
35
|
+
repo: { type: "string" },
|
|
36
|
+
issue: { type: "string" },
|
|
37
|
+
body: { type: "string" },
|
|
38
|
+
"body-file": { type: "string" },
|
|
39
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
40
|
+
},
|
|
41
|
+
allowPositionals: true,
|
|
42
|
+
strict: false,
|
|
43
|
+
tokens: true,
|
|
44
|
+
});
|
|
45
|
+
const options = {
|
|
46
|
+
help: false,
|
|
47
|
+
repo: undefined,
|
|
48
|
+
issue: undefined,
|
|
49
|
+
body: undefined,
|
|
50
|
+
bodyFile: undefined,
|
|
51
|
+
jq: undefined,
|
|
52
|
+
silent: false,
|
|
53
|
+
};
|
|
54
|
+
for (const token of tokens) {
|
|
55
|
+
if (token.kind === "positional") {
|
|
56
|
+
throw parseError(`Unknown argument: ${token.value}`);
|
|
57
|
+
}
|
|
58
|
+
if (token.kind !== "option") {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (token.name === "help") {
|
|
62
|
+
options.help = true;
|
|
63
|
+
return options;
|
|
64
|
+
}
|
|
65
|
+
if (token.name === "repo") {
|
|
66
|
+
options.repo = requireTokenValue(token, parseError).trim();
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (token.name === "issue") {
|
|
70
|
+
options.issue = parseIssueNumber(requireTokenValue(token, parseError), parseError);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (token.name === "body") {
|
|
74
|
+
options.body = requireTokenValue(token, parseError);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (token.name === "body-file") {
|
|
78
|
+
const rawPath = requireTokenValue(token, parseError).trim();
|
|
79
|
+
if (rawPath.length === 0) {
|
|
80
|
+
throw parseError("--body-file must be a non-empty path");
|
|
81
|
+
}
|
|
82
|
+
options.bodyFile = rawPath;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
|
|
86
|
+
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
87
|
+
}
|
|
88
|
+
if (options.repo === undefined || options.issue === undefined) {
|
|
89
|
+
throw parseError("Commenting on an issue requires both --repo <owner/name> and --issue <number>");
|
|
90
|
+
}
|
|
91
|
+
if (options.body === undefined && options.bodyFile === undefined) {
|
|
92
|
+
throw parseError("Commenting on an issue requires --body <text> or --body-file <path>");
|
|
93
|
+
}
|
|
94
|
+
if (options.body !== undefined && options.bodyFile !== undefined) {
|
|
95
|
+
throw parseError("--body and --body-file are mutually exclusive; pass only one");
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
parseRepoSlug(options.repo);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
throw parseError(error instanceof Error ? error.message : String(error));
|
|
101
|
+
}
|
|
102
|
+
return options;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function resolveBody(options) {
|
|
106
|
+
if (options.bodyFile === undefined) {
|
|
107
|
+
if (options.body.trim().length === 0) {
|
|
108
|
+
throw new Error("--body must not be empty");
|
|
109
|
+
}
|
|
110
|
+
return options.body;
|
|
111
|
+
}
|
|
112
|
+
const source = options.bodyFile === "-" ? 0 : options.bodyFile;
|
|
113
|
+
const body = await readFile(source, "utf8");
|
|
114
|
+
if (body.trim().length === 0) {
|
|
115
|
+
throw new Error(`--body-file ${options.bodyFile} is empty`);
|
|
116
|
+
}
|
|
117
|
+
return body;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Post the comment via `gh issue comment`, then read its URL back from
|
|
121
|
+
// `gh issue comment` output. `gh issue comment` prints the new comment URL on
|
|
122
|
+
// success — capture it so callers don't need a follow-up read.
|
|
123
|
+
export async function commentIssue(options, { env = process.env, ghCommand = "gh", run = runChild } = {}) {
|
|
124
|
+
const body = await resolveBody(options);
|
|
125
|
+
const result = await run(
|
|
126
|
+
ghCommand,
|
|
127
|
+
["issue", "comment", String(options.issue), "--repo", options.repo, "--body", body],
|
|
128
|
+
env,
|
|
129
|
+
);
|
|
130
|
+
if (result.code !== 0) {
|
|
131
|
+
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
132
|
+
throw new Error(`gh issue comment failed: ${detail}`);
|
|
133
|
+
}
|
|
134
|
+
// `gh issue comment` prints the created comment's URL (the last non-empty line
|
|
135
|
+
// of stdout). Surface it so the caller has the comment URL without a re-read.
|
|
136
|
+
const commentUrl = result.stdout
|
|
137
|
+
.split(/\r?\n/u)
|
|
138
|
+
.map((line) => line.trim())
|
|
139
|
+
.filter((line) => line.length > 0)
|
|
140
|
+
.pop() ?? null;
|
|
141
|
+
if (commentUrl === null || !/^https?:\/\//u.test(commentUrl)) {
|
|
142
|
+
throw new Error(`gh issue comment did not return a comment URL (got: ${result.stdout.trim() || "<empty>"})`);
|
|
143
|
+
}
|
|
144
|
+
return { ok: true, repo: options.repo, issue: options.issue, commentUrl };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function runCli(
|
|
148
|
+
argv = process.argv.slice(2),
|
|
149
|
+
{ stdout = process.stdout, stderr = process.stderr, env = process.env, ghCommand = "gh", run = runChild } = {},
|
|
150
|
+
) {
|
|
151
|
+
let options;
|
|
152
|
+
try {
|
|
153
|
+
options = parseCommentIssueCliArgs(argv);
|
|
154
|
+
} catch (error) {
|
|
155
|
+
stderr.write(`${formatCliError(error)}\n`);
|
|
156
|
+
return 1;
|
|
157
|
+
}
|
|
158
|
+
if (options.help) {
|
|
159
|
+
stdout.write(`${USAGE}\n`);
|
|
160
|
+
return 0;
|
|
161
|
+
}
|
|
162
|
+
let result;
|
|
163
|
+
try {
|
|
164
|
+
result = await commentIssue(options, { env, ghCommand, run });
|
|
165
|
+
} catch (error) {
|
|
166
|
+
stderr.write(`${JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) })}\n`);
|
|
167
|
+
return 1;
|
|
168
|
+
}
|
|
169
|
+
return emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
173
|
+
runCli().then((code) => { process.exitCode = code; });
|
|
174
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
|
|
5
|
+
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
6
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
|
|
7
|
+
|
|
8
|
+
const USAGE = `Usage: create-label.mjs --repo <owner/name> --name <label> [--color <hex>] [--description <text>] [--force]
|
|
9
|
+
Create (or idempotently reuse) a GitHub label. Thin wrapper over \`gh label create\`
|
|
10
|
+
— use this instead of an agent-level raw \`gh label create\` so the loop's
|
|
11
|
+
internal-tooling record stays clean (siblings: comment-issue.mjs, edit-pr.mjs).
|
|
12
|
+
Required:
|
|
13
|
+
--repo <owner/name> Repository slug (e.g. owner/repo)
|
|
14
|
+
--name <label> Label name to create
|
|
15
|
+
Optional:
|
|
16
|
+
--color <hex> Hex color without '#' (default: d73a4a)
|
|
17
|
+
--description <text> Label description
|
|
18
|
+
--force Update the label if it already exists
|
|
19
|
+
Output (stdout, JSON):
|
|
20
|
+
{ "ok": true, "created": true, "name": "gate:full", "color": "d73a4a", "repo": "owner/repo" }
|
|
21
|
+
{ "ok": true, "created": false, "alreadyExists": true, "name": "gate:full" }
|
|
22
|
+
Error output (stderr, JSON):
|
|
23
|
+
{ "ok": false, "error": "...", "usage"?: "..." }
|
|
24
|
+
${JQ_OUTPUT_USAGE}
|
|
25
|
+
Exit codes:
|
|
26
|
+
0 Success (created or already exists)
|
|
27
|
+
1 Argument error or gh failure
|
|
28
|
+
2 Invalid --jq filter`.trim();
|
|
29
|
+
const parseError = buildParseError(USAGE);
|
|
30
|
+
|
|
31
|
+
export function parseCreateLabelCliArgs(argv) {
|
|
32
|
+
const { values } = parseArgs({
|
|
33
|
+
args: [...argv],
|
|
34
|
+
options: {
|
|
35
|
+
help: { type: "boolean", short: "h" },
|
|
36
|
+
repo: { type: "string" },
|
|
37
|
+
name: { type: "string" },
|
|
38
|
+
color: { type: "string" },
|
|
39
|
+
description: { type: "string" },
|
|
40
|
+
force: { type: "boolean" },
|
|
41
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
42
|
+
},
|
|
43
|
+
allowPositionals: false,
|
|
44
|
+
strict: false,
|
|
45
|
+
});
|
|
46
|
+
if (values.help) {
|
|
47
|
+
return { help: true };
|
|
48
|
+
}
|
|
49
|
+
const repo = typeof values.repo === "string" ? values.repo.trim() : undefined;
|
|
50
|
+
const name = typeof values.name === "string" ? values.name.trim() : undefined;
|
|
51
|
+
if (!repo || !name) {
|
|
52
|
+
throw parseError("Creating a label requires both --repo <owner/name> and --name <label>");
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
parseRepoSlug(repo);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
throw parseError(error instanceof Error ? error.message : String(error));
|
|
58
|
+
}
|
|
59
|
+
const color = typeof values.color === "string" && values.color.trim().length > 0 ? values.color.trim() : "d73a4a";
|
|
60
|
+
const description =
|
|
61
|
+
typeof values.description === "string" && values.description.length > 0 ? values.description : undefined;
|
|
62
|
+
return {
|
|
63
|
+
help: false,
|
|
64
|
+
repo,
|
|
65
|
+
name,
|
|
66
|
+
color,
|
|
67
|
+
description,
|
|
68
|
+
force: values.force === true,
|
|
69
|
+
jq: values.jq,
|
|
70
|
+
silent: values.silent === true,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Pure: assemble the `gh label create` argv. Exported so the arg shape can be
|
|
75
|
+
// tested without spawning gh.
|
|
76
|
+
export function buildLabelArgs({ repo, name, color, description, force }) {
|
|
77
|
+
const args = ["label", "create", name, "--repo", repo, "--color", color];
|
|
78
|
+
if (description !== undefined) {
|
|
79
|
+
args.push("--description", description);
|
|
80
|
+
}
|
|
81
|
+
if (force) {
|
|
82
|
+
args.push("--force");
|
|
83
|
+
}
|
|
84
|
+
return args;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function createLabel(options, { ghCommand = "gh", exec = execFileSync } = {}) {
|
|
88
|
+
const args = buildLabelArgs(options);
|
|
89
|
+
try {
|
|
90
|
+
exec(ghCommand, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
91
|
+
} catch (error) {
|
|
92
|
+
const stderr = typeof error?.stderr === "string" ? error.stderr : String(error?.stderr ?? "");
|
|
93
|
+
// Without --force, gh errors if the label exists. Treat that as idempotent
|
|
94
|
+
// success so re-runs are safe; any other failure propagates.
|
|
95
|
+
if (!options.force && /already exists/i.test(stderr)) {
|
|
96
|
+
return { ok: true, created: false, alreadyExists: true, name: options.name };
|
|
97
|
+
}
|
|
98
|
+
const detail = stderr.trim() || (error instanceof Error ? error.message : String(error));
|
|
99
|
+
throw new Error(`gh label create failed: ${detail}`);
|
|
100
|
+
}
|
|
101
|
+
return { ok: true, created: true, name: options.name, color: options.color, repo: options.repo };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function run(
|
|
105
|
+
argv = process.argv.slice(2),
|
|
106
|
+
{ stdout = process.stdout, stderr = process.stderr, ghCommand = "gh", exec = execFileSync } = {},
|
|
107
|
+
) {
|
|
108
|
+
let options;
|
|
109
|
+
try {
|
|
110
|
+
options = parseCreateLabelCliArgs(argv);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
stderr.write(`${formatCliError(error)}\n`);
|
|
113
|
+
return 1;
|
|
114
|
+
}
|
|
115
|
+
if (options.help) {
|
|
116
|
+
stdout.write(`${USAGE}\n`);
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
let result;
|
|
120
|
+
try {
|
|
121
|
+
result = createLabel(options, { ghCommand, exec });
|
|
122
|
+
} catch (error) {
|
|
123
|
+
stderr.write(`${JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) })}\n`);
|
|
124
|
+
return 1;
|
|
125
|
+
}
|
|
126
|
+
return emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export const main = run;
|
|
130
|
+
|
|
131
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
132
|
+
process.exitCode = run();
|
|
133
|
+
}
|
|
@@ -9,15 +9,19 @@ import {
|
|
|
9
9
|
summarizeGateReviewCommentMarkers,
|
|
10
10
|
summarizeGateReviewComments,
|
|
11
11
|
} from "../_core-helpers.mjs";
|
|
12
|
-
import { access } from "node:fs/promises";
|
|
12
|
+
import { access, readFile } from "node:fs/promises";
|
|
13
13
|
import path from "node:path";
|
|
14
14
|
import { parsePrNumber, requireTokenValue, runChild } 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 { loadDevLoopConfig, resolveGateConfig, resolveRequireFanoutEvidence } from "@dev-loops/core/config";
|
|
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";
|
|
19
|
+
import { detectMergeBaseScope, isEligibleForLightMode } from "../loop/detect-change-scope.mjs";
|
|
18
20
|
import { buildLogPath } from "./write-gate-findings-log.mjs";
|
|
19
21
|
import { ensureAsyncRunnerOwnership } from "../loop/_pr-runner-coordination.mjs";
|
|
20
22
|
import { detectStaleRunner } from "../loop/_stale-runner-detection.mjs";
|
|
23
|
+
import { resolveLedgerCheckouts, resolveRepoRoot } from "../loop/_repo-root-resolver.mjs";
|
|
24
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
|
|
21
25
|
const USAGE = `Usage: detect-checkpoint-evidence.mjs --repo <owner/name> --pr <number>
|
|
22
26
|
Fetch the live PR head SHA and visible PR issue comments, then summarize the
|
|
23
27
|
latest valid draft-gate and pre-approval checkpoint verdict comments. Always fail
|
|
@@ -84,9 +88,11 @@ Output (stdout, JSON; always includes preMergeGateCheck):
|
|
|
84
88
|
Error output (stderr, JSON):
|
|
85
89
|
{ "ok": false, "error": "...", "usage": "..." }
|
|
86
90
|
{ "ok": false, "error": "..." }
|
|
91
|
+
${JQ_OUTPUT_USAGE}
|
|
87
92
|
Exit codes:
|
|
88
93
|
0 Success (gate evidence is valid)
|
|
89
|
-
1 Argument error, gh failure, malformed gh JSON, or missing required pre-merge gate evidence
|
|
94
|
+
1 Argument error, gh failure, malformed gh JSON, or missing required pre-merge gate evidence.
|
|
95
|
+
2 Invalid --jq filter.`.trim();
|
|
90
96
|
const parseError = buildParseError(USAGE);
|
|
91
97
|
export function parseDetectCheckpointEvidenceCliArgs(argv) {
|
|
92
98
|
const { tokens } = parseArgs({
|
|
@@ -96,6 +102,7 @@ export function parseDetectCheckpointEvidenceCliArgs(argv) {
|
|
|
96
102
|
repo: { type: "string" },
|
|
97
103
|
pr: { type: "string" },
|
|
98
104
|
"require-before-merge": { type: "boolean" },
|
|
105
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
99
106
|
},
|
|
100
107
|
allowPositionals: true,
|
|
101
108
|
strict: false,
|
|
@@ -125,6 +132,7 @@ export function parseDetectCheckpointEvidenceCliArgs(argv) {
|
|
|
125
132
|
options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
|
|
126
133
|
continue;
|
|
127
134
|
}
|
|
135
|
+
if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
|
|
128
136
|
if (token.name === "require-before-merge") {
|
|
129
137
|
throw parseError(`--require-before-merge has been removed: gate evidence enforcement is now always-on by default. Omit the flag.`);
|
|
130
138
|
}
|
|
@@ -249,7 +257,24 @@ export function buildPreMergeGateCheck(evidence, unresolvedThreadCount = null, s
|
|
|
249
257
|
// { required: false, gates: [] } so the `.required` guard skips this block.
|
|
250
258
|
if (fanoutEnforcement && fanoutEnforcement.required) {
|
|
251
259
|
for (const gate of fanoutEnforcement.gates) {
|
|
252
|
-
|
|
260
|
+
// Light-mode acceptance (#1174): a genuinely under-threshold micro-PR
|
|
261
|
+
// collapses the gate fan-out to a single inline check (#1043). Accept that
|
|
262
|
+
// inline verdict ONLY when ALL hold, fail CLOSED otherwise:
|
|
263
|
+
// - lightMode is enabled in config, AND
|
|
264
|
+
// - the reviewed head's merge-base scope was RE-DERIVED under threshold
|
|
265
|
+
// (scopeUnderThreshold; false whenever scope could not be derived), AND
|
|
266
|
+
// - the PR carries no gate:full label (which always forces fan-out), AND
|
|
267
|
+
// - the verdict records a non-empty inline reason.
|
|
268
|
+
// Any non-light inline verdict (over threshold / label / lightMode off /
|
|
269
|
+
// scope underivable) falls through to the byte-identical rejection below.
|
|
270
|
+
const lightAccepted =
|
|
271
|
+
gate.executionMode === "inline_single_agent"
|
|
272
|
+
&& fanoutEnforcement.lightMode === true
|
|
273
|
+
&& fanoutEnforcement.hasFullLabel !== true
|
|
274
|
+
&& gate.scopeUnderThreshold === true
|
|
275
|
+
&& typeof gate.inlineReason === "string"
|
|
276
|
+
&& gate.inlineReason.trim().length > 0;
|
|
277
|
+
if (gate.executionMode !== "fanout_fanin" && !lightAccepted) {
|
|
253
278
|
failures.push(
|
|
254
279
|
`${gate.name}: requireFanoutEvidence is enabled but executionMode is "${gate.executionMode ?? "unset"}" (expected "fanout_fanin"); inline gate verdicts are not accepted`,
|
|
255
280
|
);
|
|
@@ -259,6 +284,31 @@ export function buildPreMergeGateCheck(evidence, unresolvedThreadCount = null, s
|
|
|
259
284
|
failures.push(
|
|
260
285
|
`${gate.name}: requireFanoutEvidence is enabled but no findings-log ledger exists for the reviewed head (${gate.ledgerPath})`,
|
|
261
286
|
);
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
// Opt-in provenance enforcement (gates.requireFanoutProvenance), layered on
|
|
290
|
+
// top of fan-out evidence. When off (default) requireProvenance is falsy so
|
|
291
|
+
// NO new failure is added — behavior is byte-identical to today. When on, a
|
|
292
|
+
// fanout_fanin ledger must record INTERNALLY-CONSISTENT provenance (checked
|
|
293
|
+
// the same way the write path validates it — a hand-edited or shadow ledger
|
|
294
|
+
// is re-validated here, not trusted) with distinctReviewers >= the floor.
|
|
295
|
+
// Provenance is enforced ONLY for fanout_fanin verdicts (#1174): a
|
|
296
|
+
// light-accepted inline verdict is already scope-bounded and has no
|
|
297
|
+
// multi-reviewer provenance to record, so requiring it would make the
|
|
298
|
+
// light path unmergeable — the inverse of this issue's fix.
|
|
299
|
+
if (fanoutEnforcement.requireProvenance && gate.executionMode === "fanout_fanin") {
|
|
300
|
+
const prov = gate.provenance;
|
|
301
|
+
const consistencyErr = provenanceConsistencyError(prov);
|
|
302
|
+
const reviewers = prov && Number.isInteger(prov.distinctReviewers) ? prov.distinctReviewers : null;
|
|
303
|
+
if (consistencyErr) {
|
|
304
|
+
failures.push(
|
|
305
|
+
`${gate.name}: requireFanoutProvenance is enabled but the findings-log ledger lacks valid fan-out provenance (${consistencyErr}); ${FANOUT_UNAVAILABLE_MESSAGE}`,
|
|
306
|
+
);
|
|
307
|
+
} else if (reviewers === null || reviewers < FANOUT_PROVENANCE_MIN_REVIEWERS) {
|
|
308
|
+
failures.push(
|
|
309
|
+
`${gate.name}: requireFanoutProvenance is enabled but the findings-log ledger lacks valid fan-out provenance (need provenance.distinctReviewers >= ${FANOUT_PROVENANCE_MIN_REVIEWERS}, got ${reviewers === null ? "none" : reviewers}); ${FANOUT_UNAVAILABLE_MESSAGE}`,
|
|
310
|
+
);
|
|
311
|
+
}
|
|
262
312
|
}
|
|
263
313
|
}
|
|
264
314
|
}
|
|
@@ -287,24 +337,88 @@ async function ledgerExists(fullPath) {
|
|
|
287
337
|
return false;
|
|
288
338
|
}
|
|
289
339
|
}
|
|
340
|
+
/**
|
|
341
|
+
* True if the ledger (relative path) exists under ANY enumerated checkout
|
|
342
|
+
* (main + every worktree). Fixes #1050: a ledger written in the PR worktree is
|
|
343
|
+
* found even when the check runs from a different checkout's git-toplevel.
|
|
344
|
+
*/
|
|
345
|
+
async function ledgerExistsInAny(checkouts, ledgerPath) {
|
|
346
|
+
for (const root of checkouts) {
|
|
347
|
+
if (await ledgerExists(path.resolve(root, ledgerPath))) {
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Read the recorded fan-out `provenance` object from a ledger across the
|
|
355
|
+
* enumerated checkouts. Mirrors ledgerExistsInAny's "ANY checkout satisfies"
|
|
356
|
+
* semantics: prefers the FIRST checkout whose ledger provenance actually
|
|
357
|
+
* SATISFIES enforcement (internally consistent AND distinctReviewers >= floor),
|
|
358
|
+
* so a below-floor or provenance-less ledger in an earlier-enumerated checkout
|
|
359
|
+
* cannot SHADOW a valid one in the PR worktree (which would falsely fail closed).
|
|
360
|
+
* Falls back to the first non-null provenance (for a useful diagnostic message)
|
|
361
|
+
* only when NO checkout satisfies, and null only when none is present. Only
|
|
362
|
+
* called when requireFanoutProvenance is enabled so the default path pays no I/O.
|
|
363
|
+
*/
|
|
364
|
+
async function readLedgerProvenanceInAny(checkouts, ledgerPath) {
|
|
365
|
+
let firstNonNull = null;
|
|
366
|
+
for (const root of checkouts) {
|
|
367
|
+
const full = path.resolve(root, ledgerPath);
|
|
368
|
+
try {
|
|
369
|
+
const parsed = JSON.parse(await readFile(full, "utf8"));
|
|
370
|
+
const prov = parsed && typeof parsed === "object" ? parsed.provenance : null;
|
|
371
|
+
if (prov == null) continue; // ledger present but no provenance — keep scanning.
|
|
372
|
+
if (provenanceConsistencyError(prov) === null && prov.distinctReviewers >= FANOUT_PROVENANCE_MIN_REVIEWERS) {
|
|
373
|
+
return prov; // satisfying ledger — prefer it over any earlier below-floor one.
|
|
374
|
+
}
|
|
375
|
+
if (firstNonNull === null) firstNonNull = prov; // remember for diagnostics.
|
|
376
|
+
} catch {
|
|
377
|
+
// Missing/unreadable/malformed ledger in this checkout — try the next.
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return firstNonNull;
|
|
381
|
+
}
|
|
290
382
|
/**
|
|
291
383
|
* Build the fan-out evidence enforcement descriptor.
|
|
292
384
|
*
|
|
293
385
|
* Enforcement is ON by default (opt-out via gates.requireFanoutEvidence: false).
|
|
294
386
|
* Returns { required: false } when enforcement is disabled OR when config is
|
|
295
387
|
* unavailable (config == null — null or undefined — after a failed load) — config-unavailable must
|
|
296
|
-
* fail open and never enable enforcement. When enabled,
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
388
|
+
* fail open and never enable enforcement. When enabled, returns
|
|
389
|
+
* { required: true, requireProvenance, lightMode, hasFullLabel, gates } where
|
|
390
|
+
* each per-required-gate entry records executionMode, inlineReason,
|
|
391
|
+
* scopeUnderThreshold, and whether the deterministic findings-log ledger exists
|
|
392
|
+
* for the reviewed head SHA, so the pre-merge check can fail closed on inline
|
|
393
|
+
* verdicts or missing ledgers.
|
|
394
|
+
*
|
|
395
|
+
* Light mode (#1174): when gates.lightMode is configured, `hasFullLabel`
|
|
396
|
+
* (gate:full PR label) and `baseRef` feed a fail-closed merge-base scope
|
|
397
|
+
* re-derivation for inline verdicts. A gate's scopeUnderThreshold is true only
|
|
398
|
+
* when light mode is on, the PR has no gate:full label, a base ref is known,
|
|
399
|
+
* and the reviewed head's merge-base diff is genuinely under threshold — which
|
|
400
|
+
* lets the pre-merge check accept an inline single-agent verdict for a
|
|
401
|
+
* small-scope PR instead of always rejecting inline evidence.
|
|
300
402
|
*/
|
|
301
|
-
async function buildFanoutEnforcement({ repo, pr, currentHeadSha, draftGateMarker, preApprovalGateMarker, config, cwd }) {
|
|
403
|
+
export async function buildFanoutEnforcement({ repo, pr, currentHeadSha, draftGateMarker, preApprovalGateMarker, config, cwd, hasFullLabel = false, baseRef = null }) {
|
|
302
404
|
// Fail open when config could not be loaded/validated. `== null` covers both
|
|
303
405
|
// null and undefined; the loader only ever yields null on failure, but the
|
|
304
406
|
// loose check defensively treats an absent config as unavailable.
|
|
305
407
|
if (config == null || !resolveRequireFanoutEvidence(config)) {
|
|
408
|
+
// Disabled/unavailable return is intentionally byte-identical to before
|
|
409
|
+
// (no requireProvenance key): buildPreMergeGateCheck only reads it inside
|
|
410
|
+
// the `required` block, so this preserves the exact existing shape.
|
|
306
411
|
return { required: false, gates: [] };
|
|
307
412
|
}
|
|
413
|
+
// Provenance enforcement is opt-in and layered ON TOP of fan-out evidence: it
|
|
414
|
+
// only takes effect while evidence enforcement (above) is active.
|
|
415
|
+
const requireProvenance = resolveRequireFanoutProvenance(config);
|
|
416
|
+
// Light-mode facts (#1174): the threshold that a re-derived merge-base scope
|
|
417
|
+
// must fall under for an inline verdict to be accepted. null when lightMode is
|
|
418
|
+
// disabled → no inline verdict can ever be accepted (scopeUnderThreshold stays
|
|
419
|
+
// false), preserving today's rejection.
|
|
420
|
+
const lightThreshold = resolveLightMode(config);
|
|
421
|
+
const lightMode = lightThreshold != null;
|
|
308
422
|
const draftRequired = resolveGateConfig(config, "draft").required;
|
|
309
423
|
const preApprovalRequired = resolveGateConfig(config, "preApproval").required;
|
|
310
424
|
const gateSpecs = [
|
|
@@ -312,18 +426,35 @@ async function buildFanoutEnforcement({ repo, pr, currentHeadSha, draftGateMarke
|
|
|
312
426
|
{ name: "pre_approval_gate", marker: preApprovalGateMarker, required: preApprovalRequired },
|
|
313
427
|
].filter((spec) => spec.required && spec.marker.visible);
|
|
314
428
|
const gates = [];
|
|
429
|
+
const checkouts = resolveLedgerCheckouts(cwd);
|
|
430
|
+
// checkouts[0] is always resolveRepoRoot(cwd) (resolveLedgerCheckouts adds it
|
|
431
|
+
// first, unconditionally, and never throws — it falls back to cwd on git
|
|
432
|
+
// failure) — reuse it instead of a second `git rev-parse --show-toplevel`.
|
|
433
|
+
const repoRoot = checkouts[0];
|
|
315
434
|
for (const spec of gateSpecs) {
|
|
316
435
|
const headSha = spec.marker.headSha ?? currentHeadSha;
|
|
317
436
|
const ledgerPath = buildLogPath({ repo, pr, gate: spec.name, headSha, tmpRoot: "tmp" });
|
|
318
|
-
|
|
437
|
+
// Re-derive scope FAIL-CLOSED for inline verdicts only (the fan-out default
|
|
438
|
+
// path pays no git I/O). scopeUnderThreshold is true ONLY when lightMode is
|
|
439
|
+
// on, the PR has no gate:full label, a base ref is known, and the merge-base
|
|
440
|
+
// diff for the reviewed head is genuinely under threshold. Any git/scope
|
|
441
|
+
// failure leaves it false, so the inline verdict is rejected exactly as today.
|
|
442
|
+
let scopeUnderThreshold = false;
|
|
443
|
+
if (lightMode && !hasFullLabel && baseRef && spec.marker.executionMode === "inline_single_agent") {
|
|
444
|
+
const scope = detectMergeBaseScope({ base: baseRef, head: headSha, cwd: repoRoot });
|
|
445
|
+
scopeUnderThreshold = scope.ok === true && isEligibleForLightMode(scope, lightThreshold);
|
|
446
|
+
}
|
|
319
447
|
gates.push({
|
|
320
448
|
name: spec.name,
|
|
321
449
|
executionMode: spec.marker.executionMode ?? null,
|
|
450
|
+
inlineReason: spec.marker.inlineReason ?? null,
|
|
451
|
+
scopeUnderThreshold,
|
|
322
452
|
ledgerPath,
|
|
323
|
-
ledgerExists: await
|
|
453
|
+
ledgerExists: await ledgerExistsInAny(checkouts, ledgerPath),
|
|
454
|
+
provenance: requireProvenance ? await readLedgerProvenanceInAny(checkouts, ledgerPath) : null,
|
|
324
455
|
});
|
|
325
456
|
}
|
|
326
|
-
return { required: true, gates };
|
|
457
|
+
return { required: true, requireProvenance, lightMode, hasFullLabel, gates };
|
|
327
458
|
}
|
|
328
459
|
export async function detectCheckpointEvidence(options, { env = process.env, ghCommand = "gh", cwd = process.cwd() } = {}) {
|
|
329
460
|
const runnerOwnership = await ensureAsyncRunnerOwnership({
|
|
@@ -378,8 +509,31 @@ export async function detectCheckpointEvidence(options, { env = process.env, ghC
|
|
|
378
509
|
// treat it as config-unavailable and leave fan-out enforcement disabled
|
|
379
510
|
// (preserves default behavior). Other gate checks remain unaffected.
|
|
380
511
|
let config = null;
|
|
381
|
-
const { config: loadedConfig, errors: configErrors } = await loadDevLoopConfig({ repoRoot: cwd });
|
|
512
|
+
const { config: loadedConfig, errors: configErrors } = await loadDevLoopConfig({ repoRoot: resolveRepoRoot(cwd) });
|
|
382
513
|
config = Array.isArray(configErrors) && configErrors.length > 0 ? null : loadedConfig;
|
|
514
|
+
// Light-mode pre-merge facts (#1174): the base commit for the merge-base scope
|
|
515
|
+
// re-derivation and whether the PR forces full fan-out via the gate:full label.
|
|
516
|
+
// Fetched LAZILY — only when fan-out enforcement is active AND lightMode is on
|
|
517
|
+
// AND a gate actually recorded an inline verdict — so the common fan-out path
|
|
518
|
+
// (and every existing caller/test) makes NO extra gh call and stays unchanged.
|
|
519
|
+
let baseRef = null;
|
|
520
|
+
let hasFullLabel = false;
|
|
521
|
+
const anyInlineVerdict = [draftGateMarker, preApprovalGateMarker].some(
|
|
522
|
+
(marker) => marker.visible && marker.executionMode === "inline_single_agent",
|
|
523
|
+
);
|
|
524
|
+
if (config != null && resolveRequireFanoutEvidence(config) && resolveLightMode(config) != null && anyInlineVerdict) {
|
|
525
|
+
try {
|
|
526
|
+
const lightFacts = await runGhJson(["pr", "view", String(options.pr), "--repo", options.repo, "--json", "baseRefOid,labels"], { env, ghCommand });
|
|
527
|
+
baseRef = typeof lightFacts?.baseRefOid === "string" && lightFacts.baseRefOid.trim().length > 0
|
|
528
|
+
? lightFacts.baseRefOid.trim()
|
|
529
|
+
: null;
|
|
530
|
+
hasFullLabel = Array.isArray(lightFacts?.labels)
|
|
531
|
+
&& lightFacts.labels.some((label) => (typeof label === "string" ? label : label?.name) === GATE_FULL_LABEL);
|
|
532
|
+
} catch {
|
|
533
|
+
// Fail CLOSED: without the label/base facts we cannot safely accept an
|
|
534
|
+
// inline verdict, so leave baseRef null (scope underivable → rejected).
|
|
535
|
+
}
|
|
536
|
+
}
|
|
383
537
|
const fanoutEnforcement = await buildFanoutEnforcement({
|
|
384
538
|
repo: options.repo,
|
|
385
539
|
pr: options.pr,
|
|
@@ -388,6 +542,8 @@ export async function detectCheckpointEvidence(options, { env = process.env, ghC
|
|
|
388
542
|
preApprovalGateMarker,
|
|
389
543
|
config,
|
|
390
544
|
cwd,
|
|
545
|
+
hasFullLabel,
|
|
546
|
+
baseRef,
|
|
391
547
|
});
|
|
392
548
|
return {
|
|
393
549
|
ok: true,
|
|
@@ -458,7 +614,7 @@ async function main() {
|
|
|
458
614
|
process.exitCode = 1;
|
|
459
615
|
return;
|
|
460
616
|
}
|
|
461
|
-
process.
|
|
617
|
+
process.exitCode = emitResult(output, { jq: options.jq, silent: options.silent });
|
|
462
618
|
} catch (error) {
|
|
463
619
|
if (error && typeof error === "object" && "staleRunner" in error && error.staleRunner) {
|
|
464
620
|
const staleRunnerCheck = {
|