dev-loops 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/.claude-plugin/plugin.json +1 -1
- package/.claude/agents/dev-loop.md +1 -1
- package/.claude/agents/refiner.md +1 -0
- package/.claude/commands/auto.md +7 -0
- package/.claude/commands/continue.md +15 -0
- package/.claude/commands/info.md +7 -0
- package/.claude/commands/start-spike.md +16 -0
- package/.claude/commands/start.md +7 -0
- package/.claude/commands/status.md +6 -0
- package/.claude/hooks/_run-context.mjs +11 -4
- package/.claude/skills/dev-loop/SKILL.md +21 -6
- package/.claude/skills/dev-loop/templates/slides-story-review.md +54 -0
- package/.claude/skills/docs/artifact-authority-contract.md +86 -31
- package/.claude/skills/docs/local-planning-flow.md +63 -0
- package/.claude/skills/docs/local-planning-worked-example.md +139 -0
- package/.claude/skills/docs/merge-preconditions.md +35 -0
- package/.claude/skills/docs/plan-file-contract.md +37 -0
- package/.claude/skills/docs/release-runbook.md +45 -0
- package/.claude/skills/docs/retrospective-checkpoint-contract.md +55 -7
- package/.claude/skills/docs/spike-mode-contract.md +237 -0
- package/.claude/skills/docs/ui-e2e-scoping-step.md +102 -0
- package/.claude/skills/local-implementation/SKILL.md +1 -1
- package/CHANGELOG.md +73 -0
- package/README.md +21 -1
- package/agents/dev-loop.agent.md +8 -1
- package/agents/refiner.agent.md +1 -0
- package/cli/index.mjs +2 -0
- package/extension/index.ts +10 -1
- package/extension/presentation.ts +15 -0
- package/lib/dev-loops-core.mjs +141 -0
- package/package.json +8 -3
- package/scripts/claude/generate-claude-assets.mjs +15 -1
- package/scripts/github/capture-review-threads.mjs +20 -2
- package/scripts/github/comment-issue.mjs +181 -0
- package/scripts/github/fetch-ci-logs.mjs +215 -0
- package/scripts/github/list-issues.mjs +191 -0
- package/scripts/github/probe-copilot-review.mjs +69 -3
- package/scripts/github/upsert-checkpoint-verdict.mjs +18 -3
- package/scripts/lib/jq-output.mjs +297 -0
- package/scripts/loop/_handoff-contract.mjs +1 -0
- package/scripts/loop/check-retro-tooling.mjs +246 -0
- package/scripts/loop/copilot-pr-handoff.mjs +21 -3
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +65 -2
- package/scripts/loop/docs-grill-contract.mjs +70 -0
- package/scripts/loop/info.mjs +21 -2
- package/scripts/loop/pr-runner-coordination.mjs +20 -4
- package/scripts/loop/resolve-dev-loop-startup.mjs +176 -5
- package/scripts/loop/resolve-pr-conflicts.mjs +357 -0
- package/scripts/loop/run-conductor-cycle.mjs +5 -0
- package/scripts/loop/run-watch-cycle.mjs +77 -3
- package/scripts/loop/slides-story-review-contract.mjs +123 -0
- package/scripts/pages/build-site.mjs +136 -0
- package/scripts/projects/add-queue-item.mjs +12 -2
- package/scripts/projects/list-queue-items.mjs +12 -2
- package/scripts/projects/move-queue-item.mjs +12 -2
- package/scripts/projects/resolve-active-board-item.mjs +193 -0
- package/scripts/refine/_refine-helpers.mjs +20 -0
- package/scripts/refine/exit-spike.mjs +186 -0
- package/scripts/refine/promote-plan.mjs +387 -0
- package/scripts/refine/refine-plan-file.mjs +165 -0
- package/scripts/refine/scaffold-spike-file.mjs +183 -0
- package/scripts/refine/validate-plan-file.mjs +64 -0
- package/scripts/refine/validate-spike-file.mjs +87 -0
- package/scripts/release/extract-changelog-section.mjs +111 -0
- package/skills/dev-loop/SKILL.md +24 -2
- package/skills/dev-loop/templates/slides-story-review.md +54 -0
- package/skills/docs/artifact-authority-contract.md +86 -31
- package/skills/docs/local-planning-flow.md +63 -0
- package/skills/docs/local-planning-worked-example.md +139 -0
- package/skills/docs/merge-preconditions.md +35 -0
- package/skills/docs/plan-file-contract.md +37 -0
- package/skills/docs/release-runbook.md +45 -0
- package/skills/docs/retrospective-checkpoint-contract.md +55 -7
- package/skills/docs/spike-mode-contract.md +237 -0
- package/skills/docs/ui-e2e-scoping-step.md +102 -0
- package/skills/local-implementation/SKILL.md +1 -1
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { parseArgs } from "node:util";
|
|
6
|
+
|
|
7
|
+
import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
|
|
8
|
+
import { requireTokenValue } from "../_cli-primitives.mjs";
|
|
9
|
+
|
|
10
|
+
const USAGE = `Usage: resolve-pr-conflicts.mjs [--base <branch>] [--repo-root <dir>] [--no-verify] [--push] [--json]
|
|
11
|
+
|
|
12
|
+
Deterministic, conservative auto-resolve for a PR branch that is behind/CONFLICTING
|
|
13
|
+
with its base. Merges \`origin/<base>\` into the current branch and resolves ONLY the
|
|
14
|
+
safe additive case: a CHANGELOG.md conflict where both sides only ADD list/section
|
|
15
|
+
entries (keep BOTH sides, in order). ANY other conflicted path — or a non-additive
|
|
16
|
+
CHANGELOG edit — FAILS CLOSED, naming the conflicted paths. Never guesses.
|
|
17
|
+
|
|
18
|
+
After a clean merge (or an auto-resolved additive CHANGELOG) it runs \`npm run test:docs\`
|
|
19
|
+
(unless --no-verify) and, with --push, pushes the branch.
|
|
20
|
+
|
|
21
|
+
Options:
|
|
22
|
+
--base <branch> Base branch to merge from (default: derived from \`gh pr view\`
|
|
23
|
+
base, falling back to "main").
|
|
24
|
+
--repo-root <dir> Repo working tree to operate in (default: cwd).
|
|
25
|
+
--no-verify Skip \`npm run test:docs\` after resolving.
|
|
26
|
+
--push Push the resolved branch to origin after verify.
|
|
27
|
+
--json Emit machine-readable JSON on stdout.
|
|
28
|
+
|
|
29
|
+
Output (stdout, JSON with --json):
|
|
30
|
+
{ "ok": true, "action": "clean_merge"|"resolved",
|
|
31
|
+
"base": "main", "resolvedFiles": ["CHANGELOG.md"], "pushed": false,
|
|
32
|
+
"verified": true }
|
|
33
|
+
("clean_merge" covers an already-up-to-date branch; "resolved" is the
|
|
34
|
+
auto-resolved additive-CHANGELOG case. "verified" is true when post-resolve
|
|
35
|
+
verification ran; it is absent with --no-verify.)
|
|
36
|
+
Error output:
|
|
37
|
+
{ "ok": false, "error": "...", "conflictFiles": ["..."] }
|
|
38
|
+
|
|
39
|
+
Exit codes:
|
|
40
|
+
0 Clean merge (incl. already up to date) or auto-resolved CHANGELOG
|
|
41
|
+
1 Argument error, git failure, or UNRESOLVABLE conflict (fail closed)`.trim();
|
|
42
|
+
|
|
43
|
+
const parseError = buildParseError(USAGE);
|
|
44
|
+
|
|
45
|
+
const SAFE_RESOLVABLE_PATH = "CHANGELOG.md";
|
|
46
|
+
|
|
47
|
+
// Identity for merge/resolve commits so auto-resolve works on a clean runner
|
|
48
|
+
// (CI / consumer) where no ambient git user is configured. Per-command via -c
|
|
49
|
+
// so we never mutate the repo's config.
|
|
50
|
+
const BOT_IDENTITY = [
|
|
51
|
+
"-c", "user.name=dev-loops[bot]",
|
|
52
|
+
"-c", "user.email=dev-loops[bot]@users.noreply.github.com",
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
export function parseResolvePrConflictsCliArgs(argv) {
|
|
56
|
+
const options = {
|
|
57
|
+
help: false,
|
|
58
|
+
base: null,
|
|
59
|
+
repoRoot: process.cwd(),
|
|
60
|
+
verify: true,
|
|
61
|
+
push: false,
|
|
62
|
+
json: false,
|
|
63
|
+
};
|
|
64
|
+
const { tokens } = parseArgs({
|
|
65
|
+
args: [...argv],
|
|
66
|
+
options: {
|
|
67
|
+
help: { type: "boolean", short: "h" },
|
|
68
|
+
base: { type: "string" },
|
|
69
|
+
"repo-root": { type: "string" },
|
|
70
|
+
"no-verify": { type: "boolean" },
|
|
71
|
+
push: { type: "boolean" },
|
|
72
|
+
json: { type: "boolean" },
|
|
73
|
+
},
|
|
74
|
+
allowPositionals: true,
|
|
75
|
+
strict: false,
|
|
76
|
+
tokens: true,
|
|
77
|
+
});
|
|
78
|
+
for (const token of tokens) {
|
|
79
|
+
if (token.kind === "positional") throw parseError(`Unknown argument: ${token.value}`);
|
|
80
|
+
if (token.kind !== "option") continue;
|
|
81
|
+
switch (token.name) {
|
|
82
|
+
case "help":
|
|
83
|
+
options.help = true;
|
|
84
|
+
return options;
|
|
85
|
+
case "base": {
|
|
86
|
+
const value = requireTokenValue(token, parseError, { flagPattern: /^-/u }).trim();
|
|
87
|
+
options.base = value.length > 0 ? value : null;
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
case "repo-root": {
|
|
91
|
+
const value = requireTokenValue(token, parseError, { flagPattern: /^-/u }).trim();
|
|
92
|
+
options.repoRoot = value.length > 0 ? value : process.cwd();
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
case "no-verify":
|
|
96
|
+
options.verify = false;
|
|
97
|
+
break;
|
|
98
|
+
case "push":
|
|
99
|
+
options.push = true;
|
|
100
|
+
break;
|
|
101
|
+
case "json":
|
|
102
|
+
options.json = true;
|
|
103
|
+
break;
|
|
104
|
+
default:
|
|
105
|
+
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return options;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function run(command, args, { cwd, env = process.env } = {}) {
|
|
112
|
+
return new Promise((resolve, reject) => {
|
|
113
|
+
const child = spawn(command, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
|
|
114
|
+
let stdout = "";
|
|
115
|
+
let stderr = "";
|
|
116
|
+
child.stdout.on("data", (chunk) => { stdout += String(chunk); });
|
|
117
|
+
child.stderr.on("data", (chunk) => { stderr += String(chunk); });
|
|
118
|
+
child.on("error", reject);
|
|
119
|
+
child.on("close", (code) => { resolve({ code, stdout, stderr }); });
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function listUnmergedFiles({ cwd, env }) {
|
|
124
|
+
const result = await run("git", ["diff", "--name-only", "--diff-filter=U"], { cwd, env });
|
|
125
|
+
if (result.code !== 0) {
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
return result.stdout.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Decide whether a single conflicted file is the safe additive CHANGELOG case
|
|
133
|
+
* and, if so, produce the merged content (keep BOTH sides, in order).
|
|
134
|
+
*
|
|
135
|
+
* Requires diff3-style conflict markers so each hunk carries its merge BASE:
|
|
136
|
+
*
|
|
137
|
+
* <<<<<<< ours
|
|
138
|
+
* ...ours...
|
|
139
|
+
* ||||||| base
|
|
140
|
+
* ...base...
|
|
141
|
+
* =======
|
|
142
|
+
* ...theirs...
|
|
143
|
+
* >>>>>>> theirs
|
|
144
|
+
*
|
|
145
|
+
* STRUCTURAL additivity (not lexical): a hunk is safe to keep-both ONLY when its
|
|
146
|
+
* BASE section is EMPTY — a true insertion on both sides at a spot where base had
|
|
147
|
+
* nothing. A non-empty base means a shared line was MODIFIED or DELETED on at
|
|
148
|
+
* least one side → fail closed (a lexical "looks like a list item" check cannot
|
|
149
|
+
* tell an add from a modify, which silently duplicates/resurrects entries).
|
|
150
|
+
*
|
|
151
|
+
* Keep-both order: ours block then theirs block, preserving each side's order.
|
|
152
|
+
*/
|
|
153
|
+
export function resolveAdditiveChangelog(content) {
|
|
154
|
+
const lines = content.split("\n");
|
|
155
|
+
const out = [];
|
|
156
|
+
let i = 0;
|
|
157
|
+
let resolvedAnyHunk = false;
|
|
158
|
+
|
|
159
|
+
while (i < lines.length) {
|
|
160
|
+
const line = lines[i];
|
|
161
|
+
if (!line.startsWith("<<<<<<<")) {
|
|
162
|
+
out.push(line);
|
|
163
|
+
i += 1;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
// Conflict hunk: <<<<<<< ours ... ||||||| base ... ======= ... >>>>>>> theirs
|
|
167
|
+
const ours = [];
|
|
168
|
+
const base = [];
|
|
169
|
+
const theirs = [];
|
|
170
|
+
i += 1; // skip <<<<<<<
|
|
171
|
+
while (i < lines.length && !lines[i].startsWith("|||||||") && !lines[i].startsWith("=======")) {
|
|
172
|
+
ours.push(lines[i]);
|
|
173
|
+
i += 1;
|
|
174
|
+
}
|
|
175
|
+
if (i >= lines.length) {
|
|
176
|
+
return { safe: false, reason: "malformed conflict markers (no ======= separator)" };
|
|
177
|
+
}
|
|
178
|
+
if (!lines[i].startsWith("|||||||")) {
|
|
179
|
+
// No base section ⇒ not diff3 style; we cannot tell add from modify.
|
|
180
|
+
return { safe: false, reason: "missing diff3 base section (||||||| marker)" };
|
|
181
|
+
}
|
|
182
|
+
i += 1; // skip |||||||
|
|
183
|
+
while (i < lines.length && !lines[i].startsWith("=======")) {
|
|
184
|
+
base.push(lines[i]);
|
|
185
|
+
i += 1;
|
|
186
|
+
}
|
|
187
|
+
if (i >= lines.length) {
|
|
188
|
+
return { safe: false, reason: "malformed conflict markers (no ======= separator)" };
|
|
189
|
+
}
|
|
190
|
+
i += 1; // skip =======
|
|
191
|
+
while (i < lines.length && !lines[i].startsWith(">>>>>>>")) {
|
|
192
|
+
theirs.push(lines[i]);
|
|
193
|
+
i += 1;
|
|
194
|
+
}
|
|
195
|
+
if (i >= lines.length) {
|
|
196
|
+
return { safe: false, reason: "malformed conflict markers (no >>>>>>> terminator)" };
|
|
197
|
+
}
|
|
198
|
+
i += 1; // skip >>>>>>>
|
|
199
|
+
|
|
200
|
+
// Additive ⇔ base had no line here (both sides inserted). Any non-empty base
|
|
201
|
+
// section is a modify/delete of a shared line → fail closed.
|
|
202
|
+
if (base.some((b) => b.trim().length > 0)) {
|
|
203
|
+
return { safe: false, reason: "CHANGELOG conflict modifies or deletes a shared line (non-empty merge base)" };
|
|
204
|
+
}
|
|
205
|
+
// Keep both sides, in order.
|
|
206
|
+
out.push(...ours, ...theirs);
|
|
207
|
+
resolvedAnyHunk = true;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!resolvedAnyHunk) {
|
|
211
|
+
return { safe: false, reason: "no conflict hunks found in CHANGELOG.md" };
|
|
212
|
+
}
|
|
213
|
+
return { safe: true, content: out.join("\n") };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function abortMerge({ cwd, env }) {
|
|
217
|
+
await run("git", ["merge", "--abort"], { cwd, env });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export async function resolvePrConflicts(options, { env = process.env } = {}) {
|
|
221
|
+
const cwd = options.repoRoot;
|
|
222
|
+
|
|
223
|
+
// Resolve base branch: explicit flag, else `gh pr view`, else "main".
|
|
224
|
+
let base = options.base;
|
|
225
|
+
if (!base) {
|
|
226
|
+
const view = await run("gh", ["pr", "view", "--json", "baseRefName"], { cwd, env });
|
|
227
|
+
if (view.code === 0) {
|
|
228
|
+
try {
|
|
229
|
+
const parsed = JSON.parse(view.stdout);
|
|
230
|
+
if (typeof parsed?.baseRefName === "string" && parsed.baseRefName.trim().length > 0) {
|
|
231
|
+
base = parsed.baseRefName.trim();
|
|
232
|
+
}
|
|
233
|
+
} catch {
|
|
234
|
+
// fall through to default
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (!base) {
|
|
238
|
+
base = "main";
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const fetch = await run("git", ["fetch", "origin", base], { cwd, env });
|
|
243
|
+
if (fetch.code !== 0) {
|
|
244
|
+
throw new Error(`git fetch origin ${base} failed: ${fetch.stderr.trim() || `exit ${fetch.code}`}`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// diff3 conflict style so conflict hunks carry the merge BASE — the additive
|
|
248
|
+
// CHANGELOG resolver needs it to tell a true insertion from a modify/delete.
|
|
249
|
+
const merge = await run("git", [...BOT_IDENTITY, "-c", "merge.conflictStyle=diff3", "merge", "--no-edit", `origin/${base}`], { cwd, env });
|
|
250
|
+
if (merge.code === 0) {
|
|
251
|
+
const result = { ok: true, action: "clean_merge", base, resolvedFiles: [], pushed: false };
|
|
252
|
+
await afterResolve(result, options, { cwd, env });
|
|
253
|
+
return result;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Merge failed — inspect conflicts.
|
|
257
|
+
const conflictFiles = await listUnmergedFiles({ cwd, env });
|
|
258
|
+
if (conflictFiles.length === 0) {
|
|
259
|
+
await abortMerge({ cwd, env });
|
|
260
|
+
throw new Error(`git merge origin/${base} failed without resolvable conflicts: ${merge.stderr.trim() || `exit ${merge.code}`}`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Fail closed on ANY conflicted path other than the single safe CHANGELOG case.
|
|
264
|
+
const nonSafe = conflictFiles.filter((file) => file !== SAFE_RESOLVABLE_PATH);
|
|
265
|
+
if (nonSafe.length > 0) {
|
|
266
|
+
await abortMerge({ cwd, env });
|
|
267
|
+
const err = new Error(
|
|
268
|
+
`Unresolvable merge conflict: only additive ${SAFE_RESOLVABLE_PATH} conflicts are auto-resolved. `
|
|
269
|
+
+ `Conflicting paths: ${conflictFiles.join(", ")}. Resolve manually.`,
|
|
270
|
+
);
|
|
271
|
+
err.conflictFiles = conflictFiles;
|
|
272
|
+
throw err;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Sole conflict is CHANGELOG.md — attempt the safe additive resolution.
|
|
276
|
+
const changelogPath = path.join(cwd, SAFE_RESOLVABLE_PATH);
|
|
277
|
+
const content = await readFile(changelogPath, "utf8");
|
|
278
|
+
const resolution = resolveAdditiveChangelog(content);
|
|
279
|
+
if (!resolution.safe) {
|
|
280
|
+
await abortMerge({ cwd, env });
|
|
281
|
+
const err = new Error(
|
|
282
|
+
`Unresolvable ${SAFE_RESOLVABLE_PATH} conflict (${resolution.reason}); not purely additive, so it fails closed. `
|
|
283
|
+
+ `Conflicting paths: ${SAFE_RESOLVABLE_PATH}. Resolve manually.`,
|
|
284
|
+
);
|
|
285
|
+
err.conflictFiles = [SAFE_RESOLVABLE_PATH];
|
|
286
|
+
throw err;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
await writeFile(changelogPath, resolution.content, "utf8");
|
|
290
|
+
const add = await run("git", ["add", SAFE_RESOLVABLE_PATH], { cwd, env });
|
|
291
|
+
if (add.code !== 0) {
|
|
292
|
+
await abortMerge({ cwd, env });
|
|
293
|
+
throw new Error(`git add ${SAFE_RESOLVABLE_PATH} failed: ${add.stderr.trim() || `exit ${add.code}`}`);
|
|
294
|
+
}
|
|
295
|
+
const commit = await run("git", [...BOT_IDENTITY, "commit", "--no-edit"], { cwd, env });
|
|
296
|
+
if (commit.code !== 0) {
|
|
297
|
+
await abortMerge({ cwd, env });
|
|
298
|
+
throw new Error(`git commit (merge) failed: ${commit.stderr.trim() || `exit ${commit.code}`}`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const result = { ok: true, action: "resolved", base, resolvedFiles: [SAFE_RESOLVABLE_PATH], pushed: false };
|
|
302
|
+
await afterResolve(result, options, { cwd, env });
|
|
303
|
+
return result;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function afterResolve(result, options, { cwd, env }) {
|
|
307
|
+
if (options.verify) {
|
|
308
|
+
const verify = await run("npm", ["run", "test:docs"], { cwd, env });
|
|
309
|
+
if (verify.code !== 0) {
|
|
310
|
+
const err = new Error(`Post-resolve verification (npm run test:docs) failed: ${verify.stderr.trim() || verify.stdout.trim() || `exit ${verify.code}`}`);
|
|
311
|
+
err.verifyFailed = true;
|
|
312
|
+
throw err;
|
|
313
|
+
}
|
|
314
|
+
result.verified = true;
|
|
315
|
+
}
|
|
316
|
+
if (options.push) {
|
|
317
|
+
const push = await run("git", ["push"], { cwd, env });
|
|
318
|
+
if (push.code !== 0) {
|
|
319
|
+
throw new Error(`git push failed: ${push.stderr.trim() || `exit ${push.code}`}`);
|
|
320
|
+
}
|
|
321
|
+
result.pushed = true;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export async function runCli(argv, { stdout = process.stdout, stderr = process.stderr, env = process.env } = {}) {
|
|
326
|
+
let options;
|
|
327
|
+
try {
|
|
328
|
+
options = parseResolvePrConflictsCliArgs(argv);
|
|
329
|
+
} catch (error) {
|
|
330
|
+
stderr.write(`${formatCliError(error)}\n`);
|
|
331
|
+
return 1;
|
|
332
|
+
}
|
|
333
|
+
if (options.help) {
|
|
334
|
+
stdout.write(`${USAGE}\n`);
|
|
335
|
+
return 0;
|
|
336
|
+
}
|
|
337
|
+
try {
|
|
338
|
+
const result = await resolvePrConflicts(options, { env });
|
|
339
|
+
if (options.json) {
|
|
340
|
+
stdout.write(`${JSON.stringify(result)}\n`);
|
|
341
|
+
} else {
|
|
342
|
+
stdout.write(`${result.action} (base ${result.base}${result.resolvedFiles.length ? `, resolved ${result.resolvedFiles.join(", ")}` : ""}${result.pushed ? ", pushed" : ""})\n`);
|
|
343
|
+
}
|
|
344
|
+
return 0;
|
|
345
|
+
} catch (error) {
|
|
346
|
+
const payload = { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
347
|
+
if (Array.isArray(error?.conflictFiles)) {
|
|
348
|
+
payload.conflictFiles = error.conflictFiles;
|
|
349
|
+
}
|
|
350
|
+
stderr.write(`${JSON.stringify(payload)}\n`);
|
|
351
|
+
return 1;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
356
|
+
runCli(process.argv.slice(2)).then((code) => { process.exitCode = code; });
|
|
357
|
+
}
|
|
@@ -32,11 +32,16 @@ export const CHECKPOINT_ACTION_TO_CONDUCTOR_ACTION = Object.freeze({
|
|
|
32
32
|
[PR_CHECKPOINT_ACTION.DECLARE_MERGE_READY]: "merge",
|
|
33
33
|
[PR_CHECKPOINT_ACTION.AWAIT_FINAL_HUMAN_APPROVAL]: "await_approval",
|
|
34
34
|
[PR_CHECKPOINT_ACTION.RESOLVE_MERGE_CONFLICTS]: "resolve_conflicts",
|
|
35
|
+
[PR_CHECKPOINT_ACTION.RUN_UI_E2E_SUITE]: "run_ui_e2e",
|
|
35
36
|
[PR_CHECKPOINT_ACTION.REPORT_BLOCKED]: "blocked",
|
|
36
37
|
[PR_CHECKPOINT_ACTION.REPORT_DONE]: "done",
|
|
37
38
|
});
|
|
38
39
|
export const ACTION_PRIORITY = Object.freeze({
|
|
39
40
|
merge: 100,
|
|
41
|
+
// UI e2e auto-scoping fix work (#976): a path-triggered, fail-closed
|
|
42
|
+
// precondition — surface it ahead of feedback/draft work so the rendered
|
|
43
|
+
// artifact gets registered + covered before the gate proceeds.
|
|
44
|
+
run_ui_e2e: 95,
|
|
40
45
|
fix_threads: 90,
|
|
41
46
|
run_pre_approval: 80,
|
|
42
47
|
draft_gate: 70,
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
EXTERNAL_HEALTHY_WAIT_TIMEOUT_POLICY,
|
|
16
16
|
enforceExternalHealthyWaitTimeout,
|
|
17
17
|
} from "@dev-loops/core/loop/timeout-policy";
|
|
18
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
|
|
18
19
|
const REMOVED_FLAGS = new Set([
|
|
19
20
|
"--force-rerequest-review",
|
|
20
21
|
"--probe-only",
|
|
@@ -46,9 +47,16 @@ Error output (stderr, JSON):
|
|
|
46
47
|
{ "ok": false, "error": "...", "usage": "..." }
|
|
47
48
|
runtime failures:
|
|
48
49
|
{ "ok": false, "error": "..." }
|
|
50
|
+
Concise mode:
|
|
51
|
+
--concise, --summary Human-readable summary: loop state, copilot rounds,
|
|
52
|
+
unresolved/actionable thread counts, round-cap-clean
|
|
53
|
+
eligibility, CI status, next action, AND the current
|
|
54
|
+
round's new Copilot comment bodies.
|
|
55
|
+
${JQ_OUTPUT_USAGE}
|
|
49
56
|
Exit codes:
|
|
50
57
|
0 Success
|
|
51
|
-
1 Argument error or runtime failure
|
|
58
|
+
1 Argument error or runtime failure
|
|
59
|
+
2 Invalid --jq filter`.trim();
|
|
52
60
|
const parseError = buildParseError(USAGE);
|
|
53
61
|
function rejectRemovedFlag(token) {
|
|
54
62
|
throw parseError(
|
|
@@ -202,6 +210,9 @@ export function parseWatchCycleCliArgs(argv) {
|
|
|
202
210
|
help: false,
|
|
203
211
|
repo: undefined,
|
|
204
212
|
pr: undefined,
|
|
213
|
+
concise: false,
|
|
214
|
+
jq: undefined,
|
|
215
|
+
silent: false,
|
|
205
216
|
};
|
|
206
217
|
const { tokens } = parseArgs({
|
|
207
218
|
args: [...argv],
|
|
@@ -209,6 +220,9 @@ export function parseWatchCycleCliArgs(argv) {
|
|
|
209
220
|
help: { type: "boolean", short: "h" },
|
|
210
221
|
repo: { type: "string" },
|
|
211
222
|
pr: { type: "string" },
|
|
223
|
+
concise: { type: "boolean" },
|
|
224
|
+
summary: { type: "boolean" },
|
|
225
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
212
226
|
},
|
|
213
227
|
allowPositionals: true,
|
|
214
228
|
strict: false,
|
|
@@ -236,6 +250,18 @@ export function parseWatchCycleCliArgs(argv) {
|
|
|
236
250
|
options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
|
|
237
251
|
continue;
|
|
238
252
|
}
|
|
253
|
+
if (token.name === "concise" || token.name === "summary") {
|
|
254
|
+
options.concise = true;
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (token.name === "jq") {
|
|
258
|
+
options.jq = requireTokenValue(token, parseError);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (token.name === "silent") {
|
|
262
|
+
options.silent = true;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
239
265
|
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
240
266
|
}
|
|
241
267
|
if (options.repo === undefined || options.pr === undefined) {
|
|
@@ -395,6 +421,46 @@ export async function runWatchCycle(
|
|
|
395
421
|
});
|
|
396
422
|
return result;
|
|
397
423
|
}
|
|
424
|
+
// Human-readable concise summary covering loop state, Copilot round count,
|
|
425
|
+
// unresolved/actionable thread counts, round-cap-clean eligibility, CI status,
|
|
426
|
+
// next action, AND the current round's new Copilot comment bodies (from the
|
|
427
|
+
// embedded watch probe result). This is the field set the loop needs to read
|
|
428
|
+
// without parsing the full JSON blob (issue #981).
|
|
429
|
+
export function formatWatchCycleConcise(result) {
|
|
430
|
+
const snapshot = result.snapshot ?? {};
|
|
431
|
+
const lines = [
|
|
432
|
+
`Watch cycle: PR #${snapshot.prNumber ?? "?"}`,
|
|
433
|
+
` loop state: ${result.state}`,
|
|
434
|
+
` handoff action: ${result.handoffAction}`,
|
|
435
|
+
` copilot rounds: ${snapshot.copilotReviewRoundCount ?? 0}`,
|
|
436
|
+
` unresolved threads: ${snapshot.unresolvedThreadCount ?? 0}`,
|
|
437
|
+
` actionable threads: ${snapshot.actionableThreadCount ?? 0}`,
|
|
438
|
+
` round-cap clean: ${result.roundCapCleanEligible ? "yes" : "no"}`,
|
|
439
|
+
` CI status: ${snapshot.ciStatus ?? "none"}`,
|
|
440
|
+
` loop disposition: ${result.loopDisposition}`,
|
|
441
|
+
` cycle disposition: ${result.cycleDisposition}`,
|
|
442
|
+
` watch status: ${result.watchStatus ?? "(not watched)"}`,
|
|
443
|
+
` terminal: ${result.terminal ? "yes" : "no"}`,
|
|
444
|
+
` next action: ${result.nextAction}`,
|
|
445
|
+
];
|
|
446
|
+
const watch = result.watch ?? {};
|
|
447
|
+
const bodies = [
|
|
448
|
+
...(watch.newReviews ?? []).map((r) => ({ kind: "review", body: r.body })),
|
|
449
|
+
...(watch.newComments ?? []).map((c) => ({ kind: "threadComment", body: c.body })),
|
|
450
|
+
...(watch.newIssueComments ?? []).map((c) => ({ kind: "issueComment", body: c.body })),
|
|
451
|
+
].filter((entry) => typeof entry.body === "string" && entry.body.trim().length > 0);
|
|
452
|
+
if (bodies.length > 0) {
|
|
453
|
+
lines.push(" new Copilot comment bodies this round:");
|
|
454
|
+
for (const entry of bodies) {
|
|
455
|
+
const indented = entry.body.trim().split("\n").map((l) => ` ${l}`).join("\n");
|
|
456
|
+
lines.push(` [${entry.kind}]`);
|
|
457
|
+
lines.push(indented);
|
|
458
|
+
}
|
|
459
|
+
} else {
|
|
460
|
+
lines.push(" new Copilot comment bodies this round: (none)");
|
|
461
|
+
}
|
|
462
|
+
return lines.join("\n");
|
|
463
|
+
}
|
|
398
464
|
export async function runCli(
|
|
399
465
|
argv = process.argv.slice(2),
|
|
400
466
|
{
|
|
@@ -417,10 +483,18 @@ export async function runCli(
|
|
|
417
483
|
watchCopilotReviewImpl,
|
|
418
484
|
detectSessionActivity: true,
|
|
419
485
|
});
|
|
420
|
-
|
|
486
|
+
if (options.concise && options.jq === undefined && !options.silent) {
|
|
487
|
+
stdout.write(`${formatWatchCycleConcise(result)}\n`);
|
|
488
|
+
return result.ok === false ? 1 : 0;
|
|
489
|
+
}
|
|
490
|
+
return emitResult(result, { jq: options.jq, silent: options.silent, stdout });
|
|
421
491
|
}
|
|
422
492
|
if (isDirectCliRun(import.meta.url)) {
|
|
423
|
-
runCli().
|
|
493
|
+
runCli().then((code) => {
|
|
494
|
+
if (typeof code === "number") {
|
|
495
|
+
process.exitCode = code;
|
|
496
|
+
}
|
|
497
|
+
}).catch((error) => {
|
|
424
498
|
process.stderr.write(`${formatCliError(error)}\n`);
|
|
425
499
|
process.exitCode = 1;
|
|
426
500
|
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Bounded slides content & storytelling reviewer mode behind `dev-loop`.
|
|
2
|
+
// Sibling of scripts/loop/ui-designer-review-contract.mjs: this one judges a
|
|
3
|
+
// deck's narrative, not its pixels. Pure module, no I/O.
|
|
4
|
+
|
|
5
|
+
export const STORY_REVIEW_OUTCOMES = Object.freeze([
|
|
6
|
+
'story_review_satisfied',
|
|
7
|
+
'needs_iteration',
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
export const STORY_REVIEW_SEVERITIES = Object.freeze(['high', 'medium', 'low']);
|
|
11
|
+
|
|
12
|
+
export function validateSlidesStoryReviewInput(input = {}) {
|
|
13
|
+
// Coerce a null / non-object argument to {} so this validator always returns
|
|
14
|
+
// its structured status rather than throwing (the module's contract is a
|
|
15
|
+
// structured result, never an exception).
|
|
16
|
+
if (!input || typeof input !== 'object') input = {};
|
|
17
|
+
if (input.workType !== 'slides' || input.storyReviewRequested !== true) {
|
|
18
|
+
return {
|
|
19
|
+
ok: true,
|
|
20
|
+
status: 'skip_non_slides',
|
|
21
|
+
reason: 'non_slides_or_not_requested',
|
|
22
|
+
missing: [],
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
const missing = [];
|
|
26
|
+
const acceptanceCriteria = Array.isArray(input.acceptanceCriteria)
|
|
27
|
+
? input.acceptanceCriteria.filter((entry) => typeof entry === 'string' && entry.trim().length > 0)
|
|
28
|
+
: [];
|
|
29
|
+
if (acceptanceCriteria.length === 0) {
|
|
30
|
+
missing.push('acceptanceCriteria');
|
|
31
|
+
}
|
|
32
|
+
if (typeof input.storytellingBrief !== 'string' || input.storytellingBrief.trim().length === 0) {
|
|
33
|
+
missing.push('storytellingBrief');
|
|
34
|
+
}
|
|
35
|
+
const deckBundle = input.deckBundle ?? {};
|
|
36
|
+
if (typeof deckBundle.deckSourcePath !== 'string' || deckBundle.deckSourcePath.trim().length === 0) {
|
|
37
|
+
missing.push('deckBundle.deckSourcePath');
|
|
38
|
+
}
|
|
39
|
+
if (missing.length > 0) {
|
|
40
|
+
return {
|
|
41
|
+
ok: false,
|
|
42
|
+
status: 'blocked_missing_required_inputs',
|
|
43
|
+
reason: 'required_inputs_missing',
|
|
44
|
+
missing,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
// Captured slide screenshots are optional, but if present each entry must be complete.
|
|
48
|
+
const incompleteArtifacts = [];
|
|
49
|
+
const slideScreenshots = Array.isArray(deckBundle.slideScreenshots) ? deckBundle.slideScreenshots : [];
|
|
50
|
+
slideScreenshots.forEach((rawShot, index) => {
|
|
51
|
+
const shot = rawShot && typeof rawShot === 'object' ? rawShot : {};
|
|
52
|
+
if (typeof shot.slideId !== 'string' || shot.slideId.trim().length === 0) {
|
|
53
|
+
incompleteArtifacts.push(`deckBundle.slideScreenshots[${index}].slideId`);
|
|
54
|
+
}
|
|
55
|
+
if (typeof shot.screenshotPath !== 'string' || shot.screenshotPath.trim().length === 0) {
|
|
56
|
+
incompleteArtifacts.push(`deckBundle.slideScreenshots[${index}].screenshotPath`);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
if (incompleteArtifacts.length > 0) {
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
status: 'blocked_incomplete_deck_bundle',
|
|
63
|
+
reason: 'deck_bundle_incomplete',
|
|
64
|
+
missing: incompleteArtifacts,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
ok: true,
|
|
69
|
+
status: 'ready_for_story_review',
|
|
70
|
+
reason: 'deck_bundle_complete',
|
|
71
|
+
missing: [],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function validateSlidesStoryReviewResult(result = {}) {
|
|
76
|
+
// Coerce a null / non-object argument to {} so this validator returns its
|
|
77
|
+
// structured `invalid` result rather than throwing.
|
|
78
|
+
if (!result || typeof result !== 'object') result = {};
|
|
79
|
+
const invalid = [];
|
|
80
|
+
if (!STORY_REVIEW_OUTCOMES.includes(result.outcome)) {
|
|
81
|
+
invalid.push('outcome');
|
|
82
|
+
}
|
|
83
|
+
if (typeof result.summary !== 'string' || result.summary.trim().length === 0) {
|
|
84
|
+
invalid.push('summary');
|
|
85
|
+
}
|
|
86
|
+
const findings = Array.isArray(result.findings) ? result.findings : null;
|
|
87
|
+
if (findings === null) {
|
|
88
|
+
invalid.push('findings');
|
|
89
|
+
} else {
|
|
90
|
+
findings.forEach((rawFinding, index) => {
|
|
91
|
+
const finding = rawFinding && typeof rawFinding === 'object' ? rawFinding : {};
|
|
92
|
+
if (!STORY_REVIEW_SEVERITIES.includes(finding.severity)) {
|
|
93
|
+
invalid.push(`findings[${index}].severity`);
|
|
94
|
+
}
|
|
95
|
+
if (typeof finding.slideId !== 'string' || finding.slideId.trim().length === 0) {
|
|
96
|
+
invalid.push(`findings[${index}].slideId`);
|
|
97
|
+
}
|
|
98
|
+
if (typeof finding.problem !== 'string' || finding.problem.trim().length === 0) {
|
|
99
|
+
invalid.push(`findings[${index}].problem`);
|
|
100
|
+
}
|
|
101
|
+
if (typeof finding.correctiveAction !== 'string' || finding.correctiveAction.trim().length === 0) {
|
|
102
|
+
invalid.push(`findings[${index}].correctiveAction`);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
if (result.outcome === 'needs_iteration' && findings !== null && findings.length === 0) {
|
|
107
|
+
invalid.push('findings');
|
|
108
|
+
}
|
|
109
|
+
if (invalid.length > 0) {
|
|
110
|
+
return {
|
|
111
|
+
ok: false,
|
|
112
|
+
status: 'invalid_result_shape',
|
|
113
|
+
reason: 'result_shape_invalid',
|
|
114
|
+
invalid,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
ok: true,
|
|
119
|
+
status: 'valid_result_shape',
|
|
120
|
+
reason: 'result_shape_valid',
|
|
121
|
+
invalid: [],
|
|
122
|
+
};
|
|
123
|
+
}
|