dev-loops 0.7.1 → 0.7.2

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 (86) 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 +2 -2
  5. package/.claude/agents/review.md +2 -2
  6. package/.claude/commands/loop-enqueue.md +1 -1
  7. package/.claude/commands/loop-info.md +1 -1
  8. package/.claude/commands/loop-start-spike.md +1 -1
  9. package/.claude/skills/copilot-pr-followup/SKILL.md +37 -23
  10. package/.claude/skills/dev-loop/SKILL.md +6 -6
  11. package/.claude/skills/docs/acceptance-criteria-verification.md +3 -2
  12. package/.claude/skills/docs/anti-patterns.md +3 -2
  13. package/.claude/skills/docs/artifact-authority-contract.md +12 -11
  14. package/.claude/skills/docs/confirmation-rules.md +1 -0
  15. package/.claude/skills/docs/contract-style-guide.md +37 -0
  16. package/.claude/skills/docs/copilot-loop-operations.md +18 -14
  17. package/.claude/skills/docs/cross-harness-regression-contract.md +2 -2
  18. package/.claude/skills/docs/debt-remediation-contract.md +1 -1
  19. package/.claude/skills/docs/epic-tree-refinement-procedure.md +25 -22
  20. package/.claude/skills/docs/issue-intake-procedure.md +25 -19
  21. package/.claude/skills/docs/merge-preconditions.md +13 -11
  22. package/.claude/skills/docs/pr-lifecycle-contract.md +44 -35
  23. package/.claude/skills/docs/public-dev-loop-contract.md +59 -43
  24. package/.claude/skills/docs/retrospective-checkpoint-contract.md +18 -5
  25. package/.claude/skills/docs/spike-mode-contract.md +14 -7
  26. package/.claude/skills/docs/stop-conditions.md +30 -15
  27. package/.claude/skills/docs/tracker-first-loop-state.md +13 -8
  28. package/.claude/skills/docs/validation-policy.md +4 -5
  29. package/.claude/skills/local-implementation/SKILL.md +48 -142
  30. package/CHANGELOG.md +6 -0
  31. package/agents/dev-loop.agent.md +8 -8
  32. package/agents/fixer.agent.md +1 -0
  33. package/agents/refiner.agent.md +2 -2
  34. package/agents/review.agent.md +2 -2
  35. package/package.json +5 -4
  36. package/scripts/_core-helpers.mjs +2 -0
  37. package/scripts/claude/headless-dev-loop.mjs +1 -1
  38. package/scripts/docs/validate-rule-ownership.mjs +442 -0
  39. package/scripts/docs/validate-state-machine-conformance.mjs +1092 -0
  40. package/scripts/github/_review-thread-mutations.mjs +5 -2
  41. package/scripts/github/create-pr.mjs +119 -10
  42. package/scripts/github/detect-checkpoint-evidence.mjs +102 -17
  43. package/scripts/github/list-review-threads.mjs +277 -0
  44. package/scripts/github/post-gate-findings.mjs +6 -3
  45. package/scripts/github/request-copilot-review.mjs +92 -26
  46. package/scripts/github/upsert-checkpoint-verdict.mjs +74 -5
  47. package/scripts/github/verify-briefing-prefixes.mjs +191 -0
  48. package/scripts/github/verify-fresh-review-context.mjs +78 -3
  49. package/scripts/github/wait-pr-checks.mjs +171 -0
  50. package/scripts/github/write-gate-context.mjs +213 -7
  51. package/scripts/github/write-gate-findings-log.mjs +54 -2
  52. package/scripts/loop/copilot-pr-handoff.mjs +29 -2
  53. package/scripts/loop/detect-change-scope.mjs +2 -2
  54. package/scripts/loop/detect-copilot-loop-state.mjs +23 -3
  55. package/scripts/loop/detect-pr-gate-coordination-state.mjs +35 -3
  56. package/scripts/loop/resolve-dev-loop-startup.mjs +145 -8
  57. package/scripts/loop/sanctioned-commands.mjs +2 -0
  58. package/scripts/loop/validate-pr-body-spec.mjs +21 -5
  59. package/scripts/pages/build-state-atlas.mjs +50 -48
  60. package/scripts/projects/_resolve-project.mjs +1 -148
  61. package/scripts/projects/list-queue-items.mjs +3 -377
  62. package/scripts/projects/move-queue-item.mjs +3 -410
  63. package/scripts/projects/reorder-queue-item.mjs +3 -22
  64. package/skills/copilot-pr-followup/SKILL.md +37 -23
  65. package/skills/dev-loop/SKILL.md +1 -1
  66. package/skills/docs/acceptance-criteria-verification.md +3 -2
  67. package/skills/docs/anti-patterns.md +3 -2
  68. package/skills/docs/artifact-authority-contract.md +12 -11
  69. package/skills/docs/confirmation-rules.md +1 -0
  70. package/skills/docs/contract-style-guide.md +37 -0
  71. package/skills/docs/copilot-loop-operations.md +18 -14
  72. package/skills/docs/cross-harness-regression-contract.md +2 -2
  73. package/skills/docs/debt-remediation-contract.md +1 -1
  74. package/skills/docs/epic-tree-refinement-procedure.md +25 -22
  75. package/skills/docs/issue-intake-procedure.md +25 -19
  76. package/skills/docs/merge-preconditions.md +13 -11
  77. package/skills/docs/pr-lifecycle-contract.md +44 -35
  78. package/skills/docs/public-dev-loop-contract.md +59 -43
  79. package/skills/docs/required-rules.json +149 -0
  80. package/skills/docs/retrospective-checkpoint-contract.md +18 -5
  81. package/skills/docs/spike-mode-contract.md +14 -7
  82. package/skills/docs/stop-conditions.md +30 -15
  83. package/skills/docs/tracker-first-loop-state.md +13 -8
  84. package/skills/docs/validation-policy.md +4 -5
  85. package/skills/local-implementation/SKILL.md +48 -142
  86. package/scripts/docs/validate-no-duplicate-rules.mjs +0 -250
@@ -11,7 +11,7 @@ import {
11
11
  summarizeCopilotReviews,
12
12
  } from "../_core-helpers.mjs";
13
13
  import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
14
- import { loadDevLoopConfig, resolveGateConfig, resolveRefinement, resolveRefinementConfig, resolveWorkflowConfig } from "@dev-loops/core/config";
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
17
  import { evaluatePrGateCoordination, PR_CHECKPOINT, PR_CHECKPOINT_ACTION } from "@dev-loops/core/loop/pr-gate-coordination";
@@ -30,6 +30,12 @@ Required:
30
30
  --repo <owner/name> Repository slug (e.g. owner/repo)
31
31
  --pr <number> Pull request number
32
32
  Optional:
33
+ --lightweight This PR is light-dispatched (#1210): compose the Copilot
34
+ round cap with localImplementation.lightMode.maxCopilotRounds
35
+ (default 1) via min(lightMode.maxCopilotRounds,
36
+ refinement.maxCopilotRounds) instead of using
37
+ refinement.maxCopilotRounds alone. refinement.maxCopilotRounds:
38
+ 0 still disables Copilot rounds even with --lightweight.
33
39
  Output (stdout, JSON):
34
40
  {
35
41
  "ok": true,
@@ -80,6 +86,7 @@ export function parseDetectPrGateCoordinationCliArgs(argv) {
80
86
  help: false,
81
87
  repo: undefined,
82
88
  pr: undefined,
89
+ lightweight: false,
83
90
  };
84
91
  const { tokens } = parseArgs({
85
92
  args: [...argv],
@@ -87,6 +94,7 @@ export function parseDetectPrGateCoordinationCliArgs(argv) {
87
94
  help: { type: "boolean", short: "h" },
88
95
  repo: { type: "string" },
89
96
  pr: { type: "string" },
97
+ lightweight: { type: "boolean" },
90
98
  ...JQ_OUTPUT_PARSE_OPTIONS,
91
99
  },
92
100
  allowPositionals: true,
@@ -112,6 +120,10 @@ export function parseDetectPrGateCoordinationCliArgs(argv) {
112
120
  options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
113
121
  continue;
114
122
  }
123
+ if (token.name === "lightweight") {
124
+ options.lightweight = true;
125
+ continue;
126
+ }
115
127
  if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
116
128
  throw parseError(`Unknown argument: ${token.rawName}`);
117
129
  }
@@ -458,9 +470,20 @@ export async function loadPrGateCoordinationContext(options, runtime = {}) {
458
470
  // detect-copilot-loop-state path and with request-copilot-review's cap logic.
459
471
  const interpreterRepoRoot = runtime.repoRoot ?? resolveRepoRoot(process.cwd());
460
472
  const interpreterConfigResult = await loadDevLoopConfig({ repoRoot: interpreterRepoRoot });
461
- const interpreterRefinementConfig = (Array.isArray(interpreterConfigResult.errors) && interpreterConfigResult.errors.length > 0)
473
+ const interpreterConfigHasErrors = Array.isArray(interpreterConfigResult.errors) && interpreterConfigResult.errors.length > 0;
474
+ const interpreterRefinementConfig = interpreterConfigHasErrors
462
475
  ? resolveRefinement({ version: 1 })
463
476
  : resolveRefinement(interpreterConfigResult.config ?? { version: 1 });
477
+ if (options.lightweight) {
478
+ // Compose (not replace) the round cap for light-dispatched PRs (#1210):
479
+ // min(lightMode.maxCopilotRounds ?? 1, refinement.maxCopilotRounds), so
480
+ // maxCopilotRounds: 0 still disables Copilot rounds everywhere. Shared with
481
+ // the maxCopilotRounds resolution below (#1126 requires the two to agree).
482
+ interpreterRefinementConfig.maxCopilotRounds = resolveEffectiveCopilotRoundCap(
483
+ interpreterConfigHasErrors ? { version: 1 } : (interpreterConfigResult.config ?? { version: 1 }),
484
+ { lightweight: true },
485
+ );
486
+ }
464
487
  const interpretation = interpretLoopState(snapshot, interpreterRefinementConfig);
465
488
  const disposition = summarizeLoopInterpretation(interpretation, interpreterRefinementConfig);
466
489
  const mergeStateStatus = typeof prData?.mergeStateStatus === "string" && prData.mergeStateStatus.trim().length > 0
@@ -515,7 +538,12 @@ export async function detectPrGateCoordinationState(options, runtime = {}) {
515
538
  const hasConfigErrors = Array.isArray(configLoadResult.errors) && configLoadResult.errors.length > 0;
516
539
  const config = hasConfigErrors ? {} : (configLoadResult.config ?? {});
517
540
  const draftGateConfig = resolveGateConfig(config, "draft");
518
- const maxCopilotRounds = resolveRefinementConfig(config, "maxCopilotRounds");
541
+ // Shared with interpreterRefinementConfig.maxCopilotRounds in
542
+ // loadPrGateCoordinationContext (#1126: the two must never disagree at the
543
+ // cap boundary) — the same lightweight composition (#1210) is applied here.
544
+ const maxCopilotRounds = options.lightweight
545
+ ? resolveEffectiveCopilotRoundCap(config, { lightweight: true })
546
+ : resolveRefinementConfig(config, "maxCopilotRounds");
519
547
  // Shared with interpretLoopState (consumed by copilot-pr-handoff.mjs) and
520
548
  // evaluatePrGateCoordination — the single source of truth for "is the
521
549
  // Copilot round cap reached" so this detector cannot disagree with the
@@ -556,6 +584,10 @@ export async function detectPrGateCoordinationState(options, runtime = {}) {
556
584
  copilotReviewRoundCount: context.snapshot?.copilotReviewRoundCount ?? 0,
557
585
  maxCopilotRounds,
558
586
  sameHeadCleanConverged: context.interpretation.sameHeadCleanConverged,
587
+ // Independent gate-ENTRY re-check (#1190): fed alongside (not derived from)
588
+ // sameHeadCleanConverged, so an outstanding request on the current head refuses
589
+ // RUN_PRE_APPROVAL_GATE even if sameHeadCleanConverged were somehow stale/wrong.
590
+ copilotReviewRequestStatus: context.snapshot?.copilotReviewRequestStatus ?? "none",
559
591
  draftGateRequireCi: draftGateConfig.requireCi,
560
592
  draftGate: context.gateEvidence.draftGate,
561
593
  draftGateMarker: context.gateEvidence.draftGateMarker,
@@ -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,
@@ -26,9 +26,11 @@ export const SANCTIONED_COMMANDS = Object.freeze({
26
26
  reads: Object.freeze({
27
27
  "pr-facts": "scripts/github/view-pr.mjs",
28
28
  "ci-status": "scripts/github/probe-ci-status.mjs",
29
+ "ci-wait": "scripts/github/wait-pr-checks.mjs",
29
30
  "ci-logs": "scripts/github/fetch-ci-logs.mjs",
30
31
  "issue-list": "scripts/github/list-issues.mjs",
31
32
  "copilot-review-state": "scripts/github/probe-copilot-review.mjs",
33
+ "review-threads": "scripts/github/list-review-threads.mjs",
32
34
  "gate-coordination": "scripts/loop/detect-pr-gate-coordination-state.mjs",
33
35
  }),
34
36
 
@@ -24,8 +24,8 @@ const USAGE = `Usage:
24
24
  validate-pr-body-spec.mjs --input <path> [--expected-issue <n>]
25
25
  Validate that a PR body carries the lightweight spec-of-record invariants
26
26
  (Objective/why, In scope, Explicit non-goals, testable Acceptance criteria,
27
- Definition of done, Open questions/risks, and a GitHub closing-keyword issue
28
- reference such as \`Closes #123\`).
27
+ Definition of done, Open questions/risks, and unless --no-issue is given —
28
+ a GitHub closing-keyword issue reference such as \`Closes #123\`).
29
29
  Required (exactly one):
30
30
  --repo <owner/name> --pr <number> Fetch the PR body via gh and validate it
31
31
  --input <path> Path to a JSON file with { "body": "..." }
@@ -34,7 +34,14 @@ Required (exactly one):
34
34
  Optional:
35
35
  --expected-issue <n> Positive integer; the referenced closing
36
36
  issue(s) must include this number, else
37
- "closes_wrong_issue".
37
+ "closes_wrong_issue". Mutually exclusive
38
+ with --no-issue.
39
+ --no-issue Explicit issue-less mode (#1210): the PR
40
+ is the sole artifact, so a closing-issue
41
+ reference is FORBIDDEN rather than
42
+ required — one present fails closed under
43
+ "unexpected_closing_issue_reference".
44
+ Mutually exclusive with --expected-issue.
38
45
  Success output (stdout, JSON):
39
46
  {
40
47
  "ok": true | false,
@@ -61,6 +68,7 @@ export function parseValidatePrBodySpecCliArgs(argv) {
61
68
  pr: undefined,
62
69
  input: undefined,
63
70
  expectedIssue: undefined,
71
+ noIssue: false,
64
72
  };
65
73
  const { tokens } = parseArgs({
66
74
  args: [...argv],
@@ -70,6 +78,7 @@ export function parseValidatePrBodySpecCliArgs(argv) {
70
78
  pr: { type: "string" },
71
79
  input: { type: "string" },
72
80
  "expected-issue": { type: "string" },
81
+ "no-issue": { type: "boolean" },
73
82
  ...JQ_OUTPUT_PARSE_OPTIONS,
74
83
  },
75
84
  allowPositionals: true,
@@ -111,12 +120,19 @@ export function parseValidatePrBodySpecCliArgs(argv) {
111
120
  options.expectedIssue = Number(value);
112
121
  continue;
113
122
  }
123
+ if (token.name === "no-issue") {
124
+ options.noIssue = true;
125
+ continue;
126
+ }
114
127
  if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
115
128
  throw parseError(`Unknown argument: ${token.rawName}`);
116
129
  }
117
130
  if (options.help) {
118
131
  return options;
119
132
  }
133
+ if (options.noIssue && Number.isInteger(options.expectedIssue)) {
134
+ throw parseError("--no-issue is mutually exclusive with --expected-issue; provide exactly one issue-linkage mode");
135
+ }
120
136
  const hasInput = typeof options.input === "string" && options.input.length > 0;
121
137
  const hasAnyRemote = (typeof options.repo === "string" && options.repo.length > 0) || Number.isInteger(options.pr);
122
138
  const hasRemotePair = typeof options.repo === "string" && options.repo.length > 0 && Number.isInteger(options.pr);
@@ -163,12 +179,12 @@ export async function validatePrBodySpecFromOptions(options, { env = process.env
163
179
  const body = typeof payload.body === "string" ? payload.body : "";
164
180
  const repo = typeof payload.repo === "string" ? payload.repo : options.repo ?? null;
165
181
  const pr = Number.isInteger(payload.pr) ? payload.pr : options.pr ?? null;
166
- return { ...validatePrBodySpec({ body, expectedIssue: options.expectedIssue ?? null }), repo, pr };
182
+ return { ...validatePrBodySpec({ body, expectedIssue: options.expectedIssue ?? null, issueLess: options.noIssue }), repo, pr };
167
183
  }
168
184
  parseRepoSlug(options.repo);
169
185
  const body = await fetchPrBody({ repo: options.repo, pr: options.pr }, { env, ghCommand });
170
186
  return {
171
- ...validatePrBodySpec({ body, expectedIssue: options.expectedIssue ?? null }),
187
+ ...validatePrBodySpec({ body, expectedIssue: options.expectedIssue ?? null, issueLess: options.noIssue }),
172
188
  repo: options.repo,
173
189
  pr: options.pr,
174
190
  };
@@ -2,10 +2,10 @@
2
2
  // state machine rendered as a mermaid diagram, emitted DETERMINISTICALLY from
3
3
  // the code's own exported tables so the page can never drift from the code.
4
4
  //
5
- // Four diagrams come straight from packages/core/src/loop tables (copilot loop,
6
- // reviewer loop, outer conductor routing, and the public dev-loop gate hub); two
7
- // more are authored from documented, table-less sources (the PR lifecycle
8
- // contract and the release pipeline workflow).
5
+ // Five diagrams come straight from packages/core/src/loop tables (copilot loop,
6
+ // reviewer loop, outer conductor routing, the public dev-loop gate hub, and the
7
+ // PR lifecycle contract); one more (the release pipeline workflow) is authored
8
+ // from a documented, table-less source.
9
9
  //
10
10
  // The page reuses the article design system (docs/articles/introducing-dev-loops.html):
11
11
  // the same :root tokens, body gradient, and typography. It intentionally defines
@@ -15,6 +15,7 @@ import { STATE, TRANSITIONS } from '../../packages/core/src/loop/copilot-loop-st
15
15
  import { REVIEWER_STATE, REVIEWER_TRANSITIONS } from '../../packages/core/src/loop/reviewer-loop-state.mjs';
16
16
  import { OUTER_STATE, OUTER_TRANSITIONS } from '../../packages/core/src/loop/conductor-routing.mjs';
17
17
  import { PUBLIC_DEV_LOOP_GATE_CONTRACT } from '../../packages/core/src/loop/public-dev-loop-routing-contract.mjs';
18
+ import { PR_LIFECYCLE_STATES, PR_LIFECYCLE_TRANSITIONS } from '../../packages/core/src/loop/pr-lifecycle.mjs';
18
19
 
19
20
  // Classify a state/gate id by name into one of four visual classes. Colors are
20
21
  // drawn from the site's own dark palette (accent violet, kicker blue,
@@ -80,7 +81,7 @@ function gateClass(row) {
80
81
  }
81
82
 
82
83
  function renderGateFlowchart(contract) {
83
- const lines = ['flowchart TD', ' router(["dev-loop router"])'];
84
+ const lines = ['flowchart LR', ' router(["dev-loop router"])'];
84
85
  for (const row of contract) {
85
86
  const strategy = row.selectedStrategy ?? 'none';
86
87
  lines.push(` router -->|${row.routeKind}| ${row.gate}["${row.gate} — ${strategy}"]`);
@@ -105,47 +106,16 @@ const outerDiagram = renderStateDiagram(
105
106
  );
106
107
  const gateDiagram = renderGateFlowchart(PUBLIC_DEV_LOOP_GATE_CONTRACT);
107
108
 
108
- // --- Statically-authored diagrams (documented, table-less sources) ---
109
+ // PR lifecycle: the 13-state vocabulary + required transitions exported by
110
+ // packages/core/src/loop/pr-lifecycle.mjs (issue #1193), re-exported here so
111
+ // this module's public surface is unchanged. The L2/L3 state-machine
112
+ // conformance harness (scripts/docs/validate-state-machine-conformance.mjs)
113
+ // imports the same core module directly rather than through this one, so it
114
+ // never pulls in this page generator's diagram-rendering work at load time.
115
+ export { PR_LIFECYCLE_STATES, PR_LIFECYCLE_TRANSITIONS };
116
+ const prLifecycleDiagram = renderStateDiagram(PR_LIFECYCLE_TRANSITIONS, PR_LIFECYCLE_STATES);
109
117
 
110
- // PR lifecycle: the 13-state vocabulary + required transitions from
111
- // skills/docs/pr-lifecycle-contract.md.
112
- const prLifecycleStates = [
113
- 'draft_local_review_gate',
114
- 'draft_local_remediation',
115
- 'ready_state_needs_copilot_request',
116
- 'waiting_for_copilot_review',
117
- 'copilot_feedback_remediation',
118
- 'copilot_reply_resolve_pending',
119
- 'merge_conflict_resolution',
120
- 'final_local_preapproval_gate',
121
- 'final_gate_remediation',
122
- 'waiting_for_human_pr_approval',
123
- 'waiting_for_merge',
124
- 'terminal_slice_complete',
125
- 'stopped_needs_user_decision',
126
- ];
127
- const prLifecycleDiagram = renderStateDiagram([
128
- ['draft_local_review_gate', 'draft_local_remediation'],
129
- ['draft_local_review_gate', 'ready_state_needs_copilot_request'],
130
- ['draft_local_review_gate', 'stopped_needs_user_decision'],
131
- ['draft_local_remediation', 'draft_local_review_gate'],
132
- ['ready_state_needs_copilot_request', 'waiting_for_copilot_review'],
133
- ['ready_state_needs_copilot_request', 'stopped_needs_user_decision'],
134
- ['waiting_for_copilot_review', 'copilot_feedback_remediation'],
135
- ['copilot_feedback_remediation', 'copilot_reply_resolve_pending'],
136
- ['copilot_reply_resolve_pending', 'ready_state_needs_copilot_request'],
137
- ['waiting_for_copilot_review', 'merge_conflict_resolution'],
138
- ['merge_conflict_resolution', 'waiting_for_copilot_review'],
139
- ['waiting_for_copilot_review', 'final_local_preapproval_gate'],
140
- ['final_local_preapproval_gate', 'final_gate_remediation'],
141
- ['final_local_preapproval_gate', 'waiting_for_human_pr_approval'],
142
- ['final_gate_remediation', 'final_local_preapproval_gate'],
143
- ['waiting_for_human_pr_approval', 'waiting_for_merge'],
144
- ['waiting_for_human_pr_approval', 'draft_local_review_gate'],
145
- ['waiting_for_merge', 'terminal_slice_complete'],
146
- ['terminal_slice_complete', '[*]'],
147
- ['stopped_needs_user_decision', '[*]'],
148
- ], prLifecycleStates);
118
+ // --- Statically-authored diagram (documented, table-less source) ---
149
119
 
150
120
  // Release pipeline: the fail-closed gate chain from .github/workflows/release.yml.
151
121
  const releasePipelineNodes = [
@@ -189,7 +159,7 @@ const releaseDiagram = [
189
159
 
190
160
  const INTRO_PROSE = [
191
161
  'dev-loops drives every pull request through closed, deterministic state machines. Workflow control lives in the graph; agent judgment enters only as bounded, explicit inputs, never as hidden orchestration. Exactly one state applies at a time, and each state exposes the legal transitions out of it.',
192
- "Every diagram on this page is generated at site-build time from the code's own exported tables — the copilot loop's <code>STATE</code>/<code>TRANSITIONS</code>, the reviewer loop's <code>REVIEWER_STATE</code>/<code>REVIEWER_TRANSITIONS</code>, the outer loop's <code>OUTER_STATE</code>/<code>OUTER_TRANSITIONS</code>, and the public router's <code>PUBLIC_DEV_LOOP_GATE_CONTRACT</code>. They cannot drift from the code: change a transition table and this page changes with it. Two diagrams (the PR lifecycle and the release pipeline) are authored from documented contracts that have no single code table.",
162
+ "Every diagram on this page is generated at site-build time from the code's own exported tables — the copilot loop's <code>STATE</code>/<code>TRANSITIONS</code>, the reviewer loop's <code>REVIEWER_STATE</code>/<code>REVIEWER_TRANSITIONS</code>, the outer loop's <code>OUTER_STATE</code>/<code>OUTER_TRANSITIONS</code>, the public router's <code>PUBLIC_DEV_LOOP_GATE_CONTRACT</code>, and the PR lifecycle's <code>PR_LIFECYCLE_STATES</code>/<code>PR_LIFECYCLE_TRANSITIONS</code>. They cannot drift from the code: change a transition table and this page changes with it. One diagram (the release pipeline) is authored from a documented contract that has no single code table.",
193
163
  'The state names are not labels for a picture — they are the literal contract identifiers that appear in logs, handoff envelopes, and gate artifacts. Nodes are coloured by role: <strong>active / in-progress</strong> steps the loop advances itself, <strong>waiting-on-external</strong> states that block on something outside the loop (CI, Copilot, a human), <strong>blocked / fail-closed</strong> states that stop or reconcile rather than guess, and <strong>terminal</strong> states where the slice is complete.',
194
164
  ];
195
165
 
@@ -217,7 +187,7 @@ const SECTIONS = [
217
187
  {
218
188
  id: 'pr-lifecycle',
219
189
  title: 'PR lifecycle contract',
220
- source: 'skills/docs/pr-lifecycle-contract.md (documented vocabulary)',
190
+ source: 'packages/core/src/loop/pr-lifecycle.mjs PR_LIFECYCLE_STATES + PR_LIFECYCLE_TRANSITIONS',
221
191
  prose: [
222
192
  'One PR moves through a stable thirteen-state vocabulary from draft to merge. These identifiers are part of the contract surface even as the helper implementations around them change. Two local gates guard the path — <code>draft_gate</code> (draft to ready-for-review) and <code>pre_approval_gate</code> (before final approval) — and both are fail-closed fan-out reviews that run independent angle chains and must produce clean current-head evidence to pass.',
223
193
  'All gate evidence is per-head: a new push re-opens the gates, and ready-to-draft resets the lifecycle back into draft-stage gating. A conflicted head detours through <code>merge_conflict_resolution</code> before any further gate progression — a conflicted PR is never treated as approval- or merge-ready, even if older gate comments and CI were green. Human approval and merge are explicit external waits, not hidden remediation states.',
@@ -270,7 +240,7 @@ function sectionMarkup(s) {
270
240
  <h2>${s.title}</h2>
271
241
  <p class="source">Diagram generated from <code>${s.source}</code></p>
272
242
  ${proseParagraphs(s.prose)}
273
- <div class="diagram"><div class="mermaid">
243
+ <div class="diagram"><button class="expand" type="button" aria-label="View diagram fullscreen">⤢ Fullscreen</button><div class="mermaid">
274
244
  ${escapeMermaid(s.diagram)}
275
245
  </div></div>
276
246
  </section>`;
@@ -404,6 +374,16 @@ export function buildStateAtlasHtml() {
404
374
  }
405
375
  .diagram .mermaid { text-align: center; }
406
376
  .diagram .mermaid svg { max-width: 100%; height: auto; }
377
+ .diagram { position: relative; }
378
+ .diagram .expand { position: sticky; float: right; top: 0.6rem; right: 0.6rem; margin: 0 0 -2rem; z-index: 2; font: 600 0.72rem var(--font); color: var(--copy); background: rgba(15, 23, 42, 0.85); border: 1px solid var(--card-border); border-radius: 8px; padding: 0.35rem 0.6rem; cursor: pointer; }
379
+ .diagram .expand:hover { color: var(--heading); border-color: var(--accent); }
380
+ .diagram .mermaid { cursor: zoom-in; }
381
+ .diagram .mermaid:fullscreen { cursor: zoom-out; background: #0b1220; display: flex; align-items: center; justify-content: center; padding: 2rem; }
382
+ .diagram .mermaid:-webkit-full-screen { cursor: zoom-out; background: #0b1220; display: flex; align-items: center; justify-content: center; padding: 2rem; }
383
+ .diagram .mermaid:-webkit-full-screen svg { max-width: calc(100vw - 4rem); max-height: calc(100vh - 4rem); width: auto; height: auto; }
384
+ .diagram .mermaid:fullscreen svg { max-width: calc(100vw - 4rem); max-height: calc(100vh - 4rem); width: auto; height: auto; }
385
+ .diagram .mermaid.fs-fallback { position: fixed; inset: 0; z-index: 50; background: #0b1220; display: flex; align-items: center; justify-content: center; padding: 1rem; cursor: zoom-out; overflow: auto; }
386
+ .diagram .mermaid.fs-fallback svg { max-width: calc(100vw - 2rem); max-height: calc(100vh - 2rem); width: auto; height: auto; }
407
387
  </style>
408
388
  </head>
409
389
  <body>
@@ -416,6 +396,28 @@ ${sections}
416
396
  </main>
417
397
  <script src="assets/mermaid.min.js"></script>
418
398
  <script>
399
+ let fsInFlight = false;
400
+ document.addEventListener('click', async (e) => {
401
+ if (fsInFlight) return;
402
+ const target = e.target instanceof Element ? e.target : e.target?.parentElement;
403
+ if (!target) return;
404
+ const btn = target.closest('.diagram .expand');
405
+ const box = btn ? btn.parentElement.querySelector('.mermaid') : target.closest('.diagram .mermaid');
406
+ if (!box) return;
407
+ fsInFlight = true;
408
+ try {
409
+ const fsElement = document.fullscreenElement || document.webkitFullscreenElement;
410
+ if (fsElement === box) {
411
+ await (document.exitFullscreen || document.webkitExitFullscreen)?.call(document);
412
+ return;
413
+ }
414
+ if (box.classList.contains('fs-fallback')) { box.classList.remove('fs-fallback'); return; }
415
+ const request = box.requestFullscreen || box.webkitRequestFullscreen;
416
+ if (request) await request.call(box);
417
+ else box.classList.add('fs-fallback'); // iOS Safari: CSS overlay fallback
418
+ } catch { /* fullscreen denied: fall back to the CSS overlay */ box.classList.add('fs-fallback'); }
419
+ finally { fsInFlight = false; }
420
+ });
419
421
  mermaid.initialize({
420
422
  startOnLoad: true,
421
423
  securityLevel: 'strict',