dev-loops 0.7.1 → 0.8.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 (107) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +7 -7
  3. package/.claude/agents/fixer.md +1 -0
  4. package/.claude/agents/refiner.md +4 -4
  5. package/.claude/agents/review.md +10 -13
  6. package/.claude/commands/loop-continue.md +2 -1
  7. package/.claude/commands/loop-enqueue.md +9 -2
  8. package/.claude/commands/loop-info.md +1 -1
  9. package/.claude/commands/loop-start-spike.md +1 -1
  10. package/.claude/hooks/_bash-command-classify.mjs +7 -6
  11. package/.claude/hooks/_hook-decisions.mjs +5 -4
  12. package/.claude/skills/copilot-pr-followup/SKILL.md +42 -28
  13. package/.claude/skills/dev-loop/SKILL.md +8 -8
  14. package/.claude/skills/docs/acceptance-criteria-verification.md +13 -4
  15. package/.claude/skills/docs/anti-patterns.md +6 -5
  16. package/.claude/skills/docs/artifact-authority-contract.md +17 -14
  17. package/.claude/skills/docs/confirmation-rules.md +2 -1
  18. package/.claude/skills/docs/contract-style-guide.md +37 -0
  19. package/.claude/skills/docs/copilot-loop-operations.md +21 -15
  20. package/.claude/skills/docs/cross-harness-regression-contract.md +2 -2
  21. package/.claude/skills/docs/debt-remediation-contract.md +1 -1
  22. package/.claude/skills/docs/epic-tree-refinement-procedure.md +25 -22
  23. package/.claude/skills/docs/issue-intake-procedure.md +37 -22
  24. package/.claude/skills/docs/merge-preconditions.md +17 -13
  25. package/.claude/skills/docs/pr-lifecycle-contract.md +45 -36
  26. package/.claude/skills/docs/public-dev-loop-contract.md +61 -44
  27. package/.claude/skills/docs/retrospective-checkpoint-contract.md +25 -9
  28. package/.claude/skills/docs/spike-mode-contract.md +14 -7
  29. package/.claude/skills/docs/stop-conditions.md +30 -15
  30. package/.claude/skills/docs/tracker-first-loop-state.md +13 -8
  31. package/.claude/skills/docs/validation-policy.md +4 -5
  32. package/.claude/skills/local-implementation/SKILL.md +49 -143
  33. package/.claude/skills/loop-grill/SKILL.md +34 -14
  34. package/AGENTS.md +1 -1
  35. package/CHANGELOG.md +36 -0
  36. package/README.md +95 -189
  37. package/agents/dev-loop.agent.md +8 -8
  38. package/agents/fixer.agent.md +1 -0
  39. package/agents/refiner.agent.md +4 -4
  40. package/agents/review.agent.md +10 -13
  41. package/extension/README.md +5 -4
  42. package/package.json +7 -5
  43. package/scripts/_core-helpers.mjs +2 -0
  44. package/scripts/claude/headless-dev-loop.mjs +1 -1
  45. package/scripts/docs/validate-rule-ownership.mjs +442 -0
  46. package/scripts/docs/validate-state-machine-conformance.mjs +1169 -0
  47. package/scripts/github/_gate-names.mjs +5 -0
  48. package/scripts/github/_review-thread-mutations.mjs +5 -2
  49. package/scripts/github/capture-review-threads.mjs +2 -2
  50. package/scripts/github/create-pr.mjs +119 -10
  51. package/scripts/github/detect-checkpoint-evidence.mjs +109 -24
  52. package/scripts/github/edit-issue.mjs +259 -0
  53. package/scripts/github/list-review-threads.mjs +277 -0
  54. package/scripts/github/post-gate-findings.mjs +6 -3
  55. package/scripts/github/probe-ci-status.mjs +18 -0
  56. package/scripts/github/probe-copilot-review.mjs +24 -3
  57. package/scripts/github/reconcile-draft-gate.mjs +13 -13
  58. package/scripts/github/request-copilot-review.mjs +109 -42
  59. package/scripts/github/upsert-checkpoint-verdict.mjs +98 -27
  60. package/scripts/github/verify-briefing-prefixes.mjs +382 -0
  61. package/scripts/github/verify-fresh-review-context.mjs +78 -3
  62. package/scripts/github/wait-pr-checks.mjs +171 -0
  63. package/scripts/github/write-gate-context.mjs +220 -10
  64. package/scripts/github/write-gate-findings-log.mjs +54 -2
  65. package/scripts/loop/_post-convergence-change.mjs +2 -2
  66. package/scripts/loop/_pr-runner-coordination.mjs +112 -13
  67. package/scripts/loop/check-retro-tooling.mjs +14 -9
  68. package/scripts/loop/copilot-pr-handoff.mjs +47 -16
  69. package/scripts/loop/detect-change-scope.mjs +2 -2
  70. package/scripts/loop/detect-copilot-loop-state.mjs +34 -14
  71. package/scripts/loop/detect-internal-only-pr.mjs +6 -6
  72. package/scripts/loop/detect-pr-gate-coordination-state.mjs +152 -18
  73. package/scripts/loop/detect-refinement-grill-state.mjs +136 -0
  74. package/scripts/loop/resolve-dev-loop-startup.mjs +145 -8
  75. package/scripts/loop/run-watch-cycle.mjs +42 -7
  76. package/scripts/loop/sanctioned-commands.mjs +3 -0
  77. package/scripts/loop/validate-pr-body-spec.mjs +21 -5
  78. package/scripts/pages/build-state-atlas.mjs +65 -48
  79. package/scripts/projects/_resolve-project.mjs +1 -148
  80. package/scripts/projects/add-queue-item.mjs +87 -4
  81. package/scripts/projects/list-queue-items.mjs +3 -377
  82. package/scripts/projects/move-queue-item.mjs +3 -410
  83. package/scripts/projects/reorder-queue-item.mjs +3 -22
  84. package/skills/copilot-pr-followup/SKILL.md +42 -28
  85. package/skills/dev-loop/SKILL.md +3 -3
  86. package/skills/docs/acceptance-criteria-verification.md +13 -4
  87. package/skills/docs/anti-patterns.md +6 -5
  88. package/skills/docs/artifact-authority-contract.md +17 -14
  89. package/skills/docs/confirmation-rules.md +2 -1
  90. package/skills/docs/contract-style-guide.md +37 -0
  91. package/skills/docs/copilot-loop-operations.md +21 -15
  92. package/skills/docs/cross-harness-regression-contract.md +2 -2
  93. package/skills/docs/debt-remediation-contract.md +1 -1
  94. package/skills/docs/epic-tree-refinement-procedure.md +25 -22
  95. package/skills/docs/issue-intake-procedure.md +37 -22
  96. package/skills/docs/merge-preconditions.md +17 -13
  97. package/skills/docs/pr-lifecycle-contract.md +45 -36
  98. package/skills/docs/public-dev-loop-contract.md +61 -44
  99. package/skills/docs/required-rules.json +165 -0
  100. package/skills/docs/retrospective-checkpoint-contract.md +25 -9
  101. package/skills/docs/spike-mode-contract.md +14 -7
  102. package/skills/docs/stop-conditions.md +30 -15
  103. package/skills/docs/tracker-first-loop-state.md +13 -8
  104. package/skills/docs/validation-policy.md +4 -5
  105. package/skills/local-implementation/SKILL.md +49 -143
  106. package/skills/loop-grill/SKILL.md +38 -17
  107. package/scripts/docs/validate-no-duplicate-rules.mjs +0 -250
@@ -1,3 +1,5 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import fs from "node:fs";
1
3
  import path from "node:path";
2
4
  import process from "node:process";
3
5
  import { parseRepoSlugParts } from "@dev-loops/core/github/repo-slug";
@@ -9,6 +11,7 @@ import {
9
11
  } from "./_steering-state-file.mjs";
10
12
  export const RUNNER_COORDINATION_SCHEMA_VERSION = 2;
11
13
  export const RUNNER_COORDINATION_SUPPORTED_SCHEMA_VERSIONS = Object.freeze([1, 2]);
14
+ export const RUNNER_COORDINATION_HISTORY_LIMIT = 50; // cap audit trail; heartbeats append per-round, keep the most recent 50 events
12
15
  export const RUNNER_OWNERSHIP_ERROR = Object.freeze({
13
16
  ACTIVE_RUN_EXISTS: "active_run_exists",
14
17
  OWNERSHIP_LOST: "ownership_lost",
@@ -50,7 +53,10 @@ async function loadRunnerStateFile(filePath) {
50
53
  }
51
54
  async function saveRunnerStateFile(filePath, state) {
52
55
  try {
53
- return await saveSharedStateFile(filePath, state);
56
+ const capped = Array.isArray(state.history) && state.history.length > RUNNER_COORDINATION_HISTORY_LIMIT
57
+ ? { ...state, history: state.history.slice(-RUNNER_COORDINATION_HISTORY_LIMIT) }
58
+ : state;
59
+ return await saveSharedStateFile(filePath, capped);
54
60
  } catch (error) {
55
61
  const message = error instanceof Error ? error.message : String(error);
56
62
  throw new Error(`Failed to write runner coordination state file '${filePath}': ${message}`);
@@ -64,13 +70,62 @@ async function withRunnerStateFileLock(filePath, callback) {
64
70
  throw new Error(`Failed to acquire runner coordination state lock for '${filePath}': ${message}`);
65
71
  }
66
72
  }
73
+ const coordinationRootCache = new Map();
74
+ /**
75
+ * Resolve the single stable coordination root for a checkout, independent of CWD.
76
+ *
77
+ * `git rev-parse --git-common-dir` yields the shared git dir that is identical for
78
+ * a repo and all its linked worktrees, so its parent is the one main-checkout root
79
+ * that a worktree runner and a repo-root detector both anchor to — eliminating the
80
+ * split-copy false-stale stall where each read a different `.pi/runner-coordination`
81
+ * file. Falls back to the canonicalized (realpath'd) `cwd` when git is
82
+ * unavailable or the dir is not a checkout, so symlinked and realpath'd
83
+ * spellings of the same non-git dir still converge on one coordination path.
84
+ *
85
+ * `cwd` is realpath'd once at entry: git returns a relative `--git-common-dir` from
86
+ * a main checkout but an already-realpath'd absolute one from a linked worktree, so
87
+ * resolving against a symlinked cwd (e.g. macOS /tmp -> /private/tmp) would make the
88
+ * two sides compute different roots for the same physical repo. Canonicalizing first
89
+ * makes both sides converge, and doubles as the cache key so symlink-variant cwd
90
+ * spellings share one cache entry. Cached per canonical cwd for the process.
91
+ */
92
+ function resolveRepoCoordinationRoot(cwd) {
93
+ let canonicalCwd = cwd;
94
+ try {
95
+ canonicalCwd = fs.realpathSync(cwd);
96
+ } catch (err) {
97
+ // realpathSync on an existing checkout dir virtually never fails;
98
+ // warn (don't throw) so the rare transient failure — which can desync the
99
+ // coordination path across worktrees — is diagnosable instead of silent.
100
+ console.warn(
101
+ `[runner-coordination] realpathSync(${cwd}) failed; using raw cwd, coordination path may diverge across worktrees: ${err instanceof Error ? err.message : String(err)}`,
102
+ );
103
+ }
104
+ if (coordinationRootCache.has(canonicalCwd)) return coordinationRootCache.get(canonicalCwd);
105
+ let root = canonicalCwd;
106
+ try {
107
+ const commonDir = execFileSync("git", ["rev-parse", "--git-common-dir"], {
108
+ cwd: canonicalCwd,
109
+ encoding: "utf8",
110
+ stdio: ["ignore", "pipe", "ignore"],
111
+ }).trim();
112
+ if (commonDir) {
113
+ root = path.dirname(path.resolve(canonicalCwd, commonDir));
114
+ }
115
+ } catch {
116
+ // not a git checkout / git unavailable — anchor at the canonical (realpath'd) cwd
117
+ }
118
+ coordinationRootCache.set(canonicalCwd, root);
119
+ return root;
120
+ }
67
121
  export function defaultRunnerCoordinationFilePathForTarget({ repo, pr }, cwd = process.cwd()) {
68
122
  const { owner, name } = parseRepoSlugParts(repo, {
69
123
  errorMessage: `Invalid repo slug for coordination target path: ${JSON.stringify(repo)}`,
70
124
  lowercase: true,
71
125
  });
72
126
  const normalizedPr = normalizePr(pr);
73
- return path.join(cwd, ".pi", "runner-coordination", owner, name, `pr-${normalizedPr}.json`);
127
+ const root = resolveRepoCoordinationRoot(cwd);
128
+ return path.join(root, ".pi", "runner-coordination", owner, name, `pr-${normalizedPr}.json`);
74
129
  }
75
130
  export function createRunnerCoordinationState({ repo, pr, runId = null, now = new Date().toISOString() }) {
76
131
  const normalizedRepo = normalizeRepoSlug(repo);
@@ -324,6 +379,18 @@ export async function claimRunnerOwnership({
324
379
  };
325
380
  });
326
381
  }
382
+ /**
383
+ * Verify the caller still owns the PR's runner claim.
384
+ *
385
+ * A successful owner-confirmed assert (the active owner's runId matches the
386
+ * caller's) refreshes `activeRun.updatedAt` — it acts as a heartbeat. Every
387
+ * other outcome (no record, no active run, ownership lost, missing run id)
388
+ * is a pure lock-free read with no write. Long-running loops must assert (or
389
+ * claim) at least once within the stale-max-age window
390
+ * (`STALE_RUNNER_DEFAULT_MAX_AGE_MS`, 30 minutes by default, overridable via
391
+ * `DEVLOOPS_STALE_RUNNER_MAX_AGE_MS`; see `_stale-runner-detection.mjs`) to
392
+ * avoid being seen as stale during a long multi-round gate cycle.
393
+ */
327
394
  export async function assertRunnerOwnership({
328
395
  repo,
329
396
  pr,
@@ -331,6 +398,7 @@ export async function assertRunnerOwnership({
331
398
  cwd = process.cwd(),
332
399
  filePath = null,
333
400
  requireExisting = false,
401
+ now = new Date().toISOString(),
334
402
  } = {}) {
335
403
  const normalizedRepo = normalizeRepoSlug(repo);
336
404
  const normalizedPr = normalizePr(pr);
@@ -397,17 +465,48 @@ export async function assertRunnerOwnership({
397
465
  });
398
466
  }
399
467
  if (state.activeRun.runId === normalizedRunId) {
400
- return {
401
- ok: true,
402
- status: "owner_confirmed",
403
- repo: normalizedRepo,
404
- pr: normalizedPr,
405
- runId: normalizedRunId,
406
- activeRun: state.activeRun,
407
- previousRun: state.previousRun,
408
- exitSignals: state.exitSignals,
409
- filePath: resolvedPath,
410
- };
468
+ return withRunnerStateFileLock(resolvedPath, async () => {
469
+ const lockedRaw = await loadRunnerStateFile(resolvedPath);
470
+ const lockedState = lockedRaw === null
471
+ ? null
472
+ : normalizeRunnerCoordinationState(lockedRaw, { repo: normalizedRepo, pr: normalizedPr });
473
+ if (lockedState?.activeRun?.runId !== normalizedRunId) {
474
+ // Ownership changed hands between the lockless read above and acquiring
475
+ // the lock (a concurrent takeover) — don't write; report the new owner.
476
+ return buildConflict({
477
+ error: RUNNER_OWNERSHIP_ERROR.OWNERSHIP_LOST,
478
+ repo: normalizedRepo,
479
+ pr: normalizedPr,
480
+ runId: normalizedRunId,
481
+ activeRun: lockedState?.activeRun ?? null,
482
+ filePath: resolvedPath,
483
+ exitSignals: lockedState?.exitSignals ?? [],
484
+ message: lockedState?.activeRun?.runId
485
+ ? `PR ${normalizedRepo}#${normalizedPr} is now owned by run ${lockedState.activeRun.runId}; run ${normalizedRunId} must stop.`
486
+ : `PR ${normalizedRepo}#${normalizedPr} no longer has an active runner ownership record; run ${normalizedRunId} must stop.`,
487
+ });
488
+ }
489
+ const nextState = {
490
+ ...lockedState,
491
+ activeRun: {
492
+ ...lockedState.activeRun,
493
+ updatedAt: now,
494
+ },
495
+ history: [...lockedState.history, { type: "heartbeat", runId: normalizedRunId, at: now }],
496
+ };
497
+ await saveRunnerStateFile(resolvedPath, nextState);
498
+ return {
499
+ ok: true,
500
+ status: "owner_confirmed",
501
+ repo: normalizedRepo,
502
+ pr: normalizedPr,
503
+ runId: normalizedRunId,
504
+ activeRun: nextState.activeRun,
505
+ previousRun: nextState.previousRun,
506
+ exitSignals: nextState.exitSignals,
507
+ filePath: resolvedPath,
508
+ };
509
+ });
411
510
  }
412
511
  return buildConflict({
413
512
  error: RUNNER_OWNERSHIP_ERROR.OWNERSHIP_LOST,
@@ -24,10 +24,13 @@
24
24
  * - dev-loops subcommands and `node scripts/....mjs` invocations. Those scripts
25
25
  * legitimately call gh/GraphQL/etc. internally — that IS the tooling.
26
26
  * - A small explicit allowlist of write-ops that have no internal wrapper today:
27
- * `gh pr merge`, `gh pr ready`, `gh issue create`, `gh issue edit`. These are
28
- * recorded as `allowedWriteOps` rather than violations so the gate is not
27
+ * `gh pr merge`, `gh pr ready`, `gh issue create`. Plus belt-and-suspenders
28
+ * entries that DO have wrappers `gh issue edit` (scripts/github/edit-issue.mjs)
29
+ * and `gh label create` (scripts/github/create-label.mjs) — kept so a bare
30
+ * invocation surfaced from the wrapper's own subprocess is not a false violation.
31
+ * All are recorded as `allowedWriteOps` rather than violations so the gate is not
29
32
  * blocked forever on an unavoidable gap. Document/close the gap with a wrapper
30
- * to remove them from the allowlist.
33
+ * to remove the no-wrapper entries from the allowlist.
31
34
  *
32
35
  * VIOLATION (agent-level raw call):
33
36
  * - `gh ...` at the start of a command segment (start of line, or after
@@ -75,19 +78,21 @@ Exit codes:
75
78
  2 Argument/runtime error, or invalid --jq filter`;
76
79
 
77
80
  /**
78
- * Write-ops that currently have no internal dev-loops wrapper. Recorded
79
- * distinctly so the gate is not blocked forever on an unavoidable gap.
80
- * Keep this set SMALL and explicit; remove an entry once a wrapper exists.
81
+ * Write-ops recorded distinctly (as allowedWriteOps, not violations) so the gate
82
+ * is not blocked forever on an unavoidable gap. Most have no internal wrapper yet;
83
+ * a couple DO have wrappers and are kept belt-and-suspenders (see below).
84
+ * Keep this set SMALL and explicit; remove a no-wrapper entry once a wrapper exists.
81
85
  * @type {ReadonlyArray<RegExp>}
82
86
  */
83
87
  const ALLOWED_WRITE_OPS = Object.freeze([
84
88
  /^gh\s+pr\s+merge\b/,
85
89
  /^gh\s+pr\s+ready\b/,
86
90
  /^gh\s+issue\s+create\b/,
91
+ // `gh issue edit` (scripts/github/edit-issue.mjs) and `gh label create`
92
+ // (scripts/github/create-label.mjs) both HAVE wrappers; these entries are
93
+ // belt-and-suspenders so a bare invocation (e.g. surfaced from the wrapper's
94
+ // own subprocess) classifies as an allowed write-op, not a violation.
87
95
  /^gh\s+issue\s+edit\b/,
88
- // `gh label create` HAS a wrapper (scripts/github/create-label.mjs); this
89
- // entry is belt-and-suspenders so a bare invocation (e.g. surfaced from the
90
- // wrapper's own subprocess) classifies as an allowed write-op, not a violation.
91
96
  /^gh\s+label\s+create\b/,
92
97
  ]);
93
98
 
@@ -1,14 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  import { buildParseError, formatCliError, isCopilotLogin, isDirectCliRun, normalizeTimestamp, parseJsonText } from "../_core-helpers.mjs";
3
- import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
3
+ import { parsePrNumber, requireTokenValue, runChild as defaultRunChild } from "../_cli-primitives.mjs";
4
4
  import { detectPostConvergenceSignificantChange } from "./_post-convergence-change.mjs";
5
5
  import { detectRepoSlug, parseRepoSlug } from "@dev-loops/core/github/repo-slug";
6
6
  import { resolveRunId } from "@dev-loops/core/loop/run-context";
7
- import { loadDevLoopConfig, resolveRefinement } from "@dev-loops/core/config";
7
+ import { loadDevLoopConfig, resolveEffectiveCopilotRoundCap, resolveRefinement } from "@dev-loops/core/config";
8
8
  import { autoDetectSnapshot } from "./detect-copilot-loop-state.mjs";
9
9
  import { performCopilotReviewRequest } from "../github/request-copilot-review.mjs";
10
10
  import { detectInternalOnly as detectPrInternalOnly } from "./detect-internal-only-pr.mjs";
11
11
  import { applyConfirmedReviewRequest, interpretLoopState, NEXT_ACTIONS, STATE, summarizeLoopInterpretation, TRANSITIONS } from "@dev-loops/core/loop/copilot-loop-state";
12
+ import { PR_LIFECYCLE_STATE } from "@dev-loops/core/loop/pr-lifecycle";
12
13
  import { ensureAsyncRunnerOwnership, releaseAsyncRunnerOwnership } from "./_pr-runner-coordination.mjs";
13
14
  import { resolveRepoRoot } from "./_repo-root-resolver.mjs";
14
15
 
@@ -38,6 +39,13 @@ Optional:
38
39
  --watch-status <status> Refresh deterministic loop state after a prior
39
40
  watcher result (changed|timeout|idle). This mode
40
41
  never requests review; it only re-detects state.
42
+ --lightweight This PR is light-dispatched (#1210): compose the
43
+ Copilot round cap with localImplementation.lightMode.
44
+ maxCopilotRounds (default 1) via
45
+ min(lightMode.maxCopilotRounds, refinement.maxCopilotRounds)
46
+ instead of using refinement.maxCopilotRounds alone.
47
+ refinement.maxCopilotRounds: 0 still disables Copilot
48
+ rounds even with --lightweight.
41
49
  Output (stdout, JSON):
42
50
  { "ok": true, "action": "watch"|"fix"|"stop", "state": "...",
43
51
  "allowedTransitions": [...], "nextAction": "...", "snapshot": {...},
@@ -100,7 +108,7 @@ function summarizeRequestWatchContract({
100
108
  || interpretation.state === STATE.READY_TO_REREQUEST_REVIEW
101
109
  && interpretation.sameHeadCleanConverged !== true
102
110
  ) {
103
- routingState = "ready_state_needs_copilot_request";
111
+ routingState = PR_LIFECYCLE_STATE.READY_STATE_NEEDS_COPILOT_REQUEST;
104
112
  } else if (interpretation.state === STATE.INTERNAL_TOOLING_DIRECT_GATE) {
105
113
  routingState = "internal_tooling_skip_copilot";
106
114
  }
@@ -138,6 +146,7 @@ export function parseHandoffCliArgs(argv, { cwd = process.cwd() } = {}) {
138
146
  repo: undefined,
139
147
  pr: undefined,
140
148
  watchStatus: undefined,
149
+ lightweight: false,
141
150
  jq: undefined,
142
151
  silent: false,
143
152
  };
@@ -148,6 +157,7 @@ export function parseHandoffCliArgs(argv, { cwd = process.cwd() } = {}) {
148
157
  repo: { type: "string" },
149
158
  pr: { type: "string" },
150
159
  "watch-status": { type: "string" },
160
+ lightweight: { type: "boolean" },
151
161
  ...JQ_OUTPUT_PARSE_OPTIONS,
152
162
  },
153
163
  allowPositionals: true,
@@ -184,6 +194,10 @@ export function parseHandoffCliArgs(argv, { cwd = process.cwd() } = {}) {
184
194
  options.watchStatus = watchStatus;
185
195
  continue;
186
196
  }
197
+ if (token.name === "lightweight") {
198
+ options.lightweight = true;
199
+ continue;
200
+ }
187
201
  if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
188
202
  throw parseError(`Unknown argument: ${token.rawName}`);
189
203
  }
@@ -213,7 +227,7 @@ export function parseHandoffCliArgs(argv, { cwd = process.cwd() } = {}) {
213
227
  * loop should pause for operator review.
214
228
  * Returns { paused: true, humanComments: [...] } when human comments need attention.
215
229
  */
216
- export async function detectRecentHumanComments({ repo, pr, claimedAtMs }, { env = process.env, ghCommand = "gh" } = {}) {
230
+ export async function detectRecentHumanComments({ repo, pr, claimedAtMs }, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}) {
217
231
  try {
218
232
  const result = await runChild(
219
233
  ghCommand,
@@ -298,7 +312,7 @@ export async function detectRecentHumanComments({ repo, pr, claimedAtMs }, { env
298
312
  // (#1103, #1126): the current head, the Copilot reviews (to find the last
299
313
  // reviewed head), and the PR's changed files. Fetched only when the interpreter
300
314
  // already resolved ROUND_CAP_CLEAN_FALLBACK, so this extra call is off the hot path.
301
- async function fetchReopenCycleFacts({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
315
+ async function fetchReopenCycleFacts({ repo, pr }, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}) {
302
316
  const result = await runChild(
303
317
  ghCommand,
304
318
  ["pr", "view", String(pr), "--repo", repo, "--json", "headRefOid,reviews,files"],
@@ -314,12 +328,16 @@ async function fetchReopenCycleFacts({ repo, pr }, { env = process.env, ghComman
314
328
  }
315
329
  }
316
330
 
317
- export async function runHandoff(options, { env = process.env, ghCommand = "gh" } = {}) {
331
+ export async function runHandoff(options, { env = process.env, ghCommand = "gh", runChild = defaultRunChild, repoRoot } = {}) {
332
+ // Single resolved repo root for config + runner-coordination reads/writes.
333
+ // Defaults to the checkout's git toplevel (production); an injected repoRoot
334
+ // keeps the whole cascade hermetic when driven in-process.
335
+ const resolvedRepoRoot = repoRoot ?? resolveRepoRoot(process.cwd());
318
336
  const runnerOwnership = await ensureAsyncRunnerOwnership({
319
337
  repo: options.repo,
320
338
  pr: options.pr,
321
339
  env,
322
- cwd: resolveRepoRoot(process.cwd()),
340
+ cwd: resolvedRepoRoot,
323
341
  claimIfMissing: true,
324
342
  });
325
343
  if (!runnerOwnership.ok) {
@@ -349,15 +367,24 @@ export async function runHandoff(options, { env = process.env, ghCommand = "gh"
349
367
  }
350
368
  let snapshot = await autoDetectSnapshot(
351
369
  { repo: options.repo, pr: options.pr },
352
- { env, ghCommand },
370
+ { env, ghCommand, runChild },
353
371
  );
354
- const config = await loadDevLoopConfig({ repoRoot: resolveRepoRoot(process.cwd()) });
372
+ const config = await loadDevLoopConfig({ repoRoot: resolvedRepoRoot });
355
373
  if (config.errors?.length > 0) {
356
374
  console.error("[copilot-pr-handoff] config warnings:", JSON.stringify(config.errors));
357
375
  }
358
376
  const refinementConfig = config.errors?.length > 0
359
377
  ? resolveRefinement({ version: 1 })
360
378
  : resolveRefinement(config.config);
379
+ if (options.lightweight) {
380
+ // Compose (not replace) the round cap for light-dispatched PRs (#1210):
381
+ // min(lightMode.maxCopilotRounds ?? 1, refinement.maxCopilotRounds), so
382
+ // maxCopilotRounds: 0 still disables Copilot rounds everywhere.
383
+ refinementConfig.maxCopilotRounds = resolveEffectiveCopilotRoundCap(
384
+ config.errors?.length > 0 ? { version: 1 } : config.config,
385
+ { lightweight: true },
386
+ );
387
+ }
361
388
  let interpretation = interpretLoopState(snapshot, refinementConfig);
362
389
 
363
390
  // Check for human comments since last subagent action
@@ -366,7 +393,7 @@ export async function runHandoff(options, { env = process.env, ghCommand = "gh"
366
393
  if (resolveRunId(env)) {
367
394
  humanCommentCheck = await detectRecentHumanComments(
368
395
  { repo: options.repo, pr: options.pr, claimedAtMs: runnerOwnership?.activeRun?.claimedAt ? new Date(runnerOwnership.activeRun.claimedAt).getTime() : undefined },
369
- { env, ghCommand },
396
+ { env, ghCommand, runChild },
370
397
  );
371
398
  }
372
399
  const TERMINAL_STATES = new Set([STATE.NO_PR, STATE.DONE, STATE.BLOCKED_NEEDS_USER_DECISION]);
@@ -376,7 +403,7 @@ export async function runHandoff(options, { env = process.env, ghCommand = "gh"
376
403
  repo: options.repo,
377
404
  pr: options.pr,
378
405
  env,
379
- cwd: resolveRepoRoot(process.cwd()),
406
+ cwd: resolvedRepoRoot,
380
407
  });
381
408
  return {
382
409
  ok: true,
@@ -444,7 +471,7 @@ export async function runHandoff(options, { env = process.env, ghCommand = "gh"
444
471
  interpretation.state === STATE.READY_TO_REREQUEST_REVIEW)
445
472
  ) {
446
473
  try {
447
- const internalCheck = await detectPrInternalOnly(options, { env, ghCommand });
474
+ const internalCheck = await detectPrInternalOnly(options, { env, ghCommand, runChild });
448
475
  if (internalCheck.ok && internalCheck.internalOnly) {
449
476
  internalOnlySkipCopilot = true;
450
477
  interpretation = {
@@ -503,7 +530,7 @@ export async function runHandoff(options, { env = process.env, ghCommand = "gh"
503
530
  if (!internalOnlySkipCopilot
504
531
  && options.watchStatus === undefined
505
532
  && interpretation.state === STATE.ROUND_CAP_CLEAN_FALLBACK) {
506
- const reopenFacts = await fetchReopenCycleFacts(options, { env, ghCommand });
533
+ const reopenFacts = await fetchReopenCycleFacts(options, { env, ghCommand, runChild });
507
534
  const significant = await detectPostConvergenceSignificantChange(
508
535
  {
509
536
  repo: options.repo,
@@ -514,7 +541,7 @@ export async function runHandoff(options, { env = process.env, ghCommand = "gh"
514
541
  roundCapReached: true,
515
542
  regularCopilotRounds: (snapshot.copilotReviewRoundCount ?? 0) > 0,
516
543
  },
517
- { env, ghCommand },
544
+ { env, ghCommand, runChild },
518
545
  );
519
546
  if (significant) {
520
547
  reopenedCapCycle = true;
@@ -545,8 +572,12 @@ export async function runHandoff(options, { env = process.env, ghCommand = "gh"
545
572
  // still refuses unless the head actually advanced past the last review
546
573
  // (its hasNewCommits guard), so this cannot force an over-cap same-head request.
547
574
  forceRerequestReview: reopenedCapCycle,
575
+ // Thread the light-dispatch signal so the requester enforces the same
576
+ // composed round cap this handoff resolved (#1210) — the enforcement
577
+ // backstop must never permit rounds the interpreter already forbids.
578
+ lightweight: options.lightweight,
548
579
  },
549
- { env, ghCommand },
580
+ { env, ghCommand, runChild },
550
581
  );
551
582
  reviewRequestStatus = requestResult.status;
552
583
  snapshot = applyConfirmedReviewRequest(snapshot, reviewRequestStatus);
@@ -632,7 +663,7 @@ export async function runHandoff(options, { env = process.env, ghCommand = "gh"
632
663
  repo: options.repo,
633
664
  pr: options.pr,
634
665
  env,
635
- cwd: resolveRepoRoot(process.cwd()),
666
+ cwd: resolvedRepoRoot,
636
667
  });
637
668
  if (runnerRelease.status !== "skipped_no_async_run_id") {
638
669
  result.runnerRelease = runnerRelease;
@@ -75,7 +75,7 @@ export function parseGitDiffStat(output) {
75
75
  }
76
76
  return { filesChanged: fileCount, linesChanged: insertions + deletions };
77
77
  }
78
- function detectScope({ base, head } = {}) {
78
+ function detectScope({ base, head, cwd } = {}) {
79
79
  let diffArgs = ["diff", "--stat"];
80
80
  if (base && head) {
81
81
  diffArgs.push(`${base}..${head}`);
@@ -86,7 +86,7 @@ function detectScope({ base, head } = {}) {
86
86
  }
87
87
  let output;
88
88
  try {
89
- output = execFileSync("git", diffArgs, { encoding: "utf8", maxBuffer: 1_000_000 });
89
+ output = execFileSync("git", diffArgs, { encoding: "utf8", maxBuffer: 1_000_000, cwd: cwd || undefined });
90
90
  } catch (err) {
91
91
  return { ok: false, filesChanged: 0, linesChanged: 0, error: err instanceof Error ? err.message : String(err) };
92
92
  }
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { parseArgs } from "node:util";
4
- import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
4
+ import { parsePrNumber, requireTokenValue, runChild as defaultRunChild } from "../_cli-primitives.mjs";
5
5
  import {
6
6
  buildParseError,
7
7
  formatCliError,
@@ -14,7 +14,7 @@ import {
14
14
  } from "../_core-helpers.mjs";
15
15
  import { fetchGithubReviewThreadsPayload } from "../github/capture-review-threads.mjs";
16
16
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
17
- import { loadDevLoopConfig, resolveRefinement } from "@dev-loops/core/config";
17
+ import { loadDevLoopConfig, resolveEffectiveCopilotRoundCap, resolveRefinement } from "@dev-loops/core/config";
18
18
  import {
19
19
  buildSnapshotFromPrFacts,
20
20
  interpretLoopState,
@@ -43,8 +43,13 @@ Required (auto-detect mode):
43
43
  --pr <number> Pull request number
44
44
  Required (snapshot mode):
45
45
  --input <path> Path to snapshot JSON file
46
- Optional (auto-detect mode only):
47
- Optional (auto-detect mode only):
46
+ Optional (either mode):
47
+ --lightweight This PR is light-dispatched (#1210): compose the Copilot
48
+ round cap with localImplementation.lightMode.maxCopilotRounds
49
+ (default 1) via min(lightMode.maxCopilotRounds,
50
+ refinement.maxCopilotRounds) instead of using
51
+ refinement.maxCopilotRounds alone. refinement.maxCopilotRounds:
52
+ 0 still disables Copilot rounds even with --lightweight.
48
53
  Output (stdout, JSON):
49
54
  { "ok": true, "snapshot": {..., "copilotReviewRoundCount": N}, "state": "...", "allowedTransitions": [...], "nextAction": "...",
50
55
  "autoRerequestEligible": true|false, "sameHeadCleanConverged": true|false,
@@ -67,6 +72,7 @@ export function parseDetectCliArgs(argv) {
67
72
  inputPath: undefined,
68
73
  repo: undefined,
69
74
  pr: undefined,
75
+ lightweight: false,
70
76
  };
71
77
  const { tokens } = parseArgs({
72
78
  args: [...argv],
@@ -75,6 +81,7 @@ export function parseDetectCliArgs(argv) {
75
81
  input: { type: "string" },
76
82
  repo: { type: "string" },
77
83
  pr: { type: "string" },
84
+ lightweight: { type: "boolean" },
78
85
  ...JQ_OUTPUT_PARSE_OPTIONS,
79
86
  },
80
87
  allowPositionals: true,
@@ -104,6 +111,10 @@ export function parseDetectCliArgs(argv) {
104
111
  options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
105
112
  continue;
106
113
  }
114
+ if (token.name === "lightweight") {
115
+ options.lightweight = true;
116
+ continue;
117
+ }
107
118
  if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
108
119
  throw parseError(`Unknown argument: ${token.rawName}`);
109
120
  }
@@ -129,7 +140,7 @@ export function parseDetectCliArgs(argv) {
129
140
  }
130
141
  return options;
131
142
  }
132
- async function fetchPrView({ repo, pr }, { env, ghCommand }) {
143
+ async function fetchPrView({ repo, pr }, { env, ghCommand, runChild = defaultRunChild }) {
133
144
  const result = await runChild(
134
145
  ghCommand,
135
146
  ["pr", "view", String(pr), "--repo", repo, "--json", "headRefOid,isDraft,state,number,reviews,statusCheckRollup"],
@@ -150,7 +161,7 @@ async function fetchPrView({ repo, pr }, { env, ghCommand }) {
150
161
  }
151
162
  return payload;
152
163
  }
153
- async function fetchCopilotRequested({ repo, pr }, { env, ghCommand }) {
164
+ async function fetchCopilotRequested({ repo, pr }, { env, ghCommand, runChild = defaultRunChild }) {
154
165
  const result = await runChild(
155
166
  ghCommand,
156
167
  ["api", `repos/${repo}/pulls/${pr}/requested_reviewers`],
@@ -169,7 +180,7 @@ async function fetchCopilotRequested({ repo, pr }, { env, ghCommand }) {
169
180
  const users = Array.isArray(payload?.users) ? payload.users : [];
170
181
  return users.some((user) => isCopilotLogin(user?.login));
171
182
  }
172
- async function fetchLatestCopilotReviewRequestAt({ repo, pr }, { env, ghCommand }) {
183
+ async function fetchLatestCopilotReviewRequestAt({ repo, pr }, { env, ghCommand, runChild = defaultRunChild }) {
173
184
  const result = await runChild(
174
185
  ghCommand,
175
186
  ["api", `repos/${repo}/issues/${pr}/timeline`, "--paginate", "--jq",
@@ -201,7 +212,7 @@ function extractPrVisibleCheckNames(statusCheckRollup) {
201
212
  .filter((name) => typeof name === "string" && name.length > 0);
202
213
  }
203
214
 
204
- async function fetchCurrentHeadCiEvidence({ repo, headSha, prVisibleCheckNames }, { env, ghCommand }) {
215
+ async function fetchCurrentHeadCiEvidence({ repo, headSha, prVisibleCheckNames }, { env, ghCommand, runChild = defaultRunChild }) {
205
216
  const [checkRunsResult, statusesResult] = await Promise.all([
206
217
  runChild(
207
218
  ghCommand,
@@ -317,8 +328,8 @@ function hasSubmittedCopilotReviewOffCurrentHead(reviewSummary, currentHeadSha)
317
328
  }
318
329
 
319
330
 
320
- export async function autoDetectSnapshot({ repo, pr, reviewRequestStatusOverride, localValidationHeadSha, draftGateResetAtMs }, { env = process.env, ghCommand = "gh" } = {}) {
321
- const prData = await fetchPrView({ repo, pr }, { env, ghCommand });
331
+ export async function autoDetectSnapshot({ repo, pr, reviewRequestStatusOverride, localValidationHeadSha, draftGateResetAtMs }, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}) {
332
+ const prData = await fetchPrView({ repo, pr }, { env, ghCommand, runChild });
322
333
  if (prData === null) {
323
334
  return normalizeSnapshot({ prExists: false });
324
335
  }
@@ -344,13 +355,13 @@ export async function autoDetectSnapshot({ repo, pr, reviewRequestStatusOverride
344
355
  } else if (reviewSummary.hasPendingReviewOnCurrentHead) {
345
356
  copilotReviewRequestStatus = "requested";
346
357
  } else {
347
- const copilotRequested = await fetchCopilotRequested({ repo, pr }, { env, ghCommand });
358
+ const copilotRequested = await fetchCopilotRequested({ repo, pr }, { env, ghCommand, runChild });
348
359
  if (!copilotRequested) {
349
360
  copilotReviewRequestStatus = "none";
350
361
  } else if (!reviewSummary.hasSubmittedReviewOnCurrentHead) {
351
362
  copilotReviewRequestStatus = "requested";
352
363
  } else {
353
- const latestRequestAt = await fetchLatestCopilotReviewRequestAt({ repo, pr }, { env, ghCommand });
364
+ const latestRequestAt = await fetchLatestCopilotReviewRequestAt({ repo, pr }, { env, ghCommand, runChild });
354
365
  const latestReviewAt = reviewSummary.latestSubmittedReviewOnCurrentHeadAt;
355
366
  if (latestRequestAt !== null && latestReviewAt !== null && latestRequestAt > latestReviewAt) {
356
367
  copilotReviewRequestStatus = "requested";
@@ -365,7 +376,7 @@ export async function autoDetectSnapshot({ repo, pr, reviewRequestStatusOverride
365
376
  let actionableThreadCount = 0;
366
377
  let lastCopilotRoundMaxSignal = null;
367
378
  try {
368
- const threadsPayload = await fetchGithubReviewThreadsPayload({ repo, pr }, { env, ghCommand });
379
+ const threadsPayload = await fetchGithubReviewThreadsPayload({ repo, pr }, { env, ghCommand, runChild });
369
380
  const parsed = parseReviewThreads(threadsPayload);
370
381
  unresolvedThreadCount = parsed.summary.unresolvedThreads;
371
382
  actionableThreadCount = parsed.summary.actionableThreads;
@@ -389,7 +400,7 @@ export async function autoDetectSnapshot({ repo, pr, reviewRequestStatusOverride
389
400
  let failureDetails = [];
390
401
  let excludedFailureDetails = [];
391
402
  if (shouldRefreshCurrentHeadCi) {
392
- const refreshed = await fetchCurrentHeadCiEvidence({ repo, headSha: prHeadSha, prVisibleCheckNames }, { env, ghCommand });
403
+ const refreshed = await fetchCurrentHeadCiEvidence({ repo, headSha: prHeadSha, prVisibleCheckNames }, { env, ghCommand, runChild });
393
404
  currentHeadCiStatus = refreshed?.status ?? "none";
394
405
  failureDetails = refreshed?.failureDetails ?? [];
395
406
  excludedFailureDetails = refreshed?.excludedFailureDetails ?? [];
@@ -455,6 +466,15 @@ export async function runCli(
455
466
  const refinementConfig = config.errors.length > 0
456
467
  ? resolveRefinement({ version: 1 })
457
468
  : resolveRefinement(config.config);
469
+ if (options.lightweight) {
470
+ // Compose (not replace) the round cap for light-dispatched PRs (#1210):
471
+ // min(lightMode.maxCopilotRounds ?? 1, refinement.maxCopilotRounds), so
472
+ // maxCopilotRounds: 0 still disables Copilot rounds everywhere.
473
+ refinementConfig.maxCopilotRounds = resolveEffectiveCopilotRoundCap(
474
+ config.errors.length > 0 ? { version: 1 } : config.config,
475
+ { lightweight: true },
476
+ );
477
+ }
458
478
  interpretation = interpretLoopState(interpretationInput, refinementConfig);
459
479
  const interpretationSummary = summarizeLoopInterpretation(interpretation);
460
480
  process.exitCode = emitResult({
@@ -3,7 +3,7 @@ import { statSync, readFileSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { parse as parseYaml } from "yaml";
5
5
  import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
6
- import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
6
+ import { parsePrNumber, requireTokenValue, runChild as defaultRunChild } from "../_cli-primitives.mjs";
7
7
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
8
8
  import { parseArgs } from "node:util";
9
9
  import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
@@ -184,7 +184,7 @@ export function parseCliArgs(argv) {
184
184
  return options;
185
185
  }
186
186
 
187
- async function fetchPrFiles({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
187
+ async function fetchPrFiles({ repo, pr }, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}) {
188
188
  const result = await runChild(
189
189
  ghCommand,
190
190
  ["pr", "view", String(pr), "--repo", repo, "--json", "files", "--jq", ".files[].path"],
@@ -198,7 +198,7 @@ async function fetchPrFiles({ repo, pr }, { env = process.env, ghCommand = "gh"
198
198
  return paths;
199
199
  }
200
200
 
201
- async function fetchPrLabels({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
201
+ async function fetchPrLabels({ repo, pr }, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}) {
202
202
  const result = await runChild(
203
203
  ghCommand,
204
204
  ["pr", "view", String(pr), "--repo", repo, "--json", "labels", "--jq", ".labels[].name"],
@@ -219,10 +219,10 @@ async function fetchPrLabels({ repo, pr }, { env = process.env, ghCommand = "gh"
219
219
  * - If ANY changed file doesn't match any pattern → internalOnly=false
220
220
  * - No blacklist needed — a non-matching file is consumer-facing by definition.
221
221
  */
222
- export async function detectInternalOnly(options, { env = process.env, ghCommand = "gh" } = {}) {
222
+ export async function detectInternalOnly(options, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}) {
223
223
  const patterns = loadInternalPathPatterns(options.config);
224
224
  const matchers = buildPatternMatchers(patterns);
225
- const files = await fetchPrFiles(options, { env, ghCommand });
225
+ const files = await fetchPrFiles(options, { env, ghCommand, runChild });
226
226
 
227
227
  if (files.length === 0) {
228
228
  return {
@@ -250,7 +250,7 @@ export async function detectInternalOnly(options, { env = process.env, ghCommand
250
250
 
251
251
  // Check for explicit internal_only label if requested (confirmation only)
252
252
  if (options.labelCheck) {
253
- const labels = await fetchPrLabels(options, { env, ghCommand });
253
+ const labels = await fetchPrLabels(options, { env, ghCommand, runChild });
254
254
  if (labels.includes("internal_only")) {
255
255
  // Label confirms — path check already passed
256
256
  }