dev-loops 0.2.7 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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/developer.md +1 -0
  4. package/.claude/agents/fixer.md +1 -0
  5. package/.claude/agents/review.md +30 -0
  6. package/.claude/hooks/_run-context.mjs +9 -16
  7. package/.claude/skills/copilot-pr-followup/SKILL.md +62 -6
  8. package/.claude/skills/dev-loop/SKILL.md +6 -6
  9. package/.claude/skills/docs/anti-patterns.md +2 -0
  10. package/.claude/skills/docs/copilot-loop-operations.md +3 -3
  11. package/.claude/skills/docs/issue-intake-procedure.md +4 -0
  12. package/.claude/skills/docs/merge-preconditions.md +65 -1
  13. package/.claude/skills/docs/stop-conditions.md +1 -0
  14. package/.claude/skills/docs/tracker-first-loop-state.md +6 -6
  15. package/.claude/skills/local-implementation/SKILL.md +25 -5
  16. package/AGENTS.md +1 -1
  17. package/CHANGELOG.md +69 -0
  18. package/README.md +8 -2
  19. package/agents/developer.agent.md +1 -0
  20. package/agents/fixer.agent.md +1 -0
  21. package/agents/review.agent.md +30 -0
  22. package/cli/index.mjs +60 -8
  23. package/extension/README.md +1 -1
  24. package/package.json +3 -3
  25. package/scripts/README.md +8 -7
  26. package/scripts/_core-helpers.mjs +1 -0
  27. package/scripts/claude/headless-dev-loop.mjs +53 -13
  28. package/scripts/claude/headless-info-smoke.mjs +45 -11
  29. package/scripts/github/build-adjacent-bundle.mjs +448 -0
  30. package/scripts/github/{create-draft-pr.mjs → create-pr.mjs} +28 -12
  31. package/scripts/github/detect-checkpoint-evidence.mjs +95 -4
  32. package/scripts/github/offer-human-handoff.mjs +147 -0
  33. package/scripts/github/post-gate-findings.mjs +392 -0
  34. package/scripts/github/probe-ci-status.mjs +468 -0
  35. package/scripts/github/reconcile-draft-gate.mjs +2 -2
  36. package/scripts/github/request-copilot-review.mjs +72 -11
  37. package/scripts/github/resolve-handoff-candidates.mjs +412 -0
  38. package/scripts/github/upsert-checkpoint-verdict.mjs +599 -17
  39. package/scripts/github/verify-fresh-review-context.mjs +12 -1
  40. package/scripts/github/write-gate-context.mjs +634 -0
  41. package/scripts/github/write-gate-findings-log.mjs +1 -1
  42. package/scripts/loop/_stale-runner-detection.mjs +1 -1
  43. package/scripts/loop/_worktree-path.mjs +27 -0
  44. package/scripts/loop/cleanup-worktree.mjs +175 -0
  45. package/scripts/loop/copilot-pr-handoff.mjs +1 -1
  46. package/scripts/loop/detect-change-scope.mjs +36 -11
  47. package/scripts/loop/detect-pr-gate-coordination-state.mjs +30 -18
  48. package/scripts/loop/detect-stale-runner.mjs +3 -4
  49. package/scripts/loop/detect-tracker-first-loop-state.mjs +38 -11
  50. package/scripts/loop/ensure-worktree.mjs +219 -0
  51. package/scripts/loop/outer-loop.mjs +1 -1
  52. package/scripts/loop/pr-runner-coordination.mjs +1 -1
  53. package/scripts/loop/pre-flight-gate.mjs +10 -7
  54. package/scripts/loop/pre-push-main-guard.mjs +4 -4
  55. package/scripts/loop/provision-worktree.mjs +243 -0
  56. package/scripts/loop/resolve-dev-loop-startup.mjs +5 -5
  57. package/scripts/loop/run-queue.mjs +112 -16
  58. package/scripts/loop/run-watch-cycle.mjs +75 -22
  59. package/scripts/projects/add-queue-item.mjs +80 -48
  60. package/scripts/projects/archive-done-items.mjs +136 -39
  61. package/scripts/projects/ensure-queue-board.mjs +67 -65
  62. package/scripts/projects/list-queue-items.mjs +59 -57
  63. package/scripts/projects/move-queue-item.mjs +125 -125
  64. package/scripts/projects/reorder-queue-item.mjs +67 -48
  65. package/scripts/projects/sync-item-status.mjs +199 -0
  66. package/skills/copilot-pr-followup/SKILL.md +62 -6
  67. package/skills/dev-loop/SKILL.md +2 -2
  68. package/skills/dev-loop/scripts/log-bash-exit-1.mjs +2 -2
  69. package/skills/dev-loop/scripts/phase-files.mjs +2 -2
  70. package/skills/docs/anti-patterns.md +2 -0
  71. package/skills/docs/copilot-loop-operations.md +3 -3
  72. package/skills/docs/issue-intake-procedure.md +4 -0
  73. package/skills/docs/merge-preconditions.md +65 -1
  74. package/skills/docs/stop-conditions.md +1 -0
  75. package/skills/docs/tracker-first-loop-state.md +6 -6
  76. package/skills/local-implementation/SKILL.md +25 -5
@@ -9,9 +9,13 @@ import {
9
9
  summarizeGateReviewCommentMarkers,
10
10
  summarizeGateReviewComments,
11
11
  } from "../_core-helpers.mjs";
12
+ import { access } from "node:fs/promises";
13
+ import path from "node:path";
12
14
  import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
13
15
  import { fetchGithubReviewThreadsPayload } from "./capture-review-threads.mjs";
14
16
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
17
+ import { loadDevLoopConfig, resolveGateConfig, resolveRequireFanoutEvidence } from "@dev-loops/core/config";
18
+ import { buildLogPath } from "./write-gate-findings-log.mjs";
15
19
  import { ensureAsyncRunnerOwnership } from "../loop/_pr-runner-coordination.mjs";
16
20
  import { detectStaleRunner } from "../loop/_stale-runner-detection.mjs";
17
21
  const USAGE = `Usage: detect-checkpoint-evidence.mjs --repo <owner/name> --pr <number>
@@ -200,6 +204,8 @@ function emptyGateMarkerSummary() {
200
204
  verdict: null,
201
205
  findingsSummary: null,
202
206
  nextAction: null,
207
+ executionMode: null,
208
+ inlineReason: null,
203
209
  contractComplete: false,
204
210
  commentId: null,
205
211
  commentUrl: null,
@@ -216,13 +222,15 @@ function normalizeGateMarkerSummary(summary) {
216
222
  verdict: summary.verdict,
217
223
  findingsSummary: summary.findingsSummary,
218
224
  nextAction: summary.nextAction,
225
+ executionMode: summary.executionMode ?? null,
226
+ inlineReason: summary.inlineReason ?? null,
219
227
  contractComplete: summary.contractComplete === true,
220
228
  commentId: summary.commentId,
221
229
  commentUrl: summary.commentUrl,
222
230
  updatedAt: summary.updatedAt,
223
231
  };
224
232
  }
225
- export function buildPreMergeGateCheck(evidence, unresolvedThreadCount = null, staleRunnerCheck = null) {
233
+ export function buildPreMergeGateCheck(evidence, unresolvedThreadCount = null, staleRunnerCheck = null, fanoutEnforcement = null) {
226
234
  const failures = [];
227
235
  if (!(evidence.draftGate.visible && evidence.draftGate.verdict === "clean")) {
228
236
  failures.push("missing visible clean draft_gate comment");
@@ -236,6 +244,24 @@ export function buildPreMergeGateCheck(evidence, unresolvedThreadCount = null, s
236
244
  )) {
237
245
  failures.push("missing visible clean current-head pre_approval_gate comment");
238
246
  }
247
+ // Fail-closed fan-out evidence enforcement (gates.requireFanoutEvidence, ON by
248
+ // default / opt-out). When disabled or config-unavailable, fanoutEnforcement is
249
+ // { required: false, gates: [] } so the `.required` guard skips this block.
250
+ if (fanoutEnforcement && fanoutEnforcement.required) {
251
+ for (const gate of fanoutEnforcement.gates) {
252
+ if (gate.executionMode !== "fanout_fanin") {
253
+ failures.push(
254
+ `${gate.name}: requireFanoutEvidence is enabled but executionMode is "${gate.executionMode ?? "unset"}" (expected "fanout_fanin"); inline gate verdicts are not accepted`,
255
+ );
256
+ continue;
257
+ }
258
+ if (!gate.ledgerExists) {
259
+ failures.push(
260
+ `${gate.name}: requireFanoutEvidence is enabled but no findings-log ledger exists for the reviewed head (${gate.ledgerPath})`,
261
+ );
262
+ }
263
+ }
264
+ }
239
265
  if (typeof unresolvedThreadCount === "number" && unresolvedThreadCount !== 0) {
240
266
  if (unresolvedThreadCount === -1) {
241
267
  failures.push("could not fetch review thread state from GitHub API; re-run gate evidence check when API connectivity is restored");
@@ -253,6 +279,52 @@ export function buildPreMergeGateCheck(evidence, unresolvedThreadCount = null, s
253
279
  failures,
254
280
  };
255
281
  }
282
+ async function ledgerExists(fullPath) {
283
+ try {
284
+ await access(fullPath);
285
+ return true;
286
+ } catch {
287
+ return false;
288
+ }
289
+ }
290
+ /**
291
+ * Build the fan-out evidence enforcement descriptor.
292
+ *
293
+ * Enforcement is ON by default (opt-out via gates.requireFanoutEvidence: false).
294
+ * Returns { required: false } when enforcement is disabled OR when config is
295
+ * unavailable (config == null — null or undefined — after a failed load) — config-unavailable must
296
+ * fail open and never enable enforcement. When enabled, records per-required-gate
297
+ * executionMode and whether the deterministic findings-log ledger exists for the
298
+ * reviewed head SHA so the pre-merge check can fail closed on inline verdicts or
299
+ * missing ledgers.
300
+ */
301
+ async function buildFanoutEnforcement({ repo, pr, currentHeadSha, draftGateMarker, preApprovalGateMarker, config, cwd }) {
302
+ // Fail open when config could not be loaded/validated. `== null` covers both
303
+ // null and undefined; the loader only ever yields null on failure, but the
304
+ // loose check defensively treats an absent config as unavailable.
305
+ if (config == null || !resolveRequireFanoutEvidence(config)) {
306
+ return { required: false, gates: [] };
307
+ }
308
+ const draftRequired = resolveGateConfig(config, "draft").required;
309
+ const preApprovalRequired = resolveGateConfig(config, "preApproval").required;
310
+ const gateSpecs = [
311
+ { name: "draft_gate", marker: draftGateMarker, required: draftRequired },
312
+ { name: "pre_approval_gate", marker: preApprovalGateMarker, required: preApprovalRequired },
313
+ ].filter((spec) => spec.required && spec.marker.visible);
314
+ const gates = [];
315
+ for (const spec of gateSpecs) {
316
+ const headSha = spec.marker.headSha ?? currentHeadSha;
317
+ const ledgerPath = buildLogPath({ repo, pr, gate: spec.name, headSha, tmpRoot: "tmp" });
318
+ const fullPath = path.resolve(cwd, ledgerPath);
319
+ gates.push({
320
+ name: spec.name,
321
+ executionMode: spec.marker.executionMode ?? null,
322
+ ledgerPath,
323
+ ledgerExists: await ledgerExists(fullPath),
324
+ });
325
+ }
326
+ return { required: true, gates };
327
+ }
256
328
  export async function detectCheckpointEvidence(options, { env = process.env, ghCommand = "gh", cwd = process.cwd() } = {}) {
257
329
  const runnerOwnership = await ensureAsyncRunnerOwnership({
258
330
  repo: options.repo,
@@ -299,6 +371,24 @@ export async function detectCheckpointEvidence(options, { env = process.env, ghC
299
371
  const allComments = [...commentsPayload, ...prReviews];
300
372
  const commentSummary = summarizeGateReviewComments(allComments);
301
373
  const markerSummary = summarizeGateReviewCommentMarkers(allComments, { headSha: currentHeadSha });
374
+ const draftGateMarker = normalizeGateMarkerSummary(markerSummary.draft_gate);
375
+ const preApprovalGateMarker = normalizeGateMarkerSummary(markerSummary.pre_approval_gate);
376
+ // loadDevLoopConfig never throws: it returns { config, warnings, errors }.
377
+ // A non-empty errors array means the config could not be loaded/validated, so
378
+ // treat it as config-unavailable and leave fan-out enforcement disabled
379
+ // (preserves default behavior). Other gate checks remain unaffected.
380
+ let config = null;
381
+ const { config: loadedConfig, errors: configErrors } = await loadDevLoopConfig({ repoRoot: cwd });
382
+ config = Array.isArray(configErrors) && configErrors.length > 0 ? null : loadedConfig;
383
+ const fanoutEnforcement = await buildFanoutEnforcement({
384
+ repo: options.repo,
385
+ pr: options.pr,
386
+ currentHeadSha,
387
+ draftGateMarker,
388
+ preApprovalGateMarker,
389
+ config,
390
+ cwd,
391
+ });
302
392
  return {
303
393
  ok: true,
304
394
  repo: options.repo,
@@ -306,9 +396,10 @@ export async function detectCheckpointEvidence(options, { env = process.env, ghC
306
396
  currentHeadSha,
307
397
  draftGate: normalizeGateSummary(commentSummary.draft_gate),
308
398
  preApprovalGate: normalizeGateSummary(commentSummary.pre_approval_gate),
309
- draftGateMarker: normalizeGateMarkerSummary(markerSummary.draft_gate),
310
- preApprovalGateMarker: normalizeGateMarkerSummary(markerSummary.pre_approval_gate),
399
+ draftGateMarker,
400
+ preApprovalGateMarker,
311
401
  draftGateSatisfied: commentSummary.draft_gate?.verdict === "clean" && typeof commentSummary.draft_gate?.headSha === "string",
402
+ fanoutEnforcement,
312
403
  ...(runnerOwnership.status !== "skipped_no_async_run_id" ? { runnerOwnership } : {}),
313
404
  staleRunner: {
314
405
  status: staleRunnerDetection.status,
@@ -351,7 +442,7 @@ async function main() {
351
442
  ? [`exit signal recorded for run ${result.staleRunner.activeRun?.runId}: refuse to merge`]
352
443
  : [],
353
444
  };
354
- const preMergeGateCheck = buildPreMergeGateCheck(result, unresolvedThreadCount, staleRunnerCheck);
445
+ const preMergeGateCheck = buildPreMergeGateCheck(result, unresolvedThreadCount, staleRunnerCheck, result.fanoutEnforcement);
355
446
  const output = { ...result, preMergeGateCheck, staleRunnerCheck };
356
447
  if (!preMergeGateCheck.ok) {
357
448
  process.stderr.write(`${JSON.stringify({
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env node
2
+ import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
3
+ import { parsePrNumber, runChild } from "../_cli-primitives.mjs";
4
+ import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
5
+ import { loadDevLoopConfig } from "@dev-loops/core/config";
6
+ import { resolveHandoffCandidates } from "./resolve-handoff-candidates.mjs";
7
+
8
+ const USAGE = `Usage: offer-human-handoff.mjs --repo <owner/name> --pr <number> [--assign <login>...] [--request-review <login>...] [--changed-files <csv>] [--pr-author <login>]
9
+ Human-handoff offer at the pre-approval / merge-handoff boundary (#920, Request B
10
+ of #910). OFFER-only: without --assign/--request-review it resolves and prints
11
+ the candidate list (the "offer"); the operator confirms who takes it. With
12
+ --assign / --request-review it performs the confirmed action via \`gh pr edit\`.
13
+
14
+ This pairs with autonomy.humanMergeOnly: when human-merge is enforced, the offer
15
+ names who should take the merge. No-op when approval.humanHandoff is disabled
16
+ (default) — prints an empty offer.
17
+
18
+ Required:
19
+ --repo <owner/name>
20
+ --pr <number>
21
+ Optional:
22
+ --assign <login> Add a confirmed assignee (repeatable). Performs
23
+ \`gh pr edit --add-assignee\`.
24
+ --request-review <login> Request review from a confirmed reviewer (repeatable).
25
+ Performs \`gh pr edit --add-reviewer\`.
26
+ --changed-files <csv> Forwarded to the candidate resolver.
27
+ --pr-author <login> Forwarded to the candidate resolver.
28
+ Output (stdout, JSON):
29
+ Offer mode: { "ok": true, "mode": "offer", "enabled", "candidates": [...], ... }
30
+ Apply mode: { "ok": true, "mode": "apply", "assigned": [...], "requestedReview": [...] }
31
+ Exit codes:
32
+ 0 Success (including disabled no-op offer)
33
+ 1 Argument error or gh failure`.trim();
34
+
35
+ const parseError = buildParseError(USAGE);
36
+
37
+ function nextValue(args, i, flag) {
38
+ const value = args[i];
39
+ if (typeof value !== "string" || value.length === 0 || value.startsWith("--")) {
40
+ throw parseError(`Missing value for ${flag}`);
41
+ }
42
+ return value;
43
+ }
44
+
45
+ // A login flag value with the leading `@` stripped and trimmed. Reject an empty
46
+ // result (e.g. `--assign @`) so a blank login can never reach `gh pr edit
47
+ // --add-assignee ""`.
48
+ function loginValue(args, i, flag) {
49
+ const login = nextValue(args, i, flag).trim().replace(/^@/, "").trim();
50
+ if (login === "") throw parseError(`${flag} requires a non-empty login`);
51
+ return login;
52
+ }
53
+
54
+ export function parseOfferCliArgs(argv) {
55
+ const args = [...argv];
56
+ if (args.includes("--help") || args.includes("-h")) return { help: true };
57
+ const options = {
58
+ help: false, repo: undefined, pr: undefined,
59
+ assign: [], requestReview: [], changedFiles: undefined, prAuthor: undefined,
60
+ };
61
+ for (let i = 0; i < args.length; i += 1) {
62
+ const token = args[i];
63
+ if (token === "--repo") { options.repo = nextValue(args, ++i, "--repo"); continue; }
64
+ if (token === "--pr") { options.pr = parsePrNumber(nextValue(args, ++i, "--pr"), parseError); continue; }
65
+ if (token === "--assign") { options.assign.push(loginValue(args, ++i, "--assign")); continue; }
66
+ if (token === "--request-review") { options.requestReview.push(loginValue(args, ++i, "--request-review")); continue; }
67
+ if (token === "--changed-files") {
68
+ options.changedFiles = nextValue(args, ++i, "--changed-files")
69
+ .split(",").map((s) => s.trim()).filter((s) => s.length > 0);
70
+ continue;
71
+ }
72
+ if (token === "--pr-author") { options.prAuthor = nextValue(args, ++i, "--pr-author").trim().replace(/^@/, ""); continue; }
73
+ throw parseError(`Unknown argument: ${token}`);
74
+ }
75
+ if (options.repo === undefined || options.pr === undefined) {
76
+ throw parseError("Offering human handoff requires both --repo <owner/name> and --pr <number>");
77
+ }
78
+ try {
79
+ parseRepoSlug(options.repo);
80
+ } catch (error) {
81
+ throw parseError(error instanceof Error ? error.message : String(error));
82
+ }
83
+ return options;
84
+ }
85
+
86
+ /**
87
+ * Perform the confirmed assign / request-review actions via `gh pr edit`.
88
+ * OFFER-only contract: this is reached only when the operator passed
89
+ * --assign / --request-review (the confirmation). It never auto-assigns.
90
+ * @returns {Promise<{ ok: boolean, mode: "apply", assigned: string[], requestedReview: string[] }>}
91
+ */
92
+ export async function applyHandoff({ repo, pr, assign, requestReview }, { run = (cmd, args) => runChild(cmd, args), ghCommand = "gh" } = {}) {
93
+ const ghArgs = ["pr", "edit", String(pr), "--repo", repo];
94
+ for (const login of assign) ghArgs.push("--add-assignee", login);
95
+ for (const login of requestReview) ghArgs.push("--add-reviewer", login);
96
+ const result = await run(ghCommand, ghArgs);
97
+ if (result.code !== 0) {
98
+ throw new Error(result.stderr.trim() || `gh pr edit exited ${result.code}`);
99
+ }
100
+ return { ok: true, mode: "apply", assigned: assign, requestedReview: requestReview };
101
+ }
102
+
103
+ export async function main(argv = process.argv.slice(2), deps = {}) {
104
+ let options;
105
+ try {
106
+ options = parseOfferCliArgs(argv);
107
+ } catch (error) {
108
+ process.stderr.write(`${formatCliError(error)}\n`);
109
+ return 1;
110
+ }
111
+ if (options.help) {
112
+ process.stdout.write(`${USAGE}\n`);
113
+ return 0;
114
+ }
115
+
116
+ const run = deps.run ?? ((cmd, args) => runChild(cmd, args));
117
+ const ghCommand = deps.ghCommand ?? "gh";
118
+
119
+ // Apply mode: operator confirmed an assignee/reviewer.
120
+ if (options.assign.length > 0 || options.requestReview.length > 0) {
121
+ try {
122
+ const applied = await applyHandoff(
123
+ { repo: options.repo, pr: options.pr, assign: options.assign, requestReview: options.requestReview },
124
+ { run, ghCommand },
125
+ );
126
+ process.stdout.write(`${JSON.stringify(applied)}\n`);
127
+ return 0;
128
+ } catch (error) {
129
+ process.stderr.write(`${formatCliError(error)}\n`);
130
+ return 1;
131
+ }
132
+ }
133
+
134
+ // Offer mode: resolve + print candidates, assign nobody.
135
+ const repoRoot = deps.repoRoot ?? process.cwd();
136
+ const config = deps.config !== undefined ? deps.config : (await loadDevLoopConfig({ repoRoot })).config;
137
+ const offer = await resolveHandoffCandidates(
138
+ { repo: options.repo, pr: options.pr, changedFiles: options.changedFiles, prAuthor: options.prAuthor ?? null },
139
+ { ...deps, config, repoRoot, run, ghCommand },
140
+ );
141
+ process.stdout.write(`${JSON.stringify({ ...offer, mode: "offer" })}\n`);
142
+ return 0;
143
+ }
144
+
145
+ if (isDirectCliRun(import.meta.url)) {
146
+ main().then((code) => { process.exitCode = code; });
147
+ }