dev-loops 0.3.0 → 0.5.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 (85) 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/hooks/_run-context.mjs +9 -16
  5. package/.claude/skills/copilot-pr-followup/SKILL.md +9 -7
  6. package/.claude/skills/dev-loop/SKILL.md +17 -7
  7. package/.claude/skills/dev-loop/templates/slides-story-review.md +54 -0
  8. package/.claude/skills/docs/anti-patterns.md +1 -1
  9. package/.claude/skills/docs/artifact-authority-contract.md +86 -31
  10. package/.claude/skills/docs/copilot-loop-operations.md +1 -1
  11. package/.claude/skills/docs/issue-intake-procedure.md +4 -0
  12. package/.claude/skills/docs/local-planning-flow.md +63 -0
  13. package/.claude/skills/docs/local-planning-worked-example.md +139 -0
  14. package/.claude/skills/docs/merge-preconditions.md +100 -1
  15. package/.claude/skills/docs/plan-file-contract.md +37 -0
  16. package/.claude/skills/docs/retrospective-checkpoint-contract.md +55 -7
  17. package/.claude/skills/docs/spike-mode-contract.md +237 -0
  18. package/.claude/skills/docs/stop-conditions.md +1 -0
  19. package/.claude/skills/docs/tracker-first-loop-state.md +6 -6
  20. package/.claude/skills/local-implementation/SKILL.md +8 -2
  21. package/CHANGELOG.md +69 -0
  22. package/README.md +9 -2
  23. package/agents/refiner.agent.md +1 -0
  24. package/cli/index.mjs +21 -2
  25. package/extension/README.md +1 -1
  26. package/package.json +6 -4
  27. package/scripts/README.md +2 -2
  28. package/scripts/github/capture-review-threads.mjs +20 -2
  29. package/scripts/github/offer-human-handoff.mjs +147 -0
  30. package/scripts/github/probe-ci-status.mjs +468 -0
  31. package/scripts/github/probe-copilot-review.mjs +69 -3
  32. package/scripts/github/request-copilot-review.mjs +3 -3
  33. package/scripts/github/resolve-handoff-candidates.mjs +412 -0
  34. package/scripts/github/upsert-checkpoint-verdict.mjs +20 -5
  35. package/scripts/lib/jq-output.mjs +297 -0
  36. package/scripts/loop/_stale-runner-detection.mjs +1 -1
  37. package/scripts/loop/_worktree-path.mjs +27 -0
  38. package/scripts/loop/check-retro-tooling.mjs +246 -0
  39. package/scripts/loop/cleanup-worktree.mjs +175 -0
  40. package/scripts/loop/copilot-pr-handoff.mjs +22 -4
  41. package/scripts/loop/detect-pr-gate-coordination-state.mjs +35 -2
  42. package/scripts/loop/detect-stale-runner.mjs +3 -4
  43. package/scripts/loop/docs-grill-contract.mjs +70 -0
  44. package/scripts/loop/ensure-worktree.mjs +219 -0
  45. package/scripts/loop/info.mjs +21 -2
  46. package/scripts/loop/outer-loop.mjs +1 -1
  47. package/scripts/loop/pr-runner-coordination.mjs +21 -5
  48. package/scripts/loop/pre-flight-gate.mjs +10 -7
  49. package/scripts/loop/pre-push-main-guard.mjs +4 -4
  50. package/scripts/loop/provision-worktree.mjs +243 -0
  51. package/scripts/loop/resolve-dev-loop-startup.mjs +181 -10
  52. package/scripts/loop/resolve-pr-conflicts.mjs +357 -0
  53. package/scripts/loop/run-queue.mjs +25 -1
  54. package/scripts/loop/run-watch-cycle.mjs +152 -25
  55. package/scripts/loop/slides-story-review-contract.mjs +123 -0
  56. package/scripts/pages/build-site.mjs +136 -0
  57. package/scripts/projects/add-queue-item.mjs +32 -7
  58. package/scripts/projects/archive-done-items.mjs +2 -1
  59. package/scripts/projects/ensure-queue-board.mjs +2 -1
  60. package/scripts/projects/list-queue-items.mjs +14 -3
  61. package/scripts/projects/move-queue-item.mjs +14 -3
  62. package/scripts/projects/reorder-queue-item.mjs +5 -4
  63. package/scripts/projects/sync-item-status.mjs +2 -1
  64. package/scripts/refine/_refine-helpers.mjs +20 -0
  65. package/scripts/refine/exit-spike.mjs +186 -0
  66. package/scripts/refine/promote-plan.mjs +387 -0
  67. package/scripts/refine/refine-plan-file.mjs +165 -0
  68. package/scripts/refine/validate-plan-file.mjs +64 -0
  69. package/scripts/refine/validate-spike-file.mjs +87 -0
  70. package/skills/copilot-pr-followup/SKILL.md +9 -7
  71. package/skills/dev-loop/SKILL.md +13 -3
  72. package/skills/dev-loop/templates/slides-story-review.md +54 -0
  73. package/skills/docs/anti-patterns.md +1 -1
  74. package/skills/docs/artifact-authority-contract.md +86 -31
  75. package/skills/docs/copilot-loop-operations.md +1 -1
  76. package/skills/docs/issue-intake-procedure.md +4 -0
  77. package/skills/docs/local-planning-flow.md +63 -0
  78. package/skills/docs/local-planning-worked-example.md +139 -0
  79. package/skills/docs/merge-preconditions.md +100 -1
  80. package/skills/docs/plan-file-contract.md +37 -0
  81. package/skills/docs/retrospective-checkpoint-contract.md +55 -7
  82. package/skills/docs/spike-mode-contract.md +237 -0
  83. package/skills/docs/stop-conditions.md +1 -0
  84. package/skills/docs/tracker-first-loop-state.md +6 -6
  85. package/skills/local-implementation/SKILL.md +8 -2
@@ -5,13 +5,17 @@ import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helper
5
5
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
6
6
  import { DEV_LOOP_CONTRACT_TRACE_CLASSIFICATION } from "@dev-loops/core/loop/public-dev-loop-routing";
7
7
  import { watchCopilotReview } from "../github/probe-copilot-review.mjs";
8
+ import { watchCiStatus } from "../github/probe-ci-status.mjs";
8
9
  import { runHandoff } from "./copilot-pr-handoff.mjs";
10
+ import { STATE } from "@dev-loops/core/loop/copilot-loop-state";
11
+ import { DEFAULT_POLL_INTERVAL_MS } from "@dev-loops/core/loop/policy-constants";
9
12
  import { detectCopilotSessionActivity } from "./detect-copilot-session-activity.mjs";
10
13
  import { parseArgs } from "node:util";
11
14
  import {
12
15
  EXTERNAL_HEALTHY_WAIT_TIMEOUT_POLICY,
13
16
  enforceExternalHealthyWaitTimeout,
14
17
  } from "@dev-loops/core/loop/timeout-policy";
18
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
15
19
  const REMOVED_FLAGS = new Set([
16
20
  "--force-rerequest-review",
17
21
  "--probe-only",
@@ -43,9 +47,16 @@ Error output (stderr, JSON):
43
47
  { "ok": false, "error": "...", "usage": "..." }
44
48
  runtime failures:
45
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}
46
56
  Exit codes:
47
57
  0 Success
48
- 1 Argument error or runtime failure`.trim();
58
+ 1 Argument error or runtime failure
59
+ 2 Invalid --jq filter`.trim();
49
60
  const parseError = buildParseError(USAGE);
50
61
  function rejectRemovedFlag(token) {
51
62
  throw parseError(
@@ -124,16 +135,33 @@ function buildWatchCycleContractTrace({
124
135
  cycleDisposition,
125
136
  sessionActivity = null,
126
137
  workflowRunWatch = null,
138
+ ciWatchArgs = null,
139
+ ciWatchStatus = null,
127
140
  }) {
128
- const boundaryClassification = handoff.action !== "watch"
129
- ? (handoff.loopDisposition === "blocked"
141
+ // The CI-watch branch (waiting_for_ci) routes through probe-ci-status.mjs even
142
+ // though handoff.action !== "watch". It is an observational wait, so it mirrors
143
+ // the Copilot watch classification: a quiet timeout is a HEALTHY_WAIT, while
144
+ // success/failure/changed route a follow-up. Without this the boundary-action
145
+ // path below would misclassify a CI timeout as routed_followup.
146
+ const isCiWatch = ciWatchArgs !== null;
147
+ const isWatchBoundary = handoff.action === "watch" || isCiWatch;
148
+ const observedStatus = isCiWatch ? ciWatchStatus : watchStatus;
149
+ const boundaryClassification = isWatchBoundary
150
+ ? (observedStatus === "timeout" || observedStatus === "idle"
151
+ ? DEV_LOOP_CONTRACT_TRACE_CLASSIFICATION.HEALTHY_WAIT
152
+ : DEV_LOOP_CONTRACT_TRACE_CLASSIFICATION.ROUTED_FOLLOWUP)
153
+ : handoff.loopDisposition === "blocked"
130
154
  ? DEV_LOOP_CONTRACT_TRACE_CLASSIFICATION.BLOCKED
131
155
  : handoff.terminal
132
156
  ? DEV_LOOP_CONTRACT_TRACE_CLASSIFICATION.TERMINAL
133
- : DEV_LOOP_CONTRACT_TRACE_CLASSIFICATION.ROUTED_FOLLOWUP)
134
- : watchStatus === "changed"
135
- ? DEV_LOOP_CONTRACT_TRACE_CLASSIFICATION.ROUTED_FOLLOWUP
136
- : DEV_LOOP_CONTRACT_TRACE_CLASSIFICATION.HEALTHY_WAIT;
157
+ : DEV_LOOP_CONTRACT_TRACE_CLASSIFICATION.ROUTED_FOLLOWUP;
158
+ const healthyWait = boundaryClassification === DEV_LOOP_CONTRACT_TRACE_CLASSIFICATION.HEALTHY_WAIT;
159
+ const helper = handoff.action === "watch"
160
+ ? "scripts/github/probe-copilot-review.mjs"
161
+ : isCiWatch
162
+ ? "scripts/github/probe-ci-status.mjs"
163
+ : null;
164
+ const effectiveArgs = isCiWatch ? ciWatchArgs : watchArgs;
137
165
  return {
138
166
  handoff: {
139
167
  action: handoff.action,
@@ -142,38 +170,37 @@ function buildWatchCycleContractTrace({
142
170
  terminal: Boolean(handoff.terminal),
143
171
  },
144
172
  waitStrategy: {
145
- helper: handoff.action === "watch" ? "scripts/github/probe-copilot-review.mjs" : null,
146
- mode: handoff.action === "watch"
147
- ? "persistent_watch"
148
- : "not_applicable",
149
- effectiveTimeoutMs: watchArgs?.timeoutMs ?? null,
150
- effectivePollIntervalMs: watchArgs?.pollIntervalMs ?? null,
173
+ helper,
174
+ mode: isWatchBoundary ? "persistent_watch" : "not_applicable",
175
+ effectiveTimeoutMs: effectiveArgs?.timeoutMs ?? null,
176
+ effectivePollIntervalMs: effectiveArgs?.pollIntervalMs ?? null,
151
177
  timeoutPolicyClassification: watchTimeoutPolicy?.classification ?? null,
152
178
  },
153
179
  orchestration: {
154
180
  emittedWatchArgs: handoff.watchArgs ?? null,
155
- effectiveWatchArgs: watchArgs,
181
+ effectiveWatchArgs: effectiveArgs,
182
+ ciWatchArgs,
156
183
  sessionActivity,
157
184
  workflowRunWatch,
158
185
  },
159
- stateRefresh: handoff.action === "watch"
186
+ stateRefresh: isWatchBoundary
160
187
  ? {
161
188
  boundaryKind: "post_watch_or_probe",
162
- observedStatus: watchStatus,
189
+ observedStatus,
163
190
  refreshRequired: true,
164
- refreshReason: watchStatus === "changed"
165
- ? "Watch boundaries with fresh activity require an authoritative state refresh before routing the follow-up path."
166
- : "Healthy watch boundaries are observational only; refresh authoritative state before treating timeout/idle as stop or completion.",
191
+ refreshReason: healthyWait
192
+ ? "Healthy watch boundaries are observational only; refresh authoritative state before treating timeout/idle as stop or completion."
193
+ : "Watch boundaries with fresh activity require an authoritative state refresh before routing the follow-up path.",
167
194
  }
168
195
  : null,
169
196
  stopReason: {
170
197
  classification: boundaryClassification,
171
198
  terminal: Boolean(handoff.terminal),
172
199
  cycleDisposition,
173
- reason: handoff.action === "watch"
174
- ? (watchStatus === "changed"
175
- ? "Fresh watcher activity requires follow-up instead of staying in a healthy wait boundary."
176
- : "Quiet watcher boundaries remain healthy waits and must not be treated as terminal completion by themselves.")
200
+ reason: isWatchBoundary
201
+ ? (healthyWait
202
+ ? "Quiet watcher boundaries remain healthy waits and must not be treated as terminal completion by themselves."
203
+ : "Fresh watcher activity requires follow-up instead of staying in a healthy wait boundary.")
177
204
  : handoff.nextAction,
178
205
  },
179
206
  };
@@ -183,6 +210,9 @@ export function parseWatchCycleCliArgs(argv) {
183
210
  help: false,
184
211
  repo: undefined,
185
212
  pr: undefined,
213
+ concise: false,
214
+ jq: undefined,
215
+ silent: false,
186
216
  };
187
217
  const { tokens } = parseArgs({
188
218
  args: [...argv],
@@ -190,6 +220,9 @@ export function parseWatchCycleCliArgs(argv) {
190
220
  help: { type: "boolean", short: "h" },
191
221
  repo: { type: "string" },
192
222
  pr: { type: "string" },
223
+ concise: { type: "boolean" },
224
+ summary: { type: "boolean" },
225
+ ...JQ_OUTPUT_PARSE_OPTIONS,
193
226
  },
194
227
  allowPositionals: true,
195
228
  strict: false,
@@ -217,6 +250,18 @@ export function parseWatchCycleCliArgs(argv) {
217
250
  options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
218
251
  continue;
219
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
+ }
220
265
  throw parseError(`Unknown argument: ${token.rawName}`);
221
266
  }
222
267
  if (options.repo === undefined || options.pr === undefined) {
@@ -236,6 +281,7 @@ export async function runWatchCycle(
236
281
  ghCommand = "gh",
237
282
  runHandoffImpl = runHandoff,
238
283
  watchCopilotReviewImpl = watchCopilotReview,
284
+ watchCiStatusImpl = watchCiStatus,
239
285
  detectCopilotSessionActivityImpl = detectCopilotSessionActivity,
240
286
  fetchPrHeadBranchImpl = fetchPrHeadBranch,
241
287
  watchWorkflowRunImpl = watchWorkflowRun,
@@ -267,6 +313,39 @@ export async function runWatchCycle(
267
313
  if (handoff.watchTimeoutPolicy !== undefined) {
268
314
  result.watchTimeoutPolicy = handoff.watchTimeoutPolicy;
269
315
  }
316
+ // Provider-agnostic CI wait (#917): a waiting_for_ci boundary would otherwise
317
+ // dead-end at action:"stop". Route it to the helper-owned CI watcher
318
+ // (CircleCI / GH Actions / external commit-status), not gh run watch.
319
+ if (handoff.action !== "watch" && handoff.state === STATE.WAITING_FOR_CI) {
320
+ // Mirror determineWatchTimeout but with a CI-specific context label so
321
+ // timeout diagnostics read "CI wait" instead of "Copilot review wait".
322
+ const ciTimeoutMs = enforceExternalHealthyWaitTimeout({
323
+ timeoutMs: EXTERNAL_HEALTHY_WAIT_TIMEOUT_POLICY.defaultTimeoutMs,
324
+ contextLabel: "CI wait",
325
+ });
326
+ const ciWatchArgs = {
327
+ repo: options.repo,
328
+ pr: options.pr,
329
+ pollIntervalMs: DEFAULT_POLL_INTERVAL_MS,
330
+ timeoutMs: ciTimeoutMs,
331
+ };
332
+ const ciWatch = await watchCiStatusImpl(ciWatchArgs, { env, ghCommand });
333
+ result.ciWatchArgs = ciWatchArgs;
334
+ result.ciWatch = ciWatch;
335
+ result.watchStatus = ciWatch.status;
336
+ // success/failure/changed all need authoritative re-detection (follow-up);
337
+ // a quiet timeout stays a healthy pending wait.
338
+ result.cycleDisposition = ciWatch.status === "timeout" ? "pending" : "needs_followup";
339
+ result.terminal = false;
340
+ result.contractTrace = buildWatchCycleContractTrace({
341
+ handoff,
342
+ watchTimeoutPolicy: result.watchTimeoutPolicy ?? null,
343
+ cycleDisposition: result.cycleDisposition,
344
+ ciWatchArgs,
345
+ ciWatchStatus: ciWatch.status,
346
+ });
347
+ return result;
348
+ }
270
349
  if (handoff.action !== "watch") {
271
350
  result.contractTrace = buildWatchCycleContractTrace({
272
351
  handoff,
@@ -342,6 +421,46 @@ export async function runWatchCycle(
342
421
  });
343
422
  return result;
344
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
+ }
345
464
  export async function runCli(
346
465
  argv = process.argv.slice(2),
347
466
  {
@@ -364,10 +483,18 @@ export async function runCli(
364
483
  watchCopilotReviewImpl,
365
484
  detectSessionActivity: true,
366
485
  });
367
- 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 });
368
491
  }
369
492
  if (isDirectCliRun(import.meta.url)) {
370
- runCli().catch((error) => {
493
+ runCli().then((code) => {
494
+ if (typeof code === "number") {
495
+ process.exitCode = code;
496
+ }
497
+ }).catch((error) => {
371
498
  process.stderr.write(`${formatCliError(error)}\n`);
372
499
  process.exitCode = 1;
373
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
+ }
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env node
2
+ // Assembles the GitHub Pages publishable dir deterministically. The landing
3
+ // page (index.html) is the "Introducing dev-loops" article; the deep-dive
4
+ // article and the deep-dive deck are published alongside it and reached through
5
+ // a shared navigation bar injected into the article pages. The source HTML files
6
+ // under docs/ are the source of truth; site/ is assembled, never hand-maintained.
7
+ // Usage: node scripts/pages/build-site.mjs [--out <dir>] [--repo-root <dir>]
8
+ import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
9
+ import { dirname, join, resolve, parse as parsePath } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+
12
+ const REPO_ROOT_DEFAULT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
13
+
14
+ // The landing page: the intro article, published as index.html. file is
15
+ // relative to docs/articles/.
16
+ export const LANDING = { file: 'introducing-dev-loops.html' };
17
+
18
+ // The deep-dive article published alongside the landing page. file is relative
19
+ // to docs/articles/; navLabel is how the nav refers to it.
20
+ export const ARTICLES = [
21
+ { file: 'dev-loops-deep-dive.html', navLabel: 'Deep dive' },
22
+ ];
23
+
24
+ // The decks to publish. file is relative to docs/presentations/; outFile is the
25
+ // published name (defaults to file). The deep-dive article and deck share the
26
+ // source basename dev-loops-deep-dive.html under different docs/ dirs, so the
27
+ // deck publishes under a distinct name to avoid clobbering the article in site/.
28
+ export const DECKS = [
29
+ {
30
+ file: 'introducing-dev-loops.html',
31
+ title: 'Introducing dev-loops',
32
+ subtitle: 'A coordination runtime for AI-assisted development',
33
+ description: 'The concept, the data behind it, and how to run the loop on your own project.',
34
+ navLabel: 'Intro (deck)',
35
+ },
36
+ {
37
+ file: 'dev-loops-deep-dive.html',
38
+ outFile: 'dev-loops-deep-dive-deck.html',
39
+ title: 'dev-loops: A Deep Dive',
40
+ subtitle: 'Coordination delay and the waiting between actions',
41
+ description: 'How explicit handoffs on a state graph and measuring the wait between actions cut delivery delay.',
42
+ navLabel: 'Deep dive (deck)',
43
+ },
44
+ ];
45
+
46
+ // Resolve a deck's published filename: distinct outFile when set, else file.
47
+ const deckOut = (deck) => deck.outFile ?? deck.file;
48
+
49
+ // The other resources linked from the navigation, in order.
50
+ export const NAV_LINKS = [
51
+ ...ARTICLES.map((a) => ({ file: a.file, label: a.navLabel })),
52
+ ...DECKS.map((d) => ({ file: deckOut(d), label: d.navLabel })),
53
+ ];
54
+
55
+ // Nav styling, appended to each article page's own <style> block so it reuses
56
+ // the article design-system variables (--heading/--kicker/--accent-soft).
57
+ const NAV_CSS = `
58
+ .site-nav { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.5rem 1.1rem; max-width: 64rem; margin: 0 auto; padding: 0.9rem clamp(1.1rem, 5vw, 2rem); border-bottom: 1px solid rgba(148, 163, 184, 0.16); }
59
+ .site-nav-brand { font-weight: 700; letter-spacing: -0.01em; color: var(--heading); text-decoration: none; border: 0; margin-right: auto; }
60
+ .site-nav-links { display: flex; flex-wrap: wrap; gap: 0.5rem 1.1rem; }
61
+ .site-nav a { color: var(--kicker); text-decoration: none; font-size: 0.9rem; border: 0; }
62
+ .site-nav a:hover { color: var(--accent-soft); }`;
63
+
64
+ function navMarkup() {
65
+ const links = NAV_LINKS.map((l) => ` <a href="${l.file}">${l.label}</a>`).join('\n');
66
+ return `<nav class="site-nav" aria-label="dev-loops resources">
67
+ <a class="site-nav-brand" href="index.html">dev-loops</a>
68
+ <div class="site-nav-links">
69
+ ${links}
70
+ </div>
71
+ </nav>`;
72
+ }
73
+
74
+ // Inject the shared nav into an article page: nav CSS before </style>, nav
75
+ // markup right after <body>. Idempotent enough for assembly (each source file
76
+ // is read once). Throws if the page lacks the expected anchors so a structural
77
+ // drift fails the build rather than publishing an un-navigable page.
78
+ export function injectNav(html) {
79
+ if (!html.includes('</style>') || !/<body[^>]*>/.test(html)) {
80
+ throw new Error('cannot inject nav: page is missing a <style> block or <body> tag');
81
+ }
82
+ return html
83
+ .replace('</style>', `${NAV_CSS}\n</style>`)
84
+ .replace(/<body([^>]*)>/, `<body$1>\n ${navMarkup()}`);
85
+ }
86
+
87
+ export async function buildSite({ repoRoot = REPO_ROOT_DEFAULT, outDir } = {}) {
88
+ const out = outDir ? resolve(outDir) : join(repoRoot, 'site');
89
+ const articlesDir = join(repoRoot, 'docs', 'articles');
90
+ const decksDir = join(repoRoot, 'docs', 'presentations');
91
+
92
+ // Guard: out is wiped before assembly. Refuse paths that would nuke the
93
+ // filesystem root, the repo itself, or an ancestor of the repo.
94
+ const root = resolve(repoRoot);
95
+ const isAncestorOf = (a, b) => b === a || b.startsWith(a + '/');
96
+ if (out === parsePath(out).root || out === root || isAncestorOf(out, root)) {
97
+ throw new Error(`refusing to wipe unsafe output dir ${out}`);
98
+ }
99
+
100
+ await rm(out, { recursive: true, force: true });
101
+ await mkdir(out, { recursive: true });
102
+
103
+ // Landing page: the intro article, navigable, published as index.html.
104
+ const landingHtml = await readFile(join(articlesDir, LANDING.file), 'utf8');
105
+ await writeFile(join(out, 'index.html'), injectNav(landingHtml), 'utf8');
106
+
107
+ // Deep-dive article: published with the same nav so the set is navigable.
108
+ for (const article of ARTICLES) {
109
+ const html = await readFile(join(articlesDir, article.file), 'utf8');
110
+ await writeFile(join(out, article.file), injectNav(html), 'utf8');
111
+ }
112
+
113
+ // Decks: self-contained slide renders, copied as-is (no nav injection).
114
+ for (const deck of DECKS) {
115
+ await cp(join(decksDir, deck.file), join(out, deckOut(deck)));
116
+ }
117
+
118
+ return {
119
+ out,
120
+ files: ['index.html', ...ARTICLES.map((a) => a.file), ...DECKS.map((d) => deckOut(d))],
121
+ };
122
+ }
123
+
124
+ const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
125
+ if (invokedDirectly) {
126
+ const args = process.argv.slice(2);
127
+ const getArg = (name) => {
128
+ const i = args.indexOf(name);
129
+ return i >= 0 ? args[i + 1] : undefined;
130
+ };
131
+ const result = await buildSite({
132
+ repoRoot: getArg('--repo-root') ? resolve(getArg('--repo-root')) : undefined,
133
+ outDir: getArg('--out'),
134
+ });
135
+ console.log(`Built site at ${result.out}: ${result.files.join(', ')}`);
136
+ }
@@ -2,8 +2,10 @@
2
2
  import { formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
3
3
  import { runChild as _runChild } from "../_cli-primitives.mjs";
4
4
  import { parseArgs } from "node:util";
5
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
5
6
 
6
- const USAGE = `Usage: dev-loops project add --repo <owner/name> --project <number|id> --item <number>
7
+ const USAGE = `Usage: dev-loops queue add --repo <owner/name> --project <number|id> --item <number>
8
+ dev-loops project add … (back-compat alias for "queue add")
7
9
 
8
10
  Add an existing issue or PR to a GitHub Projects V2 board.
9
11
 
@@ -11,16 +13,19 @@ Options:
11
13
  --repo <owner/name> Required. Repository containing the issue/PR.
12
14
  --project <number|id> Required. Project number (integer) or node ID.
13
15
  --item <number> Required. Issue or PR number to add.
14
- --status <name> Initial Status column (default: "Backlog").
16
+ --column <name> Initial Status column (default: "Backlog").
17
+ --status <name> Back-compat alias for --column.
15
18
  --help, -h Show this help.
16
19
 
17
20
  Output (stdout):
18
21
  JSON: { ok: true, item: { itemId, issueNumber, prNumber, status, alreadyPresent } }
19
22
 
23
+ ${JQ_OUTPUT_USAGE}
24
+
20
25
  Exit codes:
21
26
  0 — success (or no-op when already present)
22
27
  1 — usage or argument error
23
- 2 — GitHub API error
28
+ 2 — GitHub API error / invalid --jq filter
24
29
  3 — project, field, column, or issue/PR not found
25
30
  `.trim();
26
31
 
@@ -41,8 +46,10 @@ function parseCliArgs(argv) {
41
46
  repo: { type: "string" },
42
47
  project: { type: "string" },
43
48
  item: { type: "string" },
49
+ column: { type: "string" },
44
50
  status: { type: "string" },
45
51
  help: { type: "boolean", short: "h" },
52
+ ...JQ_OUTPUT_PARSE_OPTIONS,
46
53
  },
47
54
  allowPositionals: true,
48
55
  strict: false,
@@ -81,9 +88,21 @@ function parseCliArgs(argv) {
81
88
  args.item = val;
82
89
  break;
83
90
  }
91
+ case "column":
92
+ args.column = requireValue(token, "--column requires a value");
93
+ break;
84
94
  case "status":
95
+ // Back-compat alias for --column (issue #912). Kept separate so a
96
+ // conflicting `--column X --status Y` is rejected rather than silently
97
+ // resolved by argv order.
85
98
  args.status = requireValue(token, "--status requires a value");
86
99
  break;
100
+ case "jq":
101
+ args.jq = requireValue(token, "--jq requires a filter");
102
+ break;
103
+ case "silent":
104
+ args.silent = true;
105
+ break;
87
106
  default:
88
107
  throw Object.assign(new Error(`Unknown flag: ${token.rawName}`), { code: "INVALID_ARGS", usage: USAGE });
89
108
  }
@@ -375,9 +394,15 @@ async function main(args, { env = process.env, runChild } = {}) {
375
394
  if (!Number.isInteger(itemNumber) || itemNumber < 1) {
376
395
  throw Object.assign(new Error("--item is required and must be a positive integer"), { code: "INVALID_ITEM" });
377
396
  }
378
- const targetStatus = (args.status ?? "Backlog").trim();
397
+ if (args.column != null && args.status != null && args.column.trim() !== args.status.trim()) {
398
+ throw Object.assign(
399
+ new Error(`Conflicting --column ("${args.column}") and --status ("${args.status}") — pass only one (prefer --column).`),
400
+ { code: "INVALID_ARGS", usage: USAGE },
401
+ );
402
+ }
403
+ const targetStatus = (args.column ?? args.status ?? "Backlog").trim();
379
404
  if (!targetStatus) {
380
- throw Object.assign(new Error("--status must not be empty"), { code: "INVALID_STATUS" });
405
+ throw Object.assign(new Error("--column must not be empty"), { code: "INVALID_STATUS" });
381
406
  }
382
407
 
383
408
  // 1. Resolve owner
@@ -528,7 +553,7 @@ async function runCli(argv, { stdout = process.stdout, stderr = process.stderr,
528
553
  }
529
554
  try {
530
555
  const result = await main(args, { env });
531
- stdout.write(JSON.stringify(result) + "\n");
556
+ process.exitCode = emitResult(result, { jq: args.jq, silent: args.silent, stdout, stderr });
532
557
  } catch (err) {
533
558
  stderr.write(JSON.stringify({ ok: false, error: err.message, code: err.code ?? "UNKNOWN" }) + "\n");
534
559
  process.exitCode = classifyExitCode(err);
@@ -542,4 +567,4 @@ if (isDirectCliRun(import.meta.url)) {
542
567
  });
543
568
  }
544
569
 
545
- export { main };
570
+ export { main, parseCliArgs };
@@ -6,7 +6,8 @@ import { formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.
6
6
  import { runChild as _runChild } from "../_cli-primitives.mjs";
7
7
  import { parseArgs } from "node:util";
8
8
 
9
- const USAGE = `Usage: dev-loops project archive-done --repo <owner/name> [--project <number|id>] [--older-than <duration>] [--dry-run]
9
+ const USAGE = `Usage: dev-loops queue archive-done --repo <owner/name> [--project <number|id>] [--older-than <duration>] [--dry-run]
10
+ (dev-loops project archive-done … is a back-compat alias)
10
11
 
11
12
  Archive GitHub Projects V2 items whose issue/PR has been closed for at least the
12
13
  given duration. Operator-triggered (no webhooks). Uses archiveProjectV2Item.
@@ -6,7 +6,8 @@ import { parse as parseYaml } from "yaml";
6
6
  import { formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
7
7
  import { runChild as _runChild } from "../_cli-primitives.mjs";
8
8
 
9
- const USAGE = `Usage: dev-loops project ensure --repo <owner/name> [--project <number>] [--title <title>] [--link-repo <owner/name>] [--repair-rename]
9
+ const USAGE = `Usage: dev-loops queue ensure --repo <owner/name> [--project <number>] [--title <title>] [--link-repo <owner/name>] [--repair-rename]
10
+ (dev-loops project ensure … is a back-compat alias)
10
11
 
11
12
  --repair-rename Rename semantically equivalent Status columns to the standard names
12
13
  (e.g. "Ready" -> "Next Up"). Without this flag the helper only