dev-loops 0.7.2 → 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 (71) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +1 -1
  3. package/.claude/agents/refiner.md +2 -2
  4. package/.claude/agents/review.md +9 -12
  5. package/.claude/commands/loop-continue.md +2 -1
  6. package/.claude/commands/loop-enqueue.md +8 -1
  7. package/.claude/commands/loop-info.md +1 -1
  8. package/.claude/hooks/_bash-command-classify.mjs +7 -6
  9. package/.claude/hooks/_hook-decisions.mjs +5 -4
  10. package/.claude/skills/copilot-pr-followup/SKILL.md +5 -5
  11. package/.claude/skills/dev-loop/SKILL.md +7 -7
  12. package/.claude/skills/docs/acceptance-criteria-verification.md +12 -4
  13. package/.claude/skills/docs/anti-patterns.md +3 -3
  14. package/.claude/skills/docs/artifact-authority-contract.md +6 -4
  15. package/.claude/skills/docs/confirmation-rules.md +1 -1
  16. package/.claude/skills/docs/copilot-loop-operations.md +4 -2
  17. package/.claude/skills/docs/issue-intake-procedure.md +12 -3
  18. package/.claude/skills/docs/merge-preconditions.md +5 -3
  19. package/.claude/skills/docs/pr-lifecycle-contract.md +1 -1
  20. package/.claude/skills/docs/public-dev-loop-contract.md +2 -1
  21. package/.claude/skills/docs/retrospective-checkpoint-contract.md +9 -6
  22. package/.claude/skills/docs/validation-policy.md +1 -1
  23. package/.claude/skills/local-implementation/SKILL.md +3 -3
  24. package/.claude/skills/loop-grill/SKILL.md +34 -14
  25. package/AGENTS.md +1 -1
  26. package/CHANGELOG.md +30 -0
  27. package/README.md +95 -189
  28. package/agents/refiner.agent.md +2 -2
  29. package/agents/review.agent.md +9 -12
  30. package/extension/README.md +5 -4
  31. package/package.json +5 -4
  32. package/scripts/docs/validate-state-machine-conformance.mjs +78 -1
  33. package/scripts/github/_gate-names.mjs +5 -0
  34. package/scripts/github/capture-review-threads.mjs +2 -2
  35. package/scripts/github/detect-checkpoint-evidence.mjs +7 -7
  36. package/scripts/github/edit-issue.mjs +259 -0
  37. package/scripts/github/probe-ci-status.mjs +18 -0
  38. package/scripts/github/probe-copilot-review.mjs +24 -3
  39. package/scripts/github/reconcile-draft-gate.mjs +13 -13
  40. package/scripts/github/request-copilot-review.mjs +17 -16
  41. package/scripts/github/upsert-checkpoint-verdict.mjs +25 -23
  42. package/scripts/github/verify-briefing-prefixes.mjs +224 -33
  43. package/scripts/github/write-gate-context.mjs +8 -4
  44. package/scripts/loop/_post-convergence-change.mjs +2 -2
  45. package/scripts/loop/_pr-runner-coordination.mjs +112 -13
  46. package/scripts/loop/check-retro-tooling.mjs +14 -9
  47. package/scripts/loop/copilot-pr-handoff.mjs +18 -14
  48. package/scripts/loop/detect-copilot-loop-state.mjs +11 -11
  49. package/scripts/loop/detect-internal-only-pr.mjs +6 -6
  50. package/scripts/loop/detect-pr-gate-coordination-state.mjs +117 -15
  51. package/scripts/loop/detect-refinement-grill-state.mjs +136 -0
  52. package/scripts/loop/run-watch-cycle.mjs +42 -7
  53. package/scripts/loop/sanctioned-commands.mjs +1 -0
  54. package/scripts/pages/build-state-atlas.mjs +15 -0
  55. package/scripts/projects/add-queue-item.mjs +87 -4
  56. package/skills/copilot-pr-followup/SKILL.md +5 -5
  57. package/skills/dev-loop/SKILL.md +2 -2
  58. package/skills/docs/acceptance-criteria-verification.md +12 -4
  59. package/skills/docs/anti-patterns.md +3 -3
  60. package/skills/docs/artifact-authority-contract.md +6 -4
  61. package/skills/docs/confirmation-rules.md +1 -1
  62. package/skills/docs/copilot-loop-operations.md +4 -2
  63. package/skills/docs/issue-intake-procedure.md +12 -3
  64. package/skills/docs/merge-preconditions.md +5 -3
  65. package/skills/docs/pr-lifecycle-contract.md +1 -1
  66. package/skills/docs/public-dev-loop-contract.md +2 -1
  67. package/skills/docs/required-rules.json +17 -1
  68. package/skills/docs/retrospective-checkpoint-contract.md +9 -6
  69. package/skills/docs/validation-policy.md +1 -1
  70. package/skills/local-implementation/SKILL.md +3 -3
  71. package/skills/loop-grill/SKILL.md +38 -17
@@ -1,6 +1,6 @@
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";
@@ -227,7 +227,7 @@ export function parseHandoffCliArgs(argv, { cwd = process.cwd() } = {}) {
227
227
  * loop should pause for operator review.
228
228
  * Returns { paused: true, humanComments: [...] } when human comments need attention.
229
229
  */
230
- 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 } = {}) {
231
231
  try {
232
232
  const result = await runChild(
233
233
  ghCommand,
@@ -312,7 +312,7 @@ export async function detectRecentHumanComments({ repo, pr, claimedAtMs }, { env
312
312
  // (#1103, #1126): the current head, the Copilot reviews (to find the last
313
313
  // reviewed head), and the PR's changed files. Fetched only when the interpreter
314
314
  // already resolved ROUND_CAP_CLEAN_FALLBACK, so this extra call is off the hot path.
315
- async function fetchReopenCycleFacts({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
315
+ async function fetchReopenCycleFacts({ repo, pr }, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}) {
316
316
  const result = await runChild(
317
317
  ghCommand,
318
318
  ["pr", "view", String(pr), "--repo", repo, "--json", "headRefOid,reviews,files"],
@@ -328,12 +328,16 @@ async function fetchReopenCycleFacts({ repo, pr }, { env = process.env, ghComman
328
328
  }
329
329
  }
330
330
 
331
- 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());
332
336
  const runnerOwnership = await ensureAsyncRunnerOwnership({
333
337
  repo: options.repo,
334
338
  pr: options.pr,
335
339
  env,
336
- cwd: resolveRepoRoot(process.cwd()),
340
+ cwd: resolvedRepoRoot,
337
341
  claimIfMissing: true,
338
342
  });
339
343
  if (!runnerOwnership.ok) {
@@ -363,9 +367,9 @@ export async function runHandoff(options, { env = process.env, ghCommand = "gh"
363
367
  }
364
368
  let snapshot = await autoDetectSnapshot(
365
369
  { repo: options.repo, pr: options.pr },
366
- { env, ghCommand },
370
+ { env, ghCommand, runChild },
367
371
  );
368
- const config = await loadDevLoopConfig({ repoRoot: resolveRepoRoot(process.cwd()) });
372
+ const config = await loadDevLoopConfig({ repoRoot: resolvedRepoRoot });
369
373
  if (config.errors?.length > 0) {
370
374
  console.error("[copilot-pr-handoff] config warnings:", JSON.stringify(config.errors));
371
375
  }
@@ -389,7 +393,7 @@ export async function runHandoff(options, { env = process.env, ghCommand = "gh"
389
393
  if (resolveRunId(env)) {
390
394
  humanCommentCheck = await detectRecentHumanComments(
391
395
  { repo: options.repo, pr: options.pr, claimedAtMs: runnerOwnership?.activeRun?.claimedAt ? new Date(runnerOwnership.activeRun.claimedAt).getTime() : undefined },
392
- { env, ghCommand },
396
+ { env, ghCommand, runChild },
393
397
  );
394
398
  }
395
399
  const TERMINAL_STATES = new Set([STATE.NO_PR, STATE.DONE, STATE.BLOCKED_NEEDS_USER_DECISION]);
@@ -399,7 +403,7 @@ export async function runHandoff(options, { env = process.env, ghCommand = "gh"
399
403
  repo: options.repo,
400
404
  pr: options.pr,
401
405
  env,
402
- cwd: resolveRepoRoot(process.cwd()),
406
+ cwd: resolvedRepoRoot,
403
407
  });
404
408
  return {
405
409
  ok: true,
@@ -467,7 +471,7 @@ export async function runHandoff(options, { env = process.env, ghCommand = "gh"
467
471
  interpretation.state === STATE.READY_TO_REREQUEST_REVIEW)
468
472
  ) {
469
473
  try {
470
- const internalCheck = await detectPrInternalOnly(options, { env, ghCommand });
474
+ const internalCheck = await detectPrInternalOnly(options, { env, ghCommand, runChild });
471
475
  if (internalCheck.ok && internalCheck.internalOnly) {
472
476
  internalOnlySkipCopilot = true;
473
477
  interpretation = {
@@ -526,7 +530,7 @@ export async function runHandoff(options, { env = process.env, ghCommand = "gh"
526
530
  if (!internalOnlySkipCopilot
527
531
  && options.watchStatus === undefined
528
532
  && interpretation.state === STATE.ROUND_CAP_CLEAN_FALLBACK) {
529
- const reopenFacts = await fetchReopenCycleFacts(options, { env, ghCommand });
533
+ const reopenFacts = await fetchReopenCycleFacts(options, { env, ghCommand, runChild });
530
534
  const significant = await detectPostConvergenceSignificantChange(
531
535
  {
532
536
  repo: options.repo,
@@ -537,7 +541,7 @@ export async function runHandoff(options, { env = process.env, ghCommand = "gh"
537
541
  roundCapReached: true,
538
542
  regularCopilotRounds: (snapshot.copilotReviewRoundCount ?? 0) > 0,
539
543
  },
540
- { env, ghCommand },
544
+ { env, ghCommand, runChild },
541
545
  );
542
546
  if (significant) {
543
547
  reopenedCapCycle = true;
@@ -573,7 +577,7 @@ export async function runHandoff(options, { env = process.env, ghCommand = "gh"
573
577
  // backstop must never permit rounds the interpreter already forbids.
574
578
  lightweight: options.lightweight,
575
579
  },
576
- { env, ghCommand },
580
+ { env, ghCommand, runChild },
577
581
  );
578
582
  reviewRequestStatus = requestResult.status;
579
583
  snapshot = applyConfirmedReviewRequest(snapshot, reviewRequestStatus);
@@ -659,7 +663,7 @@ export async function runHandoff(options, { env = process.env, ghCommand = "gh"
659
663
  repo: options.repo,
660
664
  pr: options.pr,
661
665
  env,
662
- cwd: resolveRepoRoot(process.cwd()),
666
+ cwd: resolvedRepoRoot,
663
667
  });
664
668
  if (runnerRelease.status !== "skipped_no_async_run_id") {
665
669
  result.runnerRelease = runnerRelease;
@@ -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,
@@ -140,7 +140,7 @@ export function parseDetectCliArgs(argv) {
140
140
  }
141
141
  return options;
142
142
  }
143
- async function fetchPrView({ repo, pr }, { env, ghCommand }) {
143
+ async function fetchPrView({ repo, pr }, { env, ghCommand, runChild = defaultRunChild }) {
144
144
  const result = await runChild(
145
145
  ghCommand,
146
146
  ["pr", "view", String(pr), "--repo", repo, "--json", "headRefOid,isDraft,state,number,reviews,statusCheckRollup"],
@@ -161,7 +161,7 @@ async function fetchPrView({ repo, pr }, { env, ghCommand }) {
161
161
  }
162
162
  return payload;
163
163
  }
164
- async function fetchCopilotRequested({ repo, pr }, { env, ghCommand }) {
164
+ async function fetchCopilotRequested({ repo, pr }, { env, ghCommand, runChild = defaultRunChild }) {
165
165
  const result = await runChild(
166
166
  ghCommand,
167
167
  ["api", `repos/${repo}/pulls/${pr}/requested_reviewers`],
@@ -180,7 +180,7 @@ async function fetchCopilotRequested({ repo, pr }, { env, ghCommand }) {
180
180
  const users = Array.isArray(payload?.users) ? payload.users : [];
181
181
  return users.some((user) => isCopilotLogin(user?.login));
182
182
  }
183
- async function fetchLatestCopilotReviewRequestAt({ repo, pr }, { env, ghCommand }) {
183
+ async function fetchLatestCopilotReviewRequestAt({ repo, pr }, { env, ghCommand, runChild = defaultRunChild }) {
184
184
  const result = await runChild(
185
185
  ghCommand,
186
186
  ["api", `repos/${repo}/issues/${pr}/timeline`, "--paginate", "--jq",
@@ -212,7 +212,7 @@ function extractPrVisibleCheckNames(statusCheckRollup) {
212
212
  .filter((name) => typeof name === "string" && name.length > 0);
213
213
  }
214
214
 
215
- async function fetchCurrentHeadCiEvidence({ repo, headSha, prVisibleCheckNames }, { env, ghCommand }) {
215
+ async function fetchCurrentHeadCiEvidence({ repo, headSha, prVisibleCheckNames }, { env, ghCommand, runChild = defaultRunChild }) {
216
216
  const [checkRunsResult, statusesResult] = await Promise.all([
217
217
  runChild(
218
218
  ghCommand,
@@ -328,8 +328,8 @@ function hasSubmittedCopilotReviewOffCurrentHead(reviewSummary, currentHeadSha)
328
328
  }
329
329
 
330
330
 
331
- export async function autoDetectSnapshot({ repo, pr, reviewRequestStatusOverride, localValidationHeadSha, draftGateResetAtMs }, { env = process.env, ghCommand = "gh" } = {}) {
332
- 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 });
333
333
  if (prData === null) {
334
334
  return normalizeSnapshot({ prExists: false });
335
335
  }
@@ -355,13 +355,13 @@ export async function autoDetectSnapshot({ repo, pr, reviewRequestStatusOverride
355
355
  } else if (reviewSummary.hasPendingReviewOnCurrentHead) {
356
356
  copilotReviewRequestStatus = "requested";
357
357
  } else {
358
- const copilotRequested = await fetchCopilotRequested({ repo, pr }, { env, ghCommand });
358
+ const copilotRequested = await fetchCopilotRequested({ repo, pr }, { env, ghCommand, runChild });
359
359
  if (!copilotRequested) {
360
360
  copilotReviewRequestStatus = "none";
361
361
  } else if (!reviewSummary.hasSubmittedReviewOnCurrentHead) {
362
362
  copilotReviewRequestStatus = "requested";
363
363
  } else {
364
- const latestRequestAt = await fetchLatestCopilotReviewRequestAt({ repo, pr }, { env, ghCommand });
364
+ const latestRequestAt = await fetchLatestCopilotReviewRequestAt({ repo, pr }, { env, ghCommand, runChild });
365
365
  const latestReviewAt = reviewSummary.latestSubmittedReviewOnCurrentHeadAt;
366
366
  if (latestRequestAt !== null && latestReviewAt !== null && latestRequestAt > latestReviewAt) {
367
367
  copilotReviewRequestStatus = "requested";
@@ -376,7 +376,7 @@ export async function autoDetectSnapshot({ repo, pr, reviewRequestStatusOverride
376
376
  let actionableThreadCount = 0;
377
377
  let lastCopilotRoundMaxSignal = null;
378
378
  try {
379
- const threadsPayload = await fetchGithubReviewThreadsPayload({ repo, pr }, { env, ghCommand });
379
+ const threadsPayload = await fetchGithubReviewThreadsPayload({ repo, pr }, { env, ghCommand, runChild });
380
380
  const parsed = parseReviewThreads(threadsPayload);
381
381
  unresolvedThreadCount = parsed.summary.unresolvedThreads;
382
382
  actionableThreadCount = parsed.summary.actionableThreads;
@@ -400,7 +400,7 @@ export async function autoDetectSnapshot({ repo, pr, reviewRequestStatusOverride
400
400
  let failureDetails = [];
401
401
  let excludedFailureDetails = [];
402
402
  if (shouldRefreshCurrentHeadCi) {
403
- const refreshed = await fetchCurrentHeadCiEvidence({ repo, headSha: prHeadSha, prVisibleCheckNames }, { env, ghCommand });
403
+ const refreshed = await fetchCurrentHeadCiEvidence({ repo, headSha: prHeadSha, prVisibleCheckNames }, { env, ghCommand, runChild });
404
404
  currentHeadCiStatus = refreshed?.status ?? "none";
405
405
  failureDetails = refreshed?.failureDetails ?? [];
406
406
  excludedFailureDetails = refreshed?.excludedFailureDetails ?? [];
@@ -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
  }
@@ -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";
13
+ import { parsePrNumber, requireTokenValue, runChild as defaultRunChild } from "../_cli-primitives.mjs";
14
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";
@@ -67,11 +68,19 @@ Output (stdout, JSON):
67
68
  "headSha": null,
68
69
  "verdict": null
69
70
  },
71
+ "refinementArtifact": {
72
+ "status": "present",
73
+ "specSource": "linked_issue",
74
+ "reason": "...",
75
+ "finding": null
76
+ },
70
77
  "allowedNextActions": ["resolve_merge_conflicts"],
71
78
  "forbiddenActions": ["run_pre_approval_gate", "declare_merge_ready"],
72
79
  "nextAction": "resolve_merge_conflicts",
73
80
  "reason": "..."
74
81
  }
82
+ (refinementArtifact.planDocPath is present only when specSource is
83
+ "plan_file"; the key is omitted entirely for "linked_issue" and "pr_body")
75
84
  Error output (stderr, JSON):
76
85
  { "ok": false, "error": "...", "usage": "..." }
77
86
  { "ok": false, "error": "..." }
@@ -167,7 +176,7 @@ export function parseGitStatusConflictFiles(text) {
167
176
  }
168
177
  return conflictFiles;
169
178
  }
170
- async function fetchRequestedReviewers({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
179
+ async function fetchRequestedReviewers({ repo, pr }, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}) {
171
180
  const result = await runChild(
172
181
  ghCommand,
173
182
  ["api", `repos/${repo}/pulls/${pr}/requested_reviewers`],
@@ -179,7 +188,7 @@ async function fetchRequestedReviewers({ repo, pr }, { env = process.env, ghComm
179
188
  }
180
189
  return parseRequestedReviewersPayload(result.stdout);
181
190
  }
182
- async function fetchPrFacts({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
191
+ async function fetchPrFacts({ repo, pr }, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}) {
183
192
  const result = await runChild(
184
193
  ghCommand,
185
194
  ["pr", "view", String(pr), "--repo", repo, "--json", "number,state,isDraft,headRefOid,mergeable,mergeStateStatus,body,title,closingIssuesReferences,reviews,statusCheckRollup,files"],
@@ -202,18 +211,19 @@ export async function fetchPrFactsWithSettledMergeable(
202
211
  {
203
212
  env = process.env,
204
213
  ghCommand = "gh",
214
+ runChild = defaultRunChild,
205
215
  maxPolls = 3,
206
216
  pollDelayMs = 1500,
207
217
  sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
208
218
  fetch = fetchPrFacts,
209
219
  } = {},
210
220
  ) {
211
- let prData = await fetch(options, { env, ghCommand });
221
+ let prData = await fetch(options, { env, ghCommand, runChild });
212
222
  let polls = 0;
213
223
  while (String(prData?.mergeable || "").toUpperCase() === "UNKNOWN" && polls < maxPolls) {
214
224
  polls += 1;
215
225
  await sleep(pollDelayMs);
216
- prData = await fetch(options, { env, ghCommand });
226
+ prData = await fetch(options, { env, ghCommand, runChild });
217
227
  }
218
228
  return prData;
219
229
  }
@@ -271,7 +281,7 @@ export function resolveLinkedIssuesFromPr(prData) {
271
281
  return dedupe(matches.map((m) => Number((/(\d+)/.exec(m) || [])[1])));
272
282
  }
273
283
 
274
- async function fetchIssueBody({ repo, issue }, { env = process.env, ghCommand = "gh" } = {}) {
284
+ async function fetchIssueBody({ repo, issue }, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}) {
275
285
  const result = await runChild(
276
286
  ghCommand,
277
287
  ["issue", "view", String(issue), "--repo", repo, "--json", "body"],
@@ -287,18 +297,107 @@ async function fetchIssueBody({ repo, issue }, { env = process.env, ghCommand =
287
297
  return null;
288
298
  }
289
299
  }
290
- export async function loadRefinementArtifact({ repo, prData, prDraft, prClosed, prMerged }, { env = process.env, ghCommand = "gh" } = {}) {
291
- const linkedIssues = resolveLinkedIssuesFromPr(prData);
292
- if (linkedIssues.length === 0) {
293
- 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) {
294
335
  return {
295
336
  status: "missing",
296
337
  linkedIssue: null,
297
338
  linkedIssues: [],
298
- 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.`,
299
342
  finding: "missing_refinement_artifact",
300
343
  };
301
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
+ }
302
401
  return {
303
402
  status: "unknown",
304
403
  linkedIssue: null,
@@ -320,7 +419,7 @@ export async function loadRefinementArtifact({ repo, prData, prDraft, prClosed,
320
419
  // is refined if AT LEAST ONE linked issue carries a refinement artifact.
321
420
  const evaluated = [];
322
421
  for (const issue of linkedIssues) {
323
- const body = await fetchIssueBody({ repo, issue }, { env, ghCommand });
422
+ const body = await fetchIssueBody({ repo, issue }, { env, ghCommand, runChild });
324
423
  if (body === null) {
325
424
  evaluated.push({ issue, artifact: null });
326
425
  continue;
@@ -339,6 +438,7 @@ export async function loadRefinementArtifact({ repo, prData, prDraft, prClosed,
339
438
  status: "present",
340
439
  linkedIssue: firstPresent.issue,
341
440
  linkedIssues,
441
+ specSource: REFINEMENT_ARTIFACT_SPEC_SOURCE.LINKED_ISSUE,
342
442
  refinedIssues,
343
443
  source: a.source,
344
444
  acItems: a.acItems,
@@ -366,6 +466,7 @@ export async function loadRefinementArtifact({ repo, prData, prDraft, prClosed,
366
466
  status: "missing",
367
467
  linkedIssue: firstEvaluated.issue,
368
468
  linkedIssues,
469
+ specSource: REFINEMENT_ARTIFACT_SPEC_SOURCE.LINKED_ISSUE,
369
470
  refinedIssues,
370
471
  reason: `Failed to fetch body for linked issue(s) ${scopeLabel}; draft gate cannot verify a refinement artifact, treating as missing.`,
371
472
  finding: "missing_refinement_artifact",
@@ -389,6 +490,7 @@ export async function loadRefinementArtifact({ repo, prData, prDraft, prClosed,
389
490
  status: "missing",
390
491
  linkedIssue: firstFetched.issue,
391
492
  linkedIssues,
493
+ specSource: REFINEMENT_ARTIFACT_SPEC_SOURCE.LINKED_ISSUE,
392
494
  refinedIssues,
393
495
  source: first.source,
394
496
  acItems: first.acItems,
@@ -402,7 +504,7 @@ export async function loadRefinementArtifact({ repo, prData, prDraft, prClosed,
402
504
  _onlyEnforcedWhenDraft: prDraft === true,
403
505
  };
404
506
  }
405
- async function fetchLocalConflictFiles({ env = process.env, gitCommand = "git" } = {}) {
507
+ async function fetchLocalConflictFiles({ env = process.env, gitCommand = "git", runChild = defaultRunChild } = {}) {
406
508
  let result;
407
509
  try {
408
510
  result = await runChild(
@@ -516,7 +618,7 @@ export async function loadPrGateCoordinationContext(options, runtime = {}) {
516
618
  };
517
619
  }
518
620
 
519
- async function fetchCopilotEverFormallyRequested({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
621
+ async function fetchCopilotEverFormallyRequested({ repo, pr }, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}) {
520
622
  const result = await runChild(
521
623
  ghCommand,
522
624
  ["api", `repos/${repo}/issues/${pr}/timeline`, "--paginate", "--jq",
@@ -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
+ }