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
@@ -10,12 +10,13 @@ import {
10
10
  resolveDraftGateRoundResetMs,
11
11
  summarizeCopilotReviews,
12
12
  } from "../_core-helpers.mjs";
13
- import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
14
- import { loadDevLoopConfig, resolveGateConfig, resolveRefinement, resolveRefinementConfig, resolveWorkflowConfig } from "@dev-loops/core/config";
13
+ import { parsePrNumber, requireTokenValue, runChild as defaultRunChild } from "../_cli-primitives.mjs";
14
+ import { loadDevLoopConfig, resolveEffectiveCopilotRoundCap, resolveGateConfig, resolveRefinement, resolveRefinementConfig, resolveWorkflowConfig } from "@dev-loops/core/config";
15
15
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
16
16
  import { buildSnapshotFromPrFacts, interpretLoopState, isCopilotRoundCapReached, summarizeLoopInterpretation } from "@dev-loops/core/loop/copilot-loop-state";
17
- import { evaluatePrGateCoordination, PR_CHECKPOINT, PR_CHECKPOINT_ACTION } from "@dev-loops/core/loop/pr-gate-coordination";
17
+ import { evaluatePrGateCoordination, PR_CHECKPOINT, PR_CHECKPOINT_ACTION, REFINEMENT_ARTIFACT_SPEC_SOURCE } from "@dev-loops/core/loop/pr-gate-coordination";
18
18
  import { shouldGuardCopilotReviewRequest } from "@dev-loops/core/loop/pr-gate-coordination";
19
+ import { PLAN_FILE_PROMOTION_DOC_PATH_PATTERN } from "@dev-loops/core/loop/plan-file-promote-contract";
19
20
  import { UI_E2E_CHECK_NAMES } from "@dev-loops/core/loop/ui-e2e-scoping";
20
21
  import { fetchGithubReviewThreadsPayload } from "../github/capture-review-threads.mjs";
21
22
  import { detectPostConvergenceSignificantChange } from "./_post-convergence-change.mjs";
@@ -30,6 +31,12 @@ Required:
30
31
  --repo <owner/name> Repository slug (e.g. owner/repo)
31
32
  --pr <number> Pull request number
32
33
  Optional:
34
+ --lightweight This PR is light-dispatched (#1210): compose the Copilot
35
+ round cap with localImplementation.lightMode.maxCopilotRounds
36
+ (default 1) via min(lightMode.maxCopilotRounds,
37
+ refinement.maxCopilotRounds) instead of using
38
+ refinement.maxCopilotRounds alone. refinement.maxCopilotRounds:
39
+ 0 still disables Copilot rounds even with --lightweight.
33
40
  Output (stdout, JSON):
34
41
  {
35
42
  "ok": true,
@@ -61,11 +68,19 @@ Output (stdout, JSON):
61
68
  "headSha": null,
62
69
  "verdict": null
63
70
  },
71
+ "refinementArtifact": {
72
+ "status": "present",
73
+ "specSource": "linked_issue",
74
+ "reason": "...",
75
+ "finding": null
76
+ },
64
77
  "allowedNextActions": ["resolve_merge_conflicts"],
65
78
  "forbiddenActions": ["run_pre_approval_gate", "declare_merge_ready"],
66
79
  "nextAction": "resolve_merge_conflicts",
67
80
  "reason": "..."
68
81
  }
82
+ (refinementArtifact.planDocPath is present only when specSource is
83
+ "plan_file"; the key is omitted entirely for "linked_issue" and "pr_body")
69
84
  Error output (stderr, JSON):
70
85
  { "ok": false, "error": "...", "usage": "..." }
71
86
  { "ok": false, "error": "..." }
@@ -80,6 +95,7 @@ export function parseDetectPrGateCoordinationCliArgs(argv) {
80
95
  help: false,
81
96
  repo: undefined,
82
97
  pr: undefined,
98
+ lightweight: false,
83
99
  };
84
100
  const { tokens } = parseArgs({
85
101
  args: [...argv],
@@ -87,6 +103,7 @@ export function parseDetectPrGateCoordinationCliArgs(argv) {
87
103
  help: { type: "boolean", short: "h" },
88
104
  repo: { type: "string" },
89
105
  pr: { type: "string" },
106
+ lightweight: { type: "boolean" },
90
107
  ...JQ_OUTPUT_PARSE_OPTIONS,
91
108
  },
92
109
  allowPositionals: true,
@@ -112,6 +129,10 @@ export function parseDetectPrGateCoordinationCliArgs(argv) {
112
129
  options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
113
130
  continue;
114
131
  }
132
+ if (token.name === "lightweight") {
133
+ options.lightweight = true;
134
+ continue;
135
+ }
115
136
  if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
116
137
  throw parseError(`Unknown argument: ${token.rawName}`);
117
138
  }
@@ -155,7 +176,7 @@ export function parseGitStatusConflictFiles(text) {
155
176
  }
156
177
  return conflictFiles;
157
178
  }
158
- async function fetchRequestedReviewers({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
179
+ async function fetchRequestedReviewers({ repo, pr }, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}) {
159
180
  const result = await runChild(
160
181
  ghCommand,
161
182
  ["api", `repos/${repo}/pulls/${pr}/requested_reviewers`],
@@ -167,7 +188,7 @@ async function fetchRequestedReviewers({ repo, pr }, { env = process.env, ghComm
167
188
  }
168
189
  return parseRequestedReviewersPayload(result.stdout);
169
190
  }
170
- async function fetchPrFacts({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
191
+ async function fetchPrFacts({ repo, pr }, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}) {
171
192
  const result = await runChild(
172
193
  ghCommand,
173
194
  ["pr", "view", String(pr), "--repo", repo, "--json", "number,state,isDraft,headRefOid,mergeable,mergeStateStatus,body,title,closingIssuesReferences,reviews,statusCheckRollup,files"],
@@ -190,18 +211,19 @@ export async function fetchPrFactsWithSettledMergeable(
190
211
  {
191
212
  env = process.env,
192
213
  ghCommand = "gh",
214
+ runChild = defaultRunChild,
193
215
  maxPolls = 3,
194
216
  pollDelayMs = 1500,
195
217
  sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
196
218
  fetch = fetchPrFacts,
197
219
  } = {},
198
220
  ) {
199
- let prData = await fetch(options, { env, ghCommand });
221
+ let prData = await fetch(options, { env, ghCommand, runChild });
200
222
  let polls = 0;
201
223
  while (String(prData?.mergeable || "").toUpperCase() === "UNKNOWN" && polls < maxPolls) {
202
224
  polls += 1;
203
225
  await sleep(pollDelayMs);
204
- prData = await fetch(options, { env, ghCommand });
226
+ prData = await fetch(options, { env, ghCommand, runChild });
205
227
  }
206
228
  return prData;
207
229
  }
@@ -259,7 +281,7 @@ export function resolveLinkedIssuesFromPr(prData) {
259
281
  return dedupe(matches.map((m) => Number((/(\d+)/.exec(m) || [])[1])));
260
282
  }
261
283
 
262
- async function fetchIssueBody({ repo, issue }, { env = process.env, ghCommand = "gh" } = {}) {
284
+ async function fetchIssueBody({ repo, issue }, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}) {
263
285
  const result = await runChild(
264
286
  ghCommand,
265
287
  ["issue", "view", String(issue), "--repo", repo, "--json", "body"],
@@ -275,18 +297,107 @@ async function fetchIssueBody({ repo, issue }, { env = process.env, ghCommand =
275
297
  return null;
276
298
  }
277
299
  }
278
- export async function loadRefinementArtifact({ repo, prData, prDraft, prClosed, prMerged }, { env = process.env, ghCommand = "gh" } = {}) {
279
- const linkedIssues = resolveLinkedIssuesFromPr(prData);
280
- if (linkedIssues.length === 0) {
281
- if (prDraft) {
300
+ // Plan-file promotion PRs (P4, `buildPromotionPrBody`) carry no linked issue by
301
+ // design; the PR body instead names the committed plan doc as the spec-of-record
302
+ // verbatim (marker owned by plan-file-promote-contract.mjs). Matching that
303
+ // literal marker is how the refinement check tells a promoted plan-file PR
304
+ // apart from an ordinary issue-less body.
305
+
306
+ // Resolve the refinement artifact for a draft PR with zero linked issues,
307
+ // directly from its own body. The sanctioned three-origin model
308
+ // (artifact-authority-contract.md) backs a draft PR with a linked issue, a
309
+ // promoted plan file, or the PR body itself as the spec-of-record; only the
310
+ // first fetches an issue body, so this never needs a network call.
311
+ // `specSource` here (linked_issue|pr_body|plan_file) is a distinct enum from
312
+ // handoff-envelope.mjs's CANONICAL_SPEC_SOURCE (phase_doc|pr_body): same field
313
+ // name, different object, different value space — never conflate the two.
314
+ async function resolveNoIssueRefinementArtifact(body) {
315
+ const { detectIssueRefinementArtifact, validatePrBodySpec } = await import("@dev-loops/core/loop/issue-refinement-artifact");
316
+ const planDocMatch = PLAN_FILE_PROMOTION_DOC_PATH_PATTERN.exec(body);
317
+ if (planDocMatch) {
318
+ const planDocPath = planDocMatch[1].trim();
319
+ // The marker sentence alone is spoofable (any body can paste it): require the
320
+ // body to still carry real AC/DoD checklist content, the same invariant
321
+ // `buildPromotionPrBody` actually embeds, before trusting the plan-file claim.
322
+ // Deliberately narrower than `artifact.hasACs`: that flag also turns true on a
323
+ // bare linked-refinement-doc mention (no checklist items at all), which is the
324
+ // right OR for a linked ISSUE body but not for a plan-file promotion PR body —
325
+ // a plan-file claim must be backed by actual AC/DoD checklist items, not just
326
+ // another doc mention next to the marker.
327
+ // Plan-file promotion is issue-less by design (`buildPromotionPrBody`
328
+ // neutralizes closing keywords): any closing reference the body carries —
329
+ // including the cross-repo `owner/repo#N` form validatePrBodySpec also
330
+ // accepts — means the body would auto-close an issue it doesn't actually
331
+ // back. Reuse validatePrBodySpec's closing-reference extraction
332
+ // (issueLess mode's `closesIssues`) instead of a second regex.
333
+ const closesIssues = validatePrBodySpec({ body, issueLess: true }).closesIssues;
334
+ if (closesIssues.length > 0) {
282
335
  return {
283
336
  status: "missing",
284
337
  linkedIssue: null,
285
338
  linkedIssues: [],
286
- reason: "Draft PR has no deterministically resolvable linked issue (no closingIssuesReferences and no Closes/Fixes/Resolves #n reference in body); draft gate cannot verify a refinement artifact.",
339
+ specSource: REFINEMENT_ARTIFACT_SPEC_SOURCE.PLAN_FILE,
340
+ planDocPath,
341
+ reason: `PR body names the promoted plan doc \`${planDocPath}\` as the spec-of-record but also carries a GitHub closing reference (${closesIssues.map((n) => `#${n}`).join(", ")}); plan-file promotion is issue-less by design and must not carry a reference that would auto-close an issue it doesn't back.`,
287
342
  finding: "missing_refinement_artifact",
288
343
  };
289
344
  }
345
+ const artifact = detectIssueRefinementArtifact({ body });
346
+ if (artifact.acItems.length > 0 || artifact.dodItems.length > 0) {
347
+ return {
348
+ status: "present",
349
+ linkedIssue: null,
350
+ linkedIssues: [],
351
+ specSource: REFINEMENT_ARTIFACT_SPEC_SOURCE.PLAN_FILE,
352
+ planDocPath,
353
+ acItems: artifact.acItems,
354
+ dodItems: artifact.dodItems,
355
+ sections: artifact.sections,
356
+ reason: `Refinement artifact present via the promoted plan doc \`${planDocPath}\` the PR body carries as the spec-of-record (plan-file promotion; no linked issue required).`,
357
+ finding: null,
358
+ };
359
+ }
360
+ return {
361
+ status: "missing",
362
+ linkedIssue: null,
363
+ linkedIssues: [],
364
+ specSource: REFINEMENT_ARTIFACT_SPEC_SOURCE.PLAN_FILE,
365
+ planDocPath,
366
+ reason: `PR body names the promoted plan doc \`${planDocPath}\` as the spec-of-record but carries no Acceptance criteria / DoD checklist content (${artifact.reason}); a bare marker sentence cannot satisfy the refinement check.`,
367
+ finding: "missing_refinement_artifact",
368
+ };
369
+ }
370
+ const specResult = validatePrBodySpec({ body, issueLess: true });
371
+ if (specResult.ok) {
372
+ return {
373
+ status: "present",
374
+ linkedIssue: null,
375
+ linkedIssues: [],
376
+ specSource: REFINEMENT_ARTIFACT_SPEC_SOURCE.PR_BODY,
377
+ acItems: specResult.acItems,
378
+ dodItems: specResult.dodItems,
379
+ sections: specResult.sections,
380
+ reason: "Refinement artifact present via the PR body itself (issue-less lightweight PR-body-as-spec; validate-pr-body-spec --no-issue clean).",
381
+ finding: null,
382
+ };
383
+ }
384
+ return {
385
+ status: "missing",
386
+ linkedIssue: null,
387
+ linkedIssues: [],
388
+ specSource: REFINEMENT_ARTIFACT_SPEC_SOURCE.PR_BODY,
389
+ reason: `PR body fails the issue-less lightweight spec-of-record validation (validate-pr-body-spec --no-issue: ${specResult.errors.map((e) => e.code).join(", ")}).`,
390
+ finding: "missing_refinement_artifact",
391
+ };
392
+ }
393
+
394
+ export async function loadRefinementArtifact({ repo, prData, prDraft, prClosed, prMerged }, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}) {
395
+ const linkedIssues = resolveLinkedIssuesFromPr(prData);
396
+ if (linkedIssues.length === 0) {
397
+ if (prDraft) {
398
+ const body = typeof prData?.body === "string" ? prData.body : "";
399
+ return resolveNoIssueRefinementArtifact(body);
400
+ }
290
401
  return {
291
402
  status: "unknown",
292
403
  linkedIssue: null,
@@ -308,7 +419,7 @@ export async function loadRefinementArtifact({ repo, prData, prDraft, prClosed,
308
419
  // is refined if AT LEAST ONE linked issue carries a refinement artifact.
309
420
  const evaluated = [];
310
421
  for (const issue of linkedIssues) {
311
- const body = await fetchIssueBody({ repo, issue }, { env, ghCommand });
422
+ const body = await fetchIssueBody({ repo, issue }, { env, ghCommand, runChild });
312
423
  if (body === null) {
313
424
  evaluated.push({ issue, artifact: null });
314
425
  continue;
@@ -327,6 +438,7 @@ export async function loadRefinementArtifact({ repo, prData, prDraft, prClosed,
327
438
  status: "present",
328
439
  linkedIssue: firstPresent.issue,
329
440
  linkedIssues,
441
+ specSource: REFINEMENT_ARTIFACT_SPEC_SOURCE.LINKED_ISSUE,
330
442
  refinedIssues,
331
443
  source: a.source,
332
444
  acItems: a.acItems,
@@ -354,6 +466,7 @@ export async function loadRefinementArtifact({ repo, prData, prDraft, prClosed,
354
466
  status: "missing",
355
467
  linkedIssue: firstEvaluated.issue,
356
468
  linkedIssues,
469
+ specSource: REFINEMENT_ARTIFACT_SPEC_SOURCE.LINKED_ISSUE,
357
470
  refinedIssues,
358
471
  reason: `Failed to fetch body for linked issue(s) ${scopeLabel}; draft gate cannot verify a refinement artifact, treating as missing.`,
359
472
  finding: "missing_refinement_artifact",
@@ -377,6 +490,7 @@ export async function loadRefinementArtifact({ repo, prData, prDraft, prClosed,
377
490
  status: "missing",
378
491
  linkedIssue: firstFetched.issue,
379
492
  linkedIssues,
493
+ specSource: REFINEMENT_ARTIFACT_SPEC_SOURCE.LINKED_ISSUE,
380
494
  refinedIssues,
381
495
  source: first.source,
382
496
  acItems: first.acItems,
@@ -390,7 +504,7 @@ export async function loadRefinementArtifact({ repo, prData, prDraft, prClosed,
390
504
  _onlyEnforcedWhenDraft: prDraft === true,
391
505
  };
392
506
  }
393
- async function fetchLocalConflictFiles({ env = process.env, gitCommand = "git" } = {}) {
507
+ async function fetchLocalConflictFiles({ env = process.env, gitCommand = "git", runChild = defaultRunChild } = {}) {
394
508
  let result;
395
509
  try {
396
510
  result = await runChild(
@@ -458,9 +572,20 @@ export async function loadPrGateCoordinationContext(options, runtime = {}) {
458
572
  // detect-copilot-loop-state path and with request-copilot-review's cap logic.
459
573
  const interpreterRepoRoot = runtime.repoRoot ?? resolveRepoRoot(process.cwd());
460
574
  const interpreterConfigResult = await loadDevLoopConfig({ repoRoot: interpreterRepoRoot });
461
- const interpreterRefinementConfig = (Array.isArray(interpreterConfigResult.errors) && interpreterConfigResult.errors.length > 0)
575
+ const interpreterConfigHasErrors = Array.isArray(interpreterConfigResult.errors) && interpreterConfigResult.errors.length > 0;
576
+ const interpreterRefinementConfig = interpreterConfigHasErrors
462
577
  ? resolveRefinement({ version: 1 })
463
578
  : resolveRefinement(interpreterConfigResult.config ?? { version: 1 });
579
+ if (options.lightweight) {
580
+ // Compose (not replace) the round cap for light-dispatched PRs (#1210):
581
+ // min(lightMode.maxCopilotRounds ?? 1, refinement.maxCopilotRounds), so
582
+ // maxCopilotRounds: 0 still disables Copilot rounds everywhere. Shared with
583
+ // the maxCopilotRounds resolution below (#1126 requires the two to agree).
584
+ interpreterRefinementConfig.maxCopilotRounds = resolveEffectiveCopilotRoundCap(
585
+ interpreterConfigHasErrors ? { version: 1 } : (interpreterConfigResult.config ?? { version: 1 }),
586
+ { lightweight: true },
587
+ );
588
+ }
464
589
  const interpretation = interpretLoopState(snapshot, interpreterRefinementConfig);
465
590
  const disposition = summarizeLoopInterpretation(interpretation, interpreterRefinementConfig);
466
591
  const mergeStateStatus = typeof prData?.mergeStateStatus === "string" && prData.mergeStateStatus.trim().length > 0
@@ -493,7 +618,7 @@ export async function loadPrGateCoordinationContext(options, runtime = {}) {
493
618
  };
494
619
  }
495
620
 
496
- async function fetchCopilotEverFormallyRequested({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
621
+ async function fetchCopilotEverFormallyRequested({ repo, pr }, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}) {
497
622
  const result = await runChild(
498
623
  ghCommand,
499
624
  ["api", `repos/${repo}/issues/${pr}/timeline`, "--paginate", "--jq",
@@ -515,7 +640,12 @@ export async function detectPrGateCoordinationState(options, runtime = {}) {
515
640
  const hasConfigErrors = Array.isArray(configLoadResult.errors) && configLoadResult.errors.length > 0;
516
641
  const config = hasConfigErrors ? {} : (configLoadResult.config ?? {});
517
642
  const draftGateConfig = resolveGateConfig(config, "draft");
518
- const maxCopilotRounds = resolveRefinementConfig(config, "maxCopilotRounds");
643
+ // Shared with interpreterRefinementConfig.maxCopilotRounds in
644
+ // loadPrGateCoordinationContext (#1126: the two must never disagree at the
645
+ // cap boundary) — the same lightweight composition (#1210) is applied here.
646
+ const maxCopilotRounds = options.lightweight
647
+ ? resolveEffectiveCopilotRoundCap(config, { lightweight: true })
648
+ : resolveRefinementConfig(config, "maxCopilotRounds");
519
649
  // Shared with interpretLoopState (consumed by copilot-pr-handoff.mjs) and
520
650
  // evaluatePrGateCoordination — the single source of truth for "is the
521
651
  // Copilot round cap reached" so this detector cannot disagree with the
@@ -556,6 +686,10 @@ export async function detectPrGateCoordinationState(options, runtime = {}) {
556
686
  copilotReviewRoundCount: context.snapshot?.copilotReviewRoundCount ?? 0,
557
687
  maxCopilotRounds,
558
688
  sameHeadCleanConverged: context.interpretation.sameHeadCleanConverged,
689
+ // Independent gate-ENTRY re-check (#1190): fed alongside (not derived from)
690
+ // sameHeadCleanConverged, so an outstanding request on the current head refuses
691
+ // RUN_PRE_APPROVAL_GATE even if sameHeadCleanConverged were somehow stale/wrong.
692
+ copilotReviewRequestStatus: context.snapshot?.copilotReviewRequestStatus ?? "none",
559
693
  draftGateRequireCi: draftGateConfig.requireCi,
560
694
  draftGate: context.gateEvidence.draftGate,
561
695
  draftGateMarker: context.gateEvidence.draftGateMarker,
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env node
2
+ import { readFile } from "node:fs/promises";
3
+ import { parseArgs } from "node:util";
4
+ import { requireTokenValue } from "../_cli-primitives.mjs";
5
+ import { formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
6
+ import {
7
+ interpretRefinementGrillState,
8
+ normalizeGrillSnapshot,
9
+ } from "@dev-loops/core/loop/refinement-grill-state";
10
+ import { detectIssueRefinementArtifact } from "@dev-loops/core/loop/issue-refinement-artifact";
11
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
12
+
13
+ const HELP = `Usage: detect-refinement-grill-state.mjs [--input <path> | --body-file <path> [--surface issue|pr|plan]]
14
+ Detect refinement/grill sub-loop state.
15
+ Modes (choose exactly one):
16
+ --input <path> Interpret a JSON grill snapshot from file
17
+ --body-file <path> Seed the deterministic already-refined / zero-iteration
18
+ snapshot from a markdown body on disk
19
+ Options (body-file mode only):
20
+ --surface issue|pr|plan Surface the body belongs to (default: issue)
21
+
22
+ ${JQ_OUTPUT_USAGE}
23
+
24
+ Exit codes:
25
+ 0 Success
26
+ 1 Error
27
+ 2 Invalid --jq filter
28
+ `;
29
+
30
+ const VALID_SURFACES = new Set(["issue", "pr", "plan"]);
31
+
32
+ export function parseDetectGrillCliArgs(argv) {
33
+ const { tokens } = parseArgs({
34
+ args: [...argv],
35
+ options: {
36
+ help: { type: "boolean", short: "h" },
37
+ input: { type: "string" },
38
+ "body-file": { type: "string" },
39
+ surface: { type: "string" },
40
+ ...JQ_OUTPUT_PARSE_OPTIONS,
41
+ },
42
+ allowPositionals: true,
43
+ strict: false,
44
+ tokens: true,
45
+ });
46
+ const options = {
47
+ inputPath: undefined,
48
+ bodyFilePath: undefined,
49
+ surface: undefined,
50
+ help: false,
51
+ };
52
+ for (const token of tokens) {
53
+ if (token.kind === "positional") {
54
+ throw new Error(`Unknown argument: ${token.value}`);
55
+ }
56
+ if (token.kind !== "option") {
57
+ continue;
58
+ }
59
+ if (token.name === "help") {
60
+ options.help = true;
61
+ return options;
62
+ }
63
+ if (token.name === "input") {
64
+ options.inputPath = requireTokenValue(token);
65
+ continue;
66
+ }
67
+ if (token.name === "body-file") {
68
+ options.bodyFilePath = requireTokenValue(token);
69
+ continue;
70
+ }
71
+ if (token.name === "surface") {
72
+ const surface = requireTokenValue(token).trim();
73
+ if (!VALID_SURFACES.has(surface)) {
74
+ throw new Error("--surface must be one of: issue, pr, plan");
75
+ }
76
+ options.surface = surface;
77
+ continue;
78
+ }
79
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t))) continue;
80
+ throw new Error(`Unknown argument: ${token.rawName}`);
81
+ }
82
+ const hasInput = options.inputPath !== undefined;
83
+ const hasBodyFile = options.bodyFilePath !== undefined;
84
+ if (hasInput === hasBodyFile) {
85
+ throw new Error("Provide exactly one input source: --input <path> or --body-file <path>");
86
+ }
87
+ if (hasInput && options.surface !== undefined) {
88
+ throw new Error("--surface applies only to --body-file mode");
89
+ }
90
+ return options;
91
+ }
92
+
93
+ export async function runCli(
94
+ argv = process.argv.slice(2),
95
+ { stdout = process.stdout, stderr = process.stderr } = {},
96
+ ) {
97
+ const options = parseDetectGrillCliArgs(argv);
98
+ if (options.help) { stdout.write(HELP); return; }
99
+
100
+ let snapshot;
101
+ if (options.inputPath) {
102
+ const text = await readFile(options.inputPath, "utf8");
103
+ snapshot = normalizeGrillSnapshot(parseJsonText(text));
104
+ } else {
105
+ const body = await readFile(options.bodyFilePath, "utf8");
106
+ const surface = options.surface ?? "issue";
107
+ // Deterministic seed for the already-refined / zero-iteration path only: the full
108
+ // semantic gap detection (scope/actor/decision) is the agent-layer bounded input consumed
109
+ // at await_answers. This body-file mode computes ONLY the deterministic AC-presence signal
110
+ // via the single is-it-refined source of truth, detectIssueRefinementArtifact.
111
+ const artifact = detectIssueRefinementArtifact({ body });
112
+ snapshot = normalizeGrillSnapshot({
113
+ loaded: true,
114
+ detectRan: true,
115
+ surface,
116
+ openGapCount: artifact.finding ? 1 : 0,
117
+ unresolvedGapCount: 0,
118
+ });
119
+ }
120
+
121
+ const interpretation = interpretRefinementGrillState(snapshot);
122
+ process.exitCode = emitResult(
123
+ {
124
+ ok: true,
125
+ snapshot,
126
+ state: interpretation.state,
127
+ allowedTransitions: interpretation.allowedTransitions,
128
+ nextAction: interpretation.nextAction,
129
+ },
130
+ { jq: options.jq, silent: options.silent, stdout, stderr },
131
+ );
132
+ }
133
+
134
+ if (isDirectCliRun(import.meta.url)) {
135
+ runCli().catch((error) => { process.stderr.write(`${formatCliError(error)}\n`); process.exitCode = 1; });
136
+ }
@@ -21,7 +21,8 @@ import {
21
21
  } from "@dev-loops/core/loop/async-start-contract";
22
22
  import { detectRepoSlug } from "@dev-loops/core/github/repo-slug";
23
23
  import { isCopilotLogin } from "@dev-loops/core/github/copilot-helpers";
24
- import { loadDevLoopConfig, resolveWorkflowConfig } from "@dev-loops/core/config";
24
+ import { loadDevLoopConfig, resolveLightMode, resolveWorkflowConfig } from "@dev-loops/core/config";
25
+ import { detectScope } from "./detect-change-scope.mjs";
25
26
  import { createPiAdapter } from "@dev-loops/core/harness";
26
27
  import { validatePlanFile } from "../refine/validate-plan-file.mjs";
27
28
  import {
@@ -65,6 +66,14 @@ Optional modifier:
65
66
  heuristic (chore/fix commit type + no --plan-file + small change)
66
67
  is a documented manual signal; --lightweight is the explicit,
67
68
  deterministic trigger.
69
+ Used ALONE (no --issue/--pr/--input/--plan-file/--spike):
70
+ issue-less PR-first (#1210) — no tracker binding at all
71
+ (canonicalSpecSource: pr_body, no issue-keyed worktree
72
+ requirement). Gated on localImplementation.lightMode being
73
+ enabled AND the live change scope (git diff) staying within
74
+ its maxFiles/maxLines threshold; fails closed with a distinct
75
+ reason (light mode disabled / scope undetectable / over
76
+ threshold) requiring --issue above the threshold.
68
77
  ${JQ_OUTPUT_USAGE}
69
78
 
70
79
  Exit codes:
@@ -200,17 +209,23 @@ export function parseResolveDevLoopStartupCliArgs(argv) {
200
209
  if (modeCount > 1) {
201
210
  throw parseError("--issue, --pr, --input, --plan-file, and --spike are mutually exclusive; provide exactly one");
202
211
  }
203
- if (modeCount === 0) {
204
- throw parseError("--input <path>, --issue <n>, --pr <n>, --plan-file <path>, or --spike <path> is required");
212
+ // --lightweight is normally a MODIFIER (not a 6th mode): it makes the PR body
213
+ // the spec-of-record for the --issue local path. Used ALONE (modeCount === 0,
214
+ // issue #1210) it is instead the issue-less PR-first trigger — no tracker
215
+ // binding at all — so the "no mode selected" error is skipped in that case.
216
+ if (modeCount === 0 && !options.lightweight) {
217
+ throw parseError("--input <path>, --issue <n>, --pr <n>, --plan-file <path>, --spike <path>, or --lightweight (issue-less PR-first) is required");
205
218
  }
206
- // --lightweight is a MODIFIER (not a 6th mode): it makes the PR body the
207
- // spec-of-record for the --issue local path. It is the opposite of --plan-file
208
- // (which commits a durable plan doc as the spec) and only composes with --issue.
219
+ // --lightweight is the opposite of --plan-file (which commits a durable plan
220
+ // doc as the spec) regardless of mode.
209
221
  if (options.lightweight && options.planFile !== undefined) {
210
222
  throw parseError("--lightweight and --plan-file are opposites: --plan-file commits a durable plan doc as the spec-of-record, --lightweight makes the PR body the spec. Provide only one.");
211
223
  }
212
- if (options.lightweight && options.issue === undefined) {
213
- throw parseError("--lightweight is a modifier for the --issue path (the PR body becomes the spec-of-record). Combine it with --issue <n>.");
224
+ // When another mode IS selected, --lightweight only composes with --issue
225
+ // (not --pr/--input/--spike). Used with no mode selected at all, it is the
226
+ // issue-less PR-first trigger handled above.
227
+ if (options.lightweight && modeCount > 0 && options.issue === undefined) {
228
+ throw parseError("--lightweight is a modifier for the --issue path (the PR body becomes the spec-of-record). Combine it with --issue <n>, or use --lightweight alone (no other mode flag) for the issue-less PR-first path.");
214
229
  }
215
230
  return options;
216
231
  }
@@ -545,6 +560,120 @@ export function buildSpikeInput({ spikeFilePath }) {
545
560
  },
546
561
  };
547
562
  }
563
+ // Candidate default-branch refs for issue-less scope measurement, in preference
564
+ // order. origin/HEAD tracks the remote default branch when set; the rest cover
565
+ // the common names.
566
+ const ISSUELESS_BASE_REF_CANDIDATES = ["origin/HEAD", "origin/main", "origin/master", "main", "master"];
567
+
568
+ function resolveIssuelessMergeBase(cwd) {
569
+ for (const ref of ISSUELESS_BASE_REF_CANDIDATES) {
570
+ try {
571
+ return execFileSync("git", ["merge-base", ref, "HEAD"], {
572
+ cwd,
573
+ encoding: "utf8",
574
+ stdio: ["ignore", "pipe", "pipe"],
575
+ }).trim();
576
+ } catch {
577
+ // try the next candidate
578
+ }
579
+ }
580
+ return null;
581
+ }
582
+
583
+ /**
584
+ * Decide whether the live change scope is eligible for issue-less lightweight
585
+ * PR-first (#1210): reuses the same localImplementation.lightMode threshold
586
+ * that gates inline vs full-fanout gate dispatch, so "genuinely small" means
587
+ * the same thing everywhere in the repo.
588
+ *
589
+ * Scope is measured from the merge-base with the default branch to the WORKING
590
+ * TREE (`git diff --stat <merge-base>`), so multi-commit branches and
591
+ * uncommitted changes are both counted — a HEAD~1-only measure would fail OPEN
592
+ * on a branch whose earlier commits already exceed the threshold. Fails CLOSED
593
+ * on every negative path (disabled / no resolvable base / undetectable diff /
594
+ * over threshold) with a distinct reason so the caller can report why --issue
595
+ * is required instead of silently defaulting one way or the other.
596
+ *
597
+ * Every git invocation is bound to `cwd` (the adapter-resolved repoRoot),
598
+ * matching the rest of this file's git calls — process.cwd() is never relied
599
+ * on implicitly so this stays correct when invoked from a subdirectory or
600
+ * with a harness cwd that differs from the OS process cwd.
601
+ *
602
+ * @param {import("@dev-loops/core/config").DevLoopConfig} config
603
+ * @param {string} [cwd] - Repo root to run git commands in (defaults to process.cwd() when omitted).
604
+ * @returns {{ eligible: true, scope: object, threshold: {maxFiles:number,maxLines:number} } | { eligible: false, reason: "light_mode_disabled"|"scope_detection_failed"|"over_threshold", scope?: object, threshold?: object, detail?: string }}
605
+ */
606
+ export function resolveIssuelessLightweightEligibility(config, cwd) {
607
+ const threshold = resolveLightMode(config);
608
+ if (!threshold) {
609
+ return { eligible: false, reason: "light_mode_disabled" };
610
+ }
611
+ const mergeBase = resolveIssuelessMergeBase(cwd);
612
+ if (mergeBase === null) {
613
+ return {
614
+ eligible: false,
615
+ reason: "scope_detection_failed",
616
+ detail: `no merge-base with any default-branch candidate (${ISSUELESS_BASE_REF_CANDIDATES.join(", ")})`,
617
+ };
618
+ }
619
+ // detectScope with only `base` diffs base..working-tree in one measure
620
+ // (committed branch delta + uncommitted changes).
621
+ const scope = detectScope({ base: mergeBase, cwd });
622
+ if (scope.ok === false) {
623
+ return { eligible: false, reason: "scope_detection_failed", detail: scope.error };
624
+ }
625
+ if (scope.filesChanged > threshold.maxFiles || scope.linesChanged > threshold.maxLines) {
626
+ return { eligible: false, reason: "over_threshold", scope, threshold };
627
+ }
628
+ return { eligible: true, scope, threshold };
629
+ }
630
+
631
+ /**
632
+ * Build a `--lightweight` startup input with NO tracker binding at all
633
+ * (issue-less PR-first, #1210): `--lightweight` used alone, no --issue.
634
+ *
635
+ * Read-only: no tracker mutation, no GitHub calls, no issue/PR number. Gated
636
+ * by {@link resolveIssuelessLightweightEligibility} — an ineligible change
637
+ * throws so the CLI fails closed (exit 1, no readiness bundle) with a message
638
+ * naming the distinct reason, mirroring buildPlanFileInput/buildSpikeInput's
639
+ * fail-closed-on-invalid-input convention. Exempt from the worktree-isolation
640
+ * guard like the plan-file/spike paths: there is no issue number to key a
641
+ * worktree on.
642
+ *
643
+ * @param {{ config: import("@dev-loops/core/config").DevLoopConfig, cwd?: string }} params
644
+ * @returns {object} startup input with canonicalSpecSource: "pr_body"
645
+ */
646
+ export function buildLightweightIssuelessInput({ config, cwd }) {
647
+ const eligibility = resolveIssuelessLightweightEligibility(config, cwd);
648
+ if (!eligibility.eligible) {
649
+ if (eligibility.reason === "light_mode_disabled") {
650
+ throw new Error("--lightweight without --issue (issue-less PR-first) requires localImplementation.lightMode.enabled in .devloops; enable light mode or provide --issue <n>.");
651
+ }
652
+ if (eligibility.reason === "scope_detection_failed") {
653
+ throw new Error(`--lightweight without --issue (issue-less PR-first) requires a measurable change scope; git diff failed (${eligibility.detail}). Provide --issue <n> instead.`);
654
+ }
655
+ throw new Error(`--lightweight without --issue (issue-less PR-first) requires the change to stay within the light-mode threshold (maxFiles=${eligibility.threshold.maxFiles}, maxLines=${eligibility.threshold.maxLines}); this change is ${eligibility.scope.filesChanged} files / ${eligibility.scope.linesChanged} lines. Provide --issue <n> for above-threshold changes.`);
656
+ }
657
+ return {
658
+ intent: "start_issue_locally",
659
+ mode: "bounded_handoff",
660
+ targetPreference: "prefer_local",
661
+ artifactState: "not_applicable",
662
+ issueLinkageResolution: "not_applicable",
663
+ issueReadiness: "not_applicable",
664
+ issueAssignmentState: "not_applicable",
665
+ loopState: "implementation_pending",
666
+ canonicalSpecSource: "pr_body",
667
+ planFileExempt: true,
668
+ currentState: {
669
+ target: { kind: "local_phase", issue: null, pr: null, linkedPr: null, branch: null, phase: "lightweight-issueless" },
670
+ ownership: "local",
671
+ nextActor: "local",
672
+ status: "active",
673
+ authorization: "authorized",
674
+ },
675
+ };
676
+ }
548
677
  export function summarizeCanonicalState(bundle) {
549
678
  return {
550
679
  target: bundle.canonicalState?.target ?? null,
@@ -746,6 +875,14 @@ export async function runCli(argv = process.argv.slice(2), { stdout = process.st
746
875
  if (options.lightweight) {
747
876
  input = { ...input, canonicalSpecSource: "pr_body" };
748
877
  }
878
+ } else if (options.lightweight) {
879
+ // --lightweight used ALONE (no other mode flag): issue-less PR-first (#1210).
880
+ // A broken config must surface as ITS OWN failure, not decay to the
881
+ // misleading light_mode_disabled reason a bare {version:1} would produce.
882
+ if (configErrors.length > 0) {
883
+ throw new Error(`--lightweight without --issue (issue-less PR-first) requires a loadable dev-loop config, but config loading failed: ${configErrors.map((e) => e?.message ?? String(e)).join("; ")}. Fix the config or provide --issue <n>.`);
884
+ }
885
+ input = buildLightweightIssuelessInput({ config: devLoopConfig, cwd: repoRoot });
749
886
  } else {
750
887
  input = buildAutoResolvedInput({
751
888
  pr: options.pr,