dev-loops 0.2.7 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/.claude-plugin/plugin.json +1 -1
- package/.claude/agents/dev-loop.md +1 -1
- package/.claude/agents/developer.md +1 -0
- package/.claude/agents/fixer.md +1 -0
- package/.claude/agents/review.md +30 -0
- package/.claude/hooks/_run-context.mjs +9 -16
- package/.claude/skills/copilot-pr-followup/SKILL.md +62 -6
- package/.claude/skills/dev-loop/SKILL.md +6 -6
- package/.claude/skills/docs/anti-patterns.md +2 -0
- package/.claude/skills/docs/copilot-loop-operations.md +3 -3
- package/.claude/skills/docs/issue-intake-procedure.md +4 -0
- package/.claude/skills/docs/merge-preconditions.md +65 -1
- package/.claude/skills/docs/stop-conditions.md +1 -0
- package/.claude/skills/docs/tracker-first-loop-state.md +6 -6
- package/.claude/skills/local-implementation/SKILL.md +25 -5
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +69 -0
- package/README.md +8 -2
- package/agents/developer.agent.md +1 -0
- package/agents/fixer.agent.md +1 -0
- package/agents/review.agent.md +30 -0
- package/cli/index.mjs +60 -8
- package/extension/README.md +1 -1
- package/package.json +3 -3
- package/scripts/README.md +8 -7
- package/scripts/_core-helpers.mjs +1 -0
- package/scripts/claude/headless-dev-loop.mjs +53 -13
- package/scripts/claude/headless-info-smoke.mjs +45 -11
- package/scripts/github/build-adjacent-bundle.mjs +448 -0
- package/scripts/github/{create-draft-pr.mjs → create-pr.mjs} +28 -12
- package/scripts/github/detect-checkpoint-evidence.mjs +95 -4
- package/scripts/github/offer-human-handoff.mjs +147 -0
- package/scripts/github/post-gate-findings.mjs +392 -0
- package/scripts/github/probe-ci-status.mjs +468 -0
- package/scripts/github/reconcile-draft-gate.mjs +2 -2
- package/scripts/github/request-copilot-review.mjs +72 -11
- package/scripts/github/resolve-handoff-candidates.mjs +412 -0
- package/scripts/github/upsert-checkpoint-verdict.mjs +599 -17
- package/scripts/github/verify-fresh-review-context.mjs +12 -1
- package/scripts/github/write-gate-context.mjs +634 -0
- package/scripts/github/write-gate-findings-log.mjs +1 -1
- package/scripts/loop/_stale-runner-detection.mjs +1 -1
- package/scripts/loop/_worktree-path.mjs +27 -0
- package/scripts/loop/cleanup-worktree.mjs +175 -0
- package/scripts/loop/copilot-pr-handoff.mjs +1 -1
- package/scripts/loop/detect-change-scope.mjs +36 -11
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +30 -18
- package/scripts/loop/detect-stale-runner.mjs +3 -4
- package/scripts/loop/detect-tracker-first-loop-state.mjs +38 -11
- package/scripts/loop/ensure-worktree.mjs +219 -0
- package/scripts/loop/outer-loop.mjs +1 -1
- package/scripts/loop/pr-runner-coordination.mjs +1 -1
- package/scripts/loop/pre-flight-gate.mjs +10 -7
- package/scripts/loop/pre-push-main-guard.mjs +4 -4
- package/scripts/loop/provision-worktree.mjs +243 -0
- package/scripts/loop/resolve-dev-loop-startup.mjs +5 -5
- package/scripts/loop/run-queue.mjs +112 -16
- package/scripts/loop/run-watch-cycle.mjs +75 -22
- package/scripts/projects/add-queue-item.mjs +80 -48
- package/scripts/projects/archive-done-items.mjs +136 -39
- package/scripts/projects/ensure-queue-board.mjs +67 -65
- package/scripts/projects/list-queue-items.mjs +59 -57
- package/scripts/projects/move-queue-item.mjs +125 -125
- package/scripts/projects/reorder-queue-item.mjs +67 -48
- package/scripts/projects/sync-item-status.mjs +199 -0
- package/skills/copilot-pr-followup/SKILL.md +62 -6
- package/skills/dev-loop/SKILL.md +2 -2
- package/skills/dev-loop/scripts/log-bash-exit-1.mjs +2 -2
- package/skills/dev-loop/scripts/phase-files.mjs +2 -2
- package/skills/docs/anti-patterns.md +2 -0
- package/skills/docs/copilot-loop-operations.md +3 -3
- package/skills/docs/issue-intake-procedure.md +4 -0
- package/skills/docs/merge-preconditions.md +65 -1
- package/skills/docs/stop-conditions.md +1 -0
- package/skills/docs/tracker-first-loop-state.md +6 -6
- package/skills/local-implementation/SKILL.md +25 -5
|
@@ -3,7 +3,18 @@ import { mkdir, stat, writeFile } from "node:fs/promises";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { buildParseError, isDirectCliRun, formatCliError } from "../_core-helpers.mjs";
|
|
5
5
|
const USAGE = `Usage: verify-fresh-review-context.mjs [--help] [--scope <name>]
|
|
6
|
-
Verify that the current
|
|
6
|
+
Verify that the current scoped-reviewer session has fresh context.
|
|
7
|
+
|
|
8
|
+
"Fresh" means the reviewer's context is the neutral gate-context builder
|
|
9
|
+
artifact (the build-once diff + adjacent-code bundle) plus its single review
|
|
10
|
+
angle, and explicitly NOT the main (orchestrating) agent's conversation/state
|
|
11
|
+
or a prior reviewer session's state. The injected neutral bundle is the
|
|
12
|
+
INTENDED seed and is NOT contamination; this guard detects main-agent /
|
|
13
|
+
cross-session state bleed by way of a per-(cwd, scope) sentinel: a first run in
|
|
14
|
+
a fresh session creates the sentinel and passes, a re-entry that finds an
|
|
15
|
+
existing sentinel fails closed. Seeding a reviewer with the neutral bundle (a
|
|
16
|
+
path/prompt, not a sentinel) never creates a sentinel, so it never
|
|
17
|
+
false-positives as contaminated.
|
|
7
18
|
Options:
|
|
8
19
|
--scope <name> Unique reviewer scope (e.g. "draft-gate-coverage").
|
|
9
20
|
Must be non-empty, containing only alphanumeric
|
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* write-gate-context.mjs — context-builder handoff artifact writer.
|
|
4
|
+
*
|
|
5
|
+
* The gate-review context-builder (Phase 1 of the gate-review sub-loop) resolves
|
|
6
|
+
* the dynamic review-angle set and writes a deterministic JSON handoff artifact
|
|
7
|
+
* that the downstream fork fan-out reviewers consume. This module owns that
|
|
8
|
+
* artifact: a deterministic path builder, a writer, and a reader for round-trip
|
|
9
|
+
* use, plus a thin context-builder entrypoint (`buildGateContext`) that derives
|
|
10
|
+
* the angle set + rationale directly from the canonical resolver.
|
|
11
|
+
*
|
|
12
|
+
* Angle resolution is NOT re-implemented here. The single source of truth is
|
|
13
|
+
* `resolveGateAnglesDynamic(config, gate, { diff })` from @dev-loops/core/config:
|
|
14
|
+
* it honors the mandatory-angle floor (mandatory angles are always merged back
|
|
15
|
+
* after dynamic selection, filtered by excludeAngles) and falls back to the
|
|
16
|
+
* static configured pool when `dynamicAngles` is off or no diff is available.
|
|
17
|
+
* This module maps that resolver's output into the persisted artifact:
|
|
18
|
+
* resolvedAngles = resolver.recommendedAngles
|
|
19
|
+
* rationale = resolver.skippedAngles (action 'dropped', reason from
|
|
20
|
+
* resolver.reasons) + the rest as action 'kept'
|
|
21
|
+
*
|
|
22
|
+
* The artifact records the resolved angle set + rationale + change scope
|
|
23
|
+
* (branch, head SHA, touched files, acceptance-criteria pointer, validation
|
|
24
|
+
* posture) so reviewers receive a stable, auditable briefing per head SHA.
|
|
25
|
+
*
|
|
26
|
+
* Path scheme mirrors write-gate-findings-log.mjs `buildLogPath`:
|
|
27
|
+
* <tmpRoot>/gate-context/<repo-slug>/pr-<N>/<gate>-<headSha>.json
|
|
28
|
+
*/
|
|
29
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
30
|
+
import path from "node:path";
|
|
31
|
+
import { parseArgs } from "node:util";
|
|
32
|
+
|
|
33
|
+
import { resolveGateAnglesDynamic } from "@dev-loops/core/config";
|
|
34
|
+
|
|
35
|
+
import { parsePrNumber, requireTokenValue } from "../_cli-primitives.mjs";
|
|
36
|
+
import { formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
|
|
37
|
+
import { buildAdjacentBundle, DEFAULT_MAX_FILE_BYTES } from "./build-adjacent-bundle.mjs";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Map the artifact gate name (draft_gate | pre_approval_gate) to the config
|
|
41
|
+
* gate key understood by resolveGateAnglesDynamic (draft | preApproval).
|
|
42
|
+
* @param {string} gate
|
|
43
|
+
* @returns {"draft"|"preApproval"}
|
|
44
|
+
*/
|
|
45
|
+
export function mapGateToConfigKey(gate) {
|
|
46
|
+
if (gate === "draft_gate") return "draft";
|
|
47
|
+
if (gate === "pre_approval_gate") return "preApproval";
|
|
48
|
+
throw new Error(`Unknown gate: ${JSON.stringify(gate)} (expected draft_gate or pre_approval_gate)`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Map a resolveGateAnglesDynamic result into the persisted artifact fields.
|
|
53
|
+
* Does NOT re-derive angles — it only reshapes the resolver's output.
|
|
54
|
+
*
|
|
55
|
+
* @param {{ recommendedAngles: string[]|null, skippedAngles?: string[], reasons?: Record<string,string> }} resolverResult
|
|
56
|
+
* @returns {{ resolvedAngles: string[], rationale: Array<{angle: string, action: "kept"|"dropped", reason: string}> }}
|
|
57
|
+
*/
|
|
58
|
+
export function rationaleFromResolver(resolverResult) {
|
|
59
|
+
const recommended = Array.isArray(resolverResult?.recommendedAngles)
|
|
60
|
+
? resolverResult.recommendedAngles
|
|
61
|
+
: [];
|
|
62
|
+
const skipped = Array.isArray(resolverResult?.skippedAngles)
|
|
63
|
+
? resolverResult.skippedAngles
|
|
64
|
+
: [];
|
|
65
|
+
const reasons = resolverResult?.reasons ?? {};
|
|
66
|
+
const dynamicActive = resolverResult?.dynamicAnglesActive === true;
|
|
67
|
+
const keptReason = dynamicActive
|
|
68
|
+
? "selected by dynamic angle resolver"
|
|
69
|
+
: "static pool (dynamic angle resolution inactive)";
|
|
70
|
+
|
|
71
|
+
const rationale = [];
|
|
72
|
+
for (const angle of recommended) {
|
|
73
|
+
rationale.push({ angle, action: "kept", reason: keptReason });
|
|
74
|
+
}
|
|
75
|
+
for (const angle of skipped) {
|
|
76
|
+
rationale.push({
|
|
77
|
+
angle,
|
|
78
|
+
action: "dropped",
|
|
79
|
+
reason: typeof reasons[angle] === "string" && reasons[angle].length > 0
|
|
80
|
+
? reasons[angle]
|
|
81
|
+
: "not relevant to the change set",
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return { resolvedAngles: [...recommended], rationale };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const USAGE = `Usage: write-gate-context.mjs --repo <owner/name> --pr <number> --gate <draft_gate|pre_approval_gate> --head-sha <sha> --angles <json> [--rationale <json>] [--branch <name>] [--touched-files <json>] [--acceptance-criteria <pointer>] [--validation-posture <text>] [--tmp-root <path>]
|
|
88
|
+
Write a deterministic gate-review context-builder handoff artifact under tmp/ paths.
|
|
89
|
+
Required:
|
|
90
|
+
--repo <owner/name>
|
|
91
|
+
--pr <number>
|
|
92
|
+
--gate <draft_gate|pre_approval_gate>
|
|
93
|
+
--head-sha <sha>
|
|
94
|
+
--angles <json> JSON array of resolved review-angle name strings
|
|
95
|
+
Optional:
|
|
96
|
+
--rationale <json> JSON array of {angle, action, reason} entries
|
|
97
|
+
--branch <name> Source branch name
|
|
98
|
+
--touched-files <json> JSON array of changed file path strings
|
|
99
|
+
--acceptance-criteria <ptr> Pointer to acceptance criteria (issue ref, doc path, URL)
|
|
100
|
+
--validation-posture <text> Short description of the validation posture
|
|
101
|
+
--tmp-root <path> Root tmp directory (default: tmp/)
|
|
102
|
+
`.trim();
|
|
103
|
+
|
|
104
|
+
function parseError(message) {
|
|
105
|
+
return Object.assign(new Error(message), { usage: USAGE });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function normalizeGate(value) {
|
|
109
|
+
const gates = new Set(["draft_gate", "pre_approval_gate"]);
|
|
110
|
+
const normalized = String(value).trim().toLowerCase();
|
|
111
|
+
return gates.has(normalized) ? normalized : null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function normalizeHeadSha(value) {
|
|
115
|
+
const normalized = String(value).trim().toLowerCase();
|
|
116
|
+
return /^[0-9a-f]{7,64}$/i.test(normalized) ? normalized : null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const VALID_ACTIONS = new Set(["kept", "added", "dropped", "joined"]);
|
|
120
|
+
|
|
121
|
+
function parseAnglesJson(raw) {
|
|
122
|
+
let parsed;
|
|
123
|
+
try {
|
|
124
|
+
parsed = JSON.parse(raw);
|
|
125
|
+
} catch {
|
|
126
|
+
throw parseError("--angles must be valid JSON");
|
|
127
|
+
}
|
|
128
|
+
if (!Array.isArray(parsed)) {
|
|
129
|
+
throw parseError("--angles must be a JSON array");
|
|
130
|
+
}
|
|
131
|
+
return parsed.map((a, i) => {
|
|
132
|
+
if (typeof a !== "string" || a.trim().length === 0) {
|
|
133
|
+
throw parseError(`--angles[${i}] must be a non-empty string`);
|
|
134
|
+
}
|
|
135
|
+
return a.trim();
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function parseRationaleJson(raw) {
|
|
140
|
+
let parsed;
|
|
141
|
+
try {
|
|
142
|
+
parsed = JSON.parse(raw);
|
|
143
|
+
} catch {
|
|
144
|
+
throw parseError("--rationale must be valid JSON");
|
|
145
|
+
}
|
|
146
|
+
if (!Array.isArray(parsed)) {
|
|
147
|
+
throw parseError("--rationale must be a JSON array");
|
|
148
|
+
}
|
|
149
|
+
return parsed.map((r, i) => {
|
|
150
|
+
if (!r || typeof r !== "object" || Array.isArray(r)) {
|
|
151
|
+
throw parseError(`--rationale[${i}] must be an object`);
|
|
152
|
+
}
|
|
153
|
+
if (!r.angle || typeof r.angle !== "string" || r.angle.trim().length === 0) {
|
|
154
|
+
throw parseError(`--rationale[${i}].angle is required`);
|
|
155
|
+
}
|
|
156
|
+
if (!r.action || !VALID_ACTIONS.has(r.action)) {
|
|
157
|
+
throw parseError(`--rationale[${i}].action must be one of: kept, added, dropped, joined`);
|
|
158
|
+
}
|
|
159
|
+
if (!r.reason || typeof r.reason !== "string" || r.reason.trim().length === 0) {
|
|
160
|
+
throw parseError(`--rationale[${i}].reason is required`);
|
|
161
|
+
}
|
|
162
|
+
return { angle: r.angle.trim(), action: r.action, reason: r.reason.trim() };
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function parseStringArrayJson(raw, label) {
|
|
167
|
+
let parsed;
|
|
168
|
+
try {
|
|
169
|
+
parsed = JSON.parse(raw);
|
|
170
|
+
} catch {
|
|
171
|
+
throw parseError(`${label} must be valid JSON`);
|
|
172
|
+
}
|
|
173
|
+
if (!Array.isArray(parsed)) {
|
|
174
|
+
throw parseError(`${label} must be a JSON array`);
|
|
175
|
+
}
|
|
176
|
+
return parsed.filter((x) => typeof x === "string" && x.trim().length > 0).map((x) => x.trim());
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function parseWriteGateContextCliArgs(argv) {
|
|
180
|
+
const { tokens } = parseArgs({
|
|
181
|
+
args: [...argv],
|
|
182
|
+
options: {
|
|
183
|
+
help: { type: "boolean", short: "h" },
|
|
184
|
+
repo: { type: "string" },
|
|
185
|
+
pr: { type: "string" },
|
|
186
|
+
gate: { type: "string" },
|
|
187
|
+
"head-sha": { type: "string" },
|
|
188
|
+
angles: { type: "string" },
|
|
189
|
+
rationale: { type: "string" },
|
|
190
|
+
branch: { type: "string" },
|
|
191
|
+
"touched-files": { type: "string" },
|
|
192
|
+
"acceptance-criteria": { type: "string" },
|
|
193
|
+
"validation-posture": { type: "string" },
|
|
194
|
+
"tmp-root": { type: "string" },
|
|
195
|
+
},
|
|
196
|
+
allowPositionals: true,
|
|
197
|
+
strict: false,
|
|
198
|
+
tokens: true,
|
|
199
|
+
});
|
|
200
|
+
const options = {
|
|
201
|
+
repo: undefined,
|
|
202
|
+
pr: undefined,
|
|
203
|
+
gate: undefined,
|
|
204
|
+
headSha: undefined,
|
|
205
|
+
angles: undefined,
|
|
206
|
+
rationale: [],
|
|
207
|
+
branch: null,
|
|
208
|
+
touchedFiles: [],
|
|
209
|
+
acceptanceCriteria: null,
|
|
210
|
+
validationPosture: null,
|
|
211
|
+
tmpRoot: "tmp",
|
|
212
|
+
};
|
|
213
|
+
for (const token of tokens) {
|
|
214
|
+
if (token.kind === "positional") {
|
|
215
|
+
throw parseError(`Unknown argument: ${token.value}`);
|
|
216
|
+
}
|
|
217
|
+
if (token.kind !== "option") {
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (token.name === "help") {
|
|
221
|
+
return { help: true };
|
|
222
|
+
}
|
|
223
|
+
if (token.name === "repo") {
|
|
224
|
+
options.repo = requireTokenValue(token, parseError).trim();
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (token.name === "pr") {
|
|
228
|
+
options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (token.name === "gate") {
|
|
232
|
+
const gate = normalizeGate(requireTokenValue(token, parseError));
|
|
233
|
+
if (!gate) throw parseError("--gate must be draft_gate or pre_approval_gate");
|
|
234
|
+
options.gate = gate;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (token.name === "head-sha") {
|
|
238
|
+
const sha = normalizeHeadSha(requireTokenValue(token, parseError));
|
|
239
|
+
if (!sha) throw parseError("--head-sha must be a 7-64 character hex SHA");
|
|
240
|
+
options.headSha = sha;
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (token.name === "angles") {
|
|
244
|
+
options.angles = parseAnglesJson(requireTokenValue(token, parseError));
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
if (token.name === "rationale") {
|
|
248
|
+
options.rationale = parseRationaleJson(requireTokenValue(token, parseError));
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
if (token.name === "branch") {
|
|
252
|
+
options.branch = requireTokenValue(token, parseError).trim();
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (token.name === "touched-files") {
|
|
256
|
+
options.touchedFiles = parseStringArrayJson(requireTokenValue(token, parseError), "--touched-files");
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (token.name === "acceptance-criteria") {
|
|
260
|
+
options.acceptanceCriteria = requireTokenValue(token, parseError).trim();
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
if (token.name === "validation-posture") {
|
|
264
|
+
options.validationPosture = requireTokenValue(token, parseError).trim();
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if (token.name === "tmp-root") {
|
|
268
|
+
options.tmpRoot = requireTokenValue(token, parseError).trim();
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
272
|
+
}
|
|
273
|
+
const missing = ["repo", "pr", "gate", "headSha", "angles"]
|
|
274
|
+
.filter((k) => options[k] === undefined);
|
|
275
|
+
if (missing.length > 0) {
|
|
276
|
+
throw parseError(`Missing required arguments: ${missing.join(", ")}`);
|
|
277
|
+
}
|
|
278
|
+
return options;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Build the deterministic artifact path for a gate-review context handoff.
|
|
283
|
+
* Mirrors write-gate-findings-log.mjs buildLogPath. Exported for reuse by the
|
|
284
|
+
* fork fan-out reviewers so producer and consumer agree on the path.
|
|
285
|
+
*
|
|
286
|
+
* @param {object} input
|
|
287
|
+
* @param {string} input.repo — owner/name
|
|
288
|
+
* @param {number|string} input.pr
|
|
289
|
+
* @param {string} input.gate — draft_gate | pre_approval_gate
|
|
290
|
+
* @param {string} input.headSha
|
|
291
|
+
* @param {string} [input.tmpRoot] — default "tmp"
|
|
292
|
+
* @returns {string} relative artifact path
|
|
293
|
+
*/
|
|
294
|
+
export function buildGateContextPath({ repo, pr, gate, headSha, tmpRoot = "tmp" }) {
|
|
295
|
+
const repoSlug = repoSlugFor(repo);
|
|
296
|
+
const { pr: safePr, gate: safeGate, headSha: safeSha } = validatePathSegments({ pr, gate, headSha });
|
|
297
|
+
return path.join(tmpRoot, "gate-context", repoSlug, `pr-${safePr}`, `${safeGate}-${safeSha}.json`);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Validate the non-repo path components (gate, pr, headSha) that are
|
|
302
|
+
* interpolated into a filesystem path which is later `path.resolve()`d and
|
|
303
|
+
* read/written. Mirrors the repo-segment safety check in {@link repoSlugFor} so
|
|
304
|
+
* both path builders reject traversal sequences and odd filenames coming from
|
|
305
|
+
* untrusted inputs. Returns sanitized values for interpolation.
|
|
306
|
+
*
|
|
307
|
+
* @param {object} input
|
|
308
|
+
* @param {number|string} input.pr — must coerce to a positive integer
|
|
309
|
+
* @param {string} input.gate — draft_gate | pre_approval_gate
|
|
310
|
+
* @param {string} input.headSha — 7-64 char hex SHA
|
|
311
|
+
* @returns {{ pr: number, gate: string, headSha: string }}
|
|
312
|
+
*/
|
|
313
|
+
function validatePathSegments({ pr, gate, headSha }) {
|
|
314
|
+
if (gate !== "draft_gate" && gate !== "pre_approval_gate") {
|
|
315
|
+
throw new Error(`--gate segment ${JSON.stringify(gate)} is unsafe (expected draft_gate or pre_approval_gate)`);
|
|
316
|
+
}
|
|
317
|
+
// Require a CANONICAL positive integer: the trimmed string must be all digits
|
|
318
|
+
// (`/^\d+$/`) and > 0. This mirrors the CLI's parsePrNumber rule so the path
|
|
319
|
+
// builder cannot accept non-canonical numeric forms ("1e3" → 1000, "0x10" →
|
|
320
|
+
// 16, "1.5") that Number() would coerce to a DIFFERENT pr-<N> segment than the
|
|
321
|
+
// operator/CLI intended, breaking the deterministic producer/consumer
|
|
322
|
+
// round-trip. " 9 " trims to "9" and stays valid; numbers are stringified first.
|
|
323
|
+
const prStr = String(pr).trim();
|
|
324
|
+
const prNum = Number(prStr);
|
|
325
|
+
if (!/^\d+$/.test(prStr) || !Number.isInteger(prNum) || prNum <= 0) {
|
|
326
|
+
throw new Error(`--pr segment ${JSON.stringify(pr)} is unsafe (expected a positive integer)`);
|
|
327
|
+
}
|
|
328
|
+
// Lowercase the validated SHA so the path segment is case-canonical regardless
|
|
329
|
+
// of caller casing, matching the CLI's normalizeHeadSha. A mixed-case
|
|
330
|
+
// headRefOid (e.g. ABC123) must compute the SAME filename as its lowercase
|
|
331
|
+
// form (abc123) or readGateContext / the .diff lookup would miss it — a
|
|
332
|
+
// determinism bug.
|
|
333
|
+
const sha = String(headSha).trim().toLowerCase();
|
|
334
|
+
if (!/^[0-9a-f]{7,64}$/i.test(sha)) {
|
|
335
|
+
throw new Error(`--head-sha segment ${JSON.stringify(headSha)} is unsafe (expected a 7-64 character hex SHA)`);
|
|
336
|
+
}
|
|
337
|
+
return { pr: prNum, gate, headSha: sha };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Validate the repo string and return its `owner-name` slug, applying the same
|
|
342
|
+
* safety checks (no `.`/`..` segments, no whitespace/backslashes) shared by the
|
|
343
|
+
* artifact and diff path builders.
|
|
344
|
+
* @param {string} repo — owner/name
|
|
345
|
+
* @returns {string} repo slug
|
|
346
|
+
*/
|
|
347
|
+
function repoSlugFor(repo) {
|
|
348
|
+
const parts = String(repo).split("/");
|
|
349
|
+
if (parts.length !== 2 || parts.some((p) => p.length === 0)) {
|
|
350
|
+
throw new Error(`--repo must be in owner/name format, got: ${JSON.stringify(repo)}`);
|
|
351
|
+
}
|
|
352
|
+
for (const p of parts) {
|
|
353
|
+
if (p === "." || p === ".." || /[\s\\]/.test(p)) {
|
|
354
|
+
throw new Error(`--repo segment ${JSON.stringify(p)} is unsafe (a "." or ".." path segment, or contains whitespace/backslashes)`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return parts.join("-");
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Build the deterministic path for the FULL diff captured alongside the gate
|
|
362
|
+
* context artifact. Mirrors buildGateContextPath but with a `.diff` extension so
|
|
363
|
+
* scoped reviewers can read the entire change set (not just hunks) from a stable
|
|
364
|
+
* location. Exported for reuse by the fork fan-out reviewers.
|
|
365
|
+
*
|
|
366
|
+
* @param {object} input
|
|
367
|
+
* @param {string} input.repo — owner/name
|
|
368
|
+
* @param {number|string} input.pr
|
|
369
|
+
* @param {string} input.gate — draft_gate | pre_approval_gate
|
|
370
|
+
* @param {string} input.headSha
|
|
371
|
+
* @param {string} [input.tmpRoot] — default "tmp"
|
|
372
|
+
* @returns {string} relative diff path
|
|
373
|
+
*/
|
|
374
|
+
export function buildGateDiffPath({ repo, pr, gate, headSha, tmpRoot = "tmp" }) {
|
|
375
|
+
const repoSlug = repoSlugFor(repo);
|
|
376
|
+
const { pr: safePr, gate: safeGate, headSha: safeSha } = validatePathSegments({ pr, gate, headSha });
|
|
377
|
+
return path.join(tmpRoot, "gate-context", repoSlug, `pr-${safePr}`, `${safeGate}-${safeSha}.diff`);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Parse `git diff --name-status` output into full repo-relative changed file
|
|
382
|
+
* paths. Handles rename/copy entries (R100 old new, C75 old new) by recording
|
|
383
|
+
* the destination path. Tolerates blank lines and malformed rows.
|
|
384
|
+
* @param {string} nameStatusOutput
|
|
385
|
+
* @returns {string[]}
|
|
386
|
+
*/
|
|
387
|
+
export function parseChangedFiles(nameStatusOutput) {
|
|
388
|
+
if (typeof nameStatusOutput !== "string" || nameStatusOutput.length === 0) {
|
|
389
|
+
return [];
|
|
390
|
+
}
|
|
391
|
+
const files = [];
|
|
392
|
+
for (const line of nameStatusOutput.split("\n")) {
|
|
393
|
+
const trimmed = line.replace(/\r$/, "");
|
|
394
|
+
if (trimmed.trim().length === 0) continue;
|
|
395
|
+
const cols = trimmed.split("\t");
|
|
396
|
+
if (cols.length < 2) continue;
|
|
397
|
+
const status = cols[0].trim();
|
|
398
|
+
let dest;
|
|
399
|
+
if (/^[RC]\d*$/i.test(status)) {
|
|
400
|
+
// Rename (Rxxx) / copy (Cxxx) entries carry status + old + new paths and
|
|
401
|
+
// must have >= 3 columns; record the new (last) path. A malformed 2-column
|
|
402
|
+
// rename/copy row (e.g. "R100\told-path", missing the new path) is skipped
|
|
403
|
+
// rather than misrecording the OLD path as the changed file.
|
|
404
|
+
if (cols.length < 3) continue;
|
|
405
|
+
dest = cols[cols.length - 1];
|
|
406
|
+
} else {
|
|
407
|
+
dest = cols[1];
|
|
408
|
+
}
|
|
409
|
+
const file = (dest ?? "").trim();
|
|
410
|
+
if (file.length > 0) files.push(file);
|
|
411
|
+
}
|
|
412
|
+
return files;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Build the deterministic artifact object (no I/O). Exported for callers that
|
|
417
|
+
* want the artifact shape without writing it.
|
|
418
|
+
*
|
|
419
|
+
* @param {object} options — parsed CLI options shape
|
|
420
|
+
* @returns {object}
|
|
421
|
+
*/
|
|
422
|
+
export function buildGateContextArtifact(options) {
|
|
423
|
+
const artifact = {
|
|
424
|
+
repo: options.repo,
|
|
425
|
+
pr: options.pr,
|
|
426
|
+
gate: options.gate,
|
|
427
|
+
headSha: options.headSha,
|
|
428
|
+
resolvedAngles: [...options.angles],
|
|
429
|
+
rationale: Array.isArray(options.rationale) ? options.rationale : [],
|
|
430
|
+
scope: {
|
|
431
|
+
branch: options.branch ?? null,
|
|
432
|
+
headSha: options.headSha,
|
|
433
|
+
touchedFiles: Array.isArray(options.touchedFiles) ? options.touchedFiles : [],
|
|
434
|
+
changedFiles: Array.isArray(options.changedFiles) ? options.changedFiles : [],
|
|
435
|
+
diffPath: options.diffPath ?? null,
|
|
436
|
+
acceptanceCriteria: options.acceptanceCriteria ?? null,
|
|
437
|
+
validationPosture: options.validationPosture ?? null,
|
|
438
|
+
},
|
|
439
|
+
};
|
|
440
|
+
// ADD (#895): the deterministic, neutral adjacent-code bundle. Only present
|
|
441
|
+
// when the context-builder computed it — keeps the artifact shape backward
|
|
442
|
+
// compatible for callers that build the artifact without an adjacency pass.
|
|
443
|
+
if (options.adjacentCode && typeof options.adjacentCode === "object") {
|
|
444
|
+
artifact.adjacentCode = options.adjacentCode;
|
|
445
|
+
}
|
|
446
|
+
return artifact;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export async function writeGateContext(options, { repoRoot = process.cwd() } = {}) {
|
|
450
|
+
const contextPath = buildGateContextPath({
|
|
451
|
+
repo: options.repo,
|
|
452
|
+
pr: options.pr,
|
|
453
|
+
gate: options.gate,
|
|
454
|
+
headSha: options.headSha,
|
|
455
|
+
tmpRoot: options.tmpRoot || "tmp",
|
|
456
|
+
});
|
|
457
|
+
const fullPath = path.resolve(repoRoot, contextPath);
|
|
458
|
+
const artifact = {
|
|
459
|
+
...buildGateContextArtifact(options),
|
|
460
|
+
loggedAt: new Date().toISOString(),
|
|
461
|
+
};
|
|
462
|
+
await mkdir(path.dirname(fullPath), { recursive: true });
|
|
463
|
+
await writeFile(fullPath, JSON.stringify(artifact, null, 2) + "\n", "utf8");
|
|
464
|
+
return { ok: true, path: contextPath, artifact };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Context-builder entrypoint: resolve the dynamic review-angle set via the
|
|
469
|
+
* canonical resolver and persist the handoff artifact.
|
|
470
|
+
*
|
|
471
|
+
* Angle selection is delegated entirely to `resolveGateAnglesDynamic` (the
|
|
472
|
+
* single source of truth, which honors the mandatory-angle floor and falls back
|
|
473
|
+
* to the static configured pool when `dynamicAngles` is off or no diff is
|
|
474
|
+
* given). This function only maps that result into the artifact and writes it.
|
|
475
|
+
*
|
|
476
|
+
* @param {object} input
|
|
477
|
+
* @param {import("@dev-loops/core/config").DevLoopConfig} input.config — merged dev-loop config
|
|
478
|
+
* @param {string} input.gate — draft_gate | pre_approval_gate
|
|
479
|
+
* @param {{ nameStatusOutput: string, diffOutput?: string }} [input.diff] — diff for dynamic resolution; when `diffOutput` is present it is also persisted to `scope.diffPath` and parsed into `scope.changedFiles`
|
|
480
|
+
* @param {string} input.repo — owner/name
|
|
481
|
+
* @param {number|string} input.pr
|
|
482
|
+
* @param {string} input.headSha
|
|
483
|
+
* @param {string|null} [input.branch]
|
|
484
|
+
* @param {string[]} [input.touchedFiles]
|
|
485
|
+
* @param {string|null} [input.acceptanceCriteria]
|
|
486
|
+
* @param {string|null} [input.validationPosture]
|
|
487
|
+
* @param {number} [input.maxFileBytes] — per-file cap for the adjacent-code bundle (default DEFAULT_MAX_FILE_BYTES)
|
|
488
|
+
* @param {string} [input.tmpRoot]
|
|
489
|
+
* @param {{ repoRoot?: string }} [opts]
|
|
490
|
+
* @returns {Promise<{ ok: boolean, path: string, artifact: object, resolver: object }>}
|
|
491
|
+
*
|
|
492
|
+
* The artifact additionally carries a deterministic, neutral `adjacentCode`
|
|
493
|
+
* bundle (#895) when changed files are present: 1-hop import in/out-edges of the
|
|
494
|
+
* changed files with size guards + a stripped/truncated manifest. Reviewers are
|
|
495
|
+
* seeded with this verbatim instead of re-deriving the diff + adjacent code.
|
|
496
|
+
*/
|
|
497
|
+
export async function buildGateContext(input, { repoRoot = process.cwd() } = {}) {
|
|
498
|
+
const configKey = mapGateToConfigKey(input.gate);
|
|
499
|
+
const resolverResult = await resolveGateAnglesDynamic(input.config, configKey, {
|
|
500
|
+
diff: input.diff,
|
|
501
|
+
});
|
|
502
|
+
const { resolvedAngles, rationale } = rationaleFromResolver(resolverResult);
|
|
503
|
+
|
|
504
|
+
const tmpRoot = input.tmpRoot || "tmp";
|
|
505
|
+
|
|
506
|
+
// Best-effort full-diff capture: when the resolver was handed a diff with
|
|
507
|
+
// `diffOutput`, persist the ENTIRE diff to a deterministic `.diff` file next
|
|
508
|
+
// to the context artifact so scoped reviewers can read the full change set
|
|
509
|
+
// (not just hunks) and trace adjacent code. Reference it from `scope.diffPath`
|
|
510
|
+
// (relative) and record full repo-relative `scope.changedFiles` parsed from
|
|
511
|
+
// the diff's `nameStatusOutput`. We do NOT inline the diff in the JSON.
|
|
512
|
+
const diffOutput = input.diff?.diffOutput;
|
|
513
|
+
let diffPath = null;
|
|
514
|
+
let changedFiles = parseChangedFiles(input.diff?.nameStatusOutput);
|
|
515
|
+
if (typeof diffOutput === "string" && diffOutput.length > 0) {
|
|
516
|
+
diffPath = buildGateDiffPath({
|
|
517
|
+
repo: input.repo,
|
|
518
|
+
pr: input.pr,
|
|
519
|
+
gate: input.gate,
|
|
520
|
+
headSha: input.headSha,
|
|
521
|
+
tmpRoot,
|
|
522
|
+
});
|
|
523
|
+
const fullDiffPath = path.resolve(repoRoot, diffPath);
|
|
524
|
+
try {
|
|
525
|
+
await mkdir(path.dirname(fullDiffPath), { recursive: true });
|
|
526
|
+
await writeFile(fullDiffPath, diffOutput.endsWith("\n") ? diffOutput : diffOutput + "\n", "utf8");
|
|
527
|
+
} catch (err) {
|
|
528
|
+
// Best-effort: a diff-file write failure (disk, permissions) must not block
|
|
529
|
+
// the context artifact. Degrade to diffPath=null; reviewers reconstruct the
|
|
530
|
+
// diff with `git diff`. changedFiles (from nameStatusOutput) is unaffected.
|
|
531
|
+
process.stderr.write(`[gate-context] full-diff capture failed (continuing without scope.diffPath): ${err?.message ?? err}\n`);
|
|
532
|
+
diffPath = null;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Build the deterministic, neutral adjacent-code bundle ONCE (#895): for each
|
|
537
|
+
// changed source file, include its 1-hop import out-edges (files it imports)
|
|
538
|
+
// and in-edges (files that import it), with size guards (skip
|
|
539
|
+
// lockfiles/generated/binary/minified; cap per-file bytes; truncate the long
|
|
540
|
+
// tail) recorded in a stripped/truncated manifest. Every independent reviewer
|
|
541
|
+
// is seeded with this identical bundle instead of re-deriving it — work-dedup.
|
|
542
|
+
// Best-effort: bundle computation must never block the context artifact.
|
|
543
|
+
let adjacentCode = null;
|
|
544
|
+
if (changedFiles.length > 0) {
|
|
545
|
+
try {
|
|
546
|
+
adjacentCode = await buildAdjacentBundle({
|
|
547
|
+
changedFiles,
|
|
548
|
+
repoRoot,
|
|
549
|
+
maxFileBytes: typeof input.maxFileBytes === "number" && input.maxFileBytes > 0
|
|
550
|
+
? input.maxFileBytes
|
|
551
|
+
: DEFAULT_MAX_FILE_BYTES,
|
|
552
|
+
});
|
|
553
|
+
} catch (err) {
|
|
554
|
+
process.stderr.write(`[gate-context] adjacent-code bundle failed (continuing without adjacentCode): ${err?.message ?? err}\n`);
|
|
555
|
+
adjacentCode = null;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const writeResult = await writeGateContext(
|
|
560
|
+
{
|
|
561
|
+
repo: input.repo,
|
|
562
|
+
pr: input.pr,
|
|
563
|
+
gate: input.gate,
|
|
564
|
+
headSha: input.headSha,
|
|
565
|
+
angles: resolvedAngles,
|
|
566
|
+
rationale,
|
|
567
|
+
branch: input.branch ?? null,
|
|
568
|
+
touchedFiles: input.touchedFiles ?? [],
|
|
569
|
+
changedFiles,
|
|
570
|
+
diffPath,
|
|
571
|
+
adjacentCode,
|
|
572
|
+
acceptanceCriteria: input.acceptanceCriteria ?? null,
|
|
573
|
+
validationPosture: input.validationPosture ?? null,
|
|
574
|
+
tmpRoot,
|
|
575
|
+
},
|
|
576
|
+
{ repoRoot },
|
|
577
|
+
);
|
|
578
|
+
|
|
579
|
+
return { ...writeResult, resolver: resolverResult };
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Read a previously-written gate context artifact. Returns null when absent.
|
|
584
|
+
*
|
|
585
|
+
* @param {object} input — { repo, pr, gate, headSha, tmpRoot }
|
|
586
|
+
* @param {{ repoRoot?: string }} [options]
|
|
587
|
+
* @returns {Promise<object|null>}
|
|
588
|
+
*/
|
|
589
|
+
export async function readGateContext(input, { repoRoot = process.cwd() } = {}) {
|
|
590
|
+
const contextPath = buildGateContextPath({
|
|
591
|
+
repo: input.repo,
|
|
592
|
+
pr: input.pr,
|
|
593
|
+
gate: input.gate,
|
|
594
|
+
headSha: input.headSha,
|
|
595
|
+
tmpRoot: input.tmpRoot || "tmp",
|
|
596
|
+
});
|
|
597
|
+
const fullPath = path.resolve(repoRoot, contextPath);
|
|
598
|
+
try {
|
|
599
|
+
const raw = await readFile(fullPath, "utf8");
|
|
600
|
+
return JSON.parse(raw);
|
|
601
|
+
} catch (err) {
|
|
602
|
+
if (err && err.code === "ENOENT") return null;
|
|
603
|
+
throw err;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
async function main() {
|
|
608
|
+
let options;
|
|
609
|
+
try {
|
|
610
|
+
options = parseWriteGateContextCliArgs(process.argv.slice(2));
|
|
611
|
+
} catch (error) {
|
|
612
|
+
process.stderr.write(`${formatCliError(error, { usage: USAGE })}\n`);
|
|
613
|
+
process.exitCode = 1;
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
if (options.help) {
|
|
617
|
+
process.stdout.write(`${USAGE}\n`);
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
try {
|
|
621
|
+
const result = await writeGateContext(options);
|
|
622
|
+
process.stdout.write(JSON.stringify(result) + "\n");
|
|
623
|
+
} catch (error) {
|
|
624
|
+
process.stderr.write(JSON.stringify({
|
|
625
|
+
ok: false,
|
|
626
|
+
error: error instanceof Error ? error.message : String(error),
|
|
627
|
+
}) + "\n");
|
|
628
|
+
process.exitCode = 1;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
633
|
+
await main();
|
|
634
|
+
}
|
|
@@ -168,7 +168,7 @@ export function parseWriteGateFindingsLogCliArgs(argv) {
|
|
|
168
168
|
}
|
|
169
169
|
return options;
|
|
170
170
|
}
|
|
171
|
-
function buildLogPath({ repo, pr, gate, headSha, tmpRoot }) {
|
|
171
|
+
export function buildLogPath({ repo, pr, gate, headSha, tmpRoot }) {
|
|
172
172
|
const parts = repo.split("/");
|
|
173
173
|
if (parts.length !== 2 || parts.some(p => p.length === 0)) {
|
|
174
174
|
throw new Error(`--repo must be in owner/name format, got: ${JSON.stringify(repo)}`);
|
|
@@ -18,7 +18,7 @@ export function resolveStaleRunnerMaxAgeMs(options = {}, env = process.env) {
|
|
|
18
18
|
if (Number.isFinite(explicit) && explicit > 0) {
|
|
19
19
|
return Math.floor(explicit);
|
|
20
20
|
}
|
|
21
|
-
const fromEnv = parsePositiveIntegerMs(env?.
|
|
21
|
+
const fromEnv = parsePositiveIntegerMs(env?.DEVLOOPS_STALE_RUNNER_MAX_AGE_MS, NaN);
|
|
22
22
|
if (Number.isFinite(fromEnv) && fromEnv > 0) {
|
|
23
23
|
return fromEnv;
|
|
24
24
|
}
|