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.
Files changed (76) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +1 -1
  3. package/.claude/agents/refiner.md +1 -0
  4. package/.claude/commands/auto.md +7 -0
  5. package/.claude/commands/continue.md +15 -0
  6. package/.claude/commands/info.md +7 -0
  7. package/.claude/commands/start-spike.md +16 -0
  8. package/.claude/commands/start.md +7 -0
  9. package/.claude/commands/status.md +6 -0
  10. package/.claude/hooks/_run-context.mjs +11 -4
  11. package/.claude/skills/dev-loop/SKILL.md +21 -6
  12. package/.claude/skills/dev-loop/templates/slides-story-review.md +54 -0
  13. package/.claude/skills/docs/artifact-authority-contract.md +86 -31
  14. package/.claude/skills/docs/local-planning-flow.md +63 -0
  15. package/.claude/skills/docs/local-planning-worked-example.md +139 -0
  16. package/.claude/skills/docs/merge-preconditions.md +35 -0
  17. package/.claude/skills/docs/plan-file-contract.md +37 -0
  18. package/.claude/skills/docs/release-runbook.md +45 -0
  19. package/.claude/skills/docs/retrospective-checkpoint-contract.md +55 -7
  20. package/.claude/skills/docs/spike-mode-contract.md +237 -0
  21. package/.claude/skills/docs/ui-e2e-scoping-step.md +102 -0
  22. package/.claude/skills/local-implementation/SKILL.md +1 -1
  23. package/CHANGELOG.md +73 -0
  24. package/README.md +21 -1
  25. package/agents/dev-loop.agent.md +8 -1
  26. package/agents/refiner.agent.md +1 -0
  27. package/cli/index.mjs +2 -0
  28. package/extension/index.ts +10 -1
  29. package/extension/presentation.ts +15 -0
  30. package/lib/dev-loops-core.mjs +141 -0
  31. package/package.json +8 -3
  32. package/scripts/claude/generate-claude-assets.mjs +15 -1
  33. package/scripts/github/capture-review-threads.mjs +20 -2
  34. package/scripts/github/comment-issue.mjs +181 -0
  35. package/scripts/github/fetch-ci-logs.mjs +215 -0
  36. package/scripts/github/list-issues.mjs +191 -0
  37. package/scripts/github/probe-copilot-review.mjs +69 -3
  38. package/scripts/github/upsert-checkpoint-verdict.mjs +18 -3
  39. package/scripts/lib/jq-output.mjs +297 -0
  40. package/scripts/loop/_handoff-contract.mjs +1 -0
  41. package/scripts/loop/check-retro-tooling.mjs +246 -0
  42. package/scripts/loop/copilot-pr-handoff.mjs +21 -3
  43. package/scripts/loop/detect-pr-gate-coordination-state.mjs +65 -2
  44. package/scripts/loop/docs-grill-contract.mjs +70 -0
  45. package/scripts/loop/info.mjs +21 -2
  46. package/scripts/loop/pr-runner-coordination.mjs +20 -4
  47. package/scripts/loop/resolve-dev-loop-startup.mjs +176 -5
  48. package/scripts/loop/resolve-pr-conflicts.mjs +357 -0
  49. package/scripts/loop/run-conductor-cycle.mjs +5 -0
  50. package/scripts/loop/run-watch-cycle.mjs +77 -3
  51. package/scripts/loop/slides-story-review-contract.mjs +123 -0
  52. package/scripts/pages/build-site.mjs +136 -0
  53. package/scripts/projects/add-queue-item.mjs +12 -2
  54. package/scripts/projects/list-queue-items.mjs +12 -2
  55. package/scripts/projects/move-queue-item.mjs +12 -2
  56. package/scripts/projects/resolve-active-board-item.mjs +193 -0
  57. package/scripts/refine/_refine-helpers.mjs +20 -0
  58. package/scripts/refine/exit-spike.mjs +186 -0
  59. package/scripts/refine/promote-plan.mjs +387 -0
  60. package/scripts/refine/refine-plan-file.mjs +165 -0
  61. package/scripts/refine/scaffold-spike-file.mjs +183 -0
  62. package/scripts/refine/validate-plan-file.mjs +64 -0
  63. package/scripts/refine/validate-spike-file.mjs +87 -0
  64. package/scripts/release/extract-changelog-section.mjs +111 -0
  65. package/skills/dev-loop/SKILL.md +24 -2
  66. package/skills/dev-loop/templates/slides-story-review.md +54 -0
  67. package/skills/docs/artifact-authority-contract.md +86 -31
  68. package/skills/docs/local-planning-flow.md +63 -0
  69. package/skills/docs/local-planning-worked-example.md +139 -0
  70. package/skills/docs/merge-preconditions.md +35 -0
  71. package/skills/docs/plan-file-contract.md +37 -0
  72. package/skills/docs/release-runbook.md +45 -0
  73. package/skills/docs/retrospective-checkpoint-contract.md +55 -7
  74. package/skills/docs/spike-mode-contract.md +237 -0
  75. package/skills/docs/ui-e2e-scoping-step.md +102 -0
  76. 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`.trim();
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
- stdout.write(`${JSON.stringify(result)}\n`);
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().catch((error) => {
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
+ }