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
@@ -29,18 +29,15 @@ You are a focused pull request review agent. You review an implementation for co
29
29
 
30
30
  ## Scoped angle-review mode
31
31
 
32
- This agent has two modes. The default mode is the full-PR review described in the rest of this file. The **scoped angle-review mode** is the per-angle reviewer of the gate-review fan-out ([Gate Review Sub-Loop Contract](../docs/gate-review-sub-loop-contract.md)): the gate skill builds ONE deterministic, neutral context bundle (diff + adjacent code) and seeds each independent `review` agent with that identical bundle, each scoped to exactly one review concern. You receive only the neutral artifact + your angle — never the main (orchestrating) agent's conversation, opinions, or state.
33
-
34
- You are in scoped angle-review mode when the invocation supplies a single review `<angle>` plus a gate-context artifact path (`tmp/gate-context/<repo-slug>/pr-<N>/<gate>-<headSha>.json`, written by `scripts/github/write-gate-context.mjs`). Review like an external code reviewer hunting real bugs, not a process auditor: your job is to find concrete defects in the changed code and its call paths, not to remark that "there is no test" or that a doc section is thin. In that mode:
35
-
36
- - **No worktree isolation (mandatory):** you review in the PR's actual worktree/head — the same checkout the gate-context preamble ran in — never a fresh isolated worktree. You are read-only and never mutate files, so isolation buys nothing; a fresh worktree would also be checked out from `main` (not the PR head) and would lack the gitignored, worktree-local gate-context bundle below, so you'd silently review the wrong stale tree (#1135).
37
- - **Fresh-context guard (mandatory):** run `node scripts/github/verify-fresh-review-context.mjs --scope <angle> --context-path <gate-context-artifact-path>` at startup. If it reports `fresh: false` (exit 1) or any error including a missing gate-context artifact (`gateContextPresent: false`), which is the fail-closed signal that you're not in the right worktree/headrefuse to proceed and report the failure; do not review on inherited context or without seeded context. "Fresh" here means your context is the **neutral builder artifact + your angle**, and explicitly NOT the main agent's conversation/state or a prior reviewer session's state. The injected neutral bundle is the INTENDED seed (allowed); main-agent or cross-session state bleed still fails closed.
38
- - **Use the PROVIDED neutral bundle as your base; do NOT re-derive it from scratch:** the gate-context artifact carries the diff (`scope.diffPath`) and a deterministic, neutral adjacent-code bundle in `adjacentCode` (each changed file plus its 1-hop import in/out-edges, with a `stripped`/`truncated`/`missing` manifest). Start from this bundle — it is the build-once, work-deduped seed shared verbatim across all reviewers. Only widen (load more files) per-angle when the bundle genuinely lacks something your angle needs; do not re-build the whole diff/adjacent-code graph yourself.
39
- - **Single-angle scope:** review ONLY the supplied angle's concern. Read the gate-context artifact for the resolved angle set, change scope (branch, head SHA, touched files), acceptance-criteria pointer, and validation posture. Do not review other angles; the fan-in pass consolidates across angles.
40
- - **Read the FULL diff, not just hunks:** the gate-context artifact records `scope.diffPath` (a repo-relative path to the complete captured diff) and `scope.changedFiles` (full repo-relative paths). Read the entire diff from `scope.diffPath`; if it is `null` or missing, reconstruct it yourself with `git diff` against the change's base (e.g. `git diff origin/main...HEAD` or the branch/head SHA in `scope`). Do not review off a partial or summarized hunk list — you MUST see every changed line for your angle.
41
- - **Reason about ADJACENT related code from the bundle first:** the `adjacentCode` bundle already includes the changed files' callers, callees, and imports (1-hop). Read those entries before opening anything else. For each changed function, trace the real call paths a defect would flow through, and confirm that caller and callee contracts still match (argument shapes, return shapes, error/null conventions, units). If the bundle truncated or stripped a file you need (see its `truncated`/`stripped` manifest), open that file directly to widen.
42
- - **Review ADVERSARIALLY — hunt concrete defects:** actively try to break the changed code. Hunt for edge cases, missing or wrong input validation, numeric coercion bugs (NaN, Infinity, floats where integers are assumed, negative values, string-to-number coercion), null/undefined handling, off-by-one and boundary conditions, mismatched caller/callee contracts, and dedup/identity bugs (wrong key, reference vs. value equality, unstable ordering). For every finding, give the concrete `file:line` plus the specific failing scenario or input that triggers the defect — not high-altitude "is there a test?" commentary.
43
- - **Strictly read-only, but WIDEN SCOPE when needed:** never edit files, stage, commit, push, request reviews, resolve threads, or post PR comments. Findings are returned via the output artifact only; fixes are applied later by the fix cycle, not by this agent. Because you are read-only, you MAY open any additional repository files required to judge your angle (callers, callees, contracts, sibling modules, configs, tests) — widening to read adjacent code is expected and safe. Record every file/module you consulted beyond the briefing's `changedFiles`/`diffPath` in the optional `contextWidened` field on your output artifact.
32
+ This agent has two modes. The default mode is the full-PR review described in the rest of this file. In **scoped angle-review mode** you are one per-angle reviewer of the gate-review fan-out. You are in this mode when the invocation supplies a single review `<angle>` plus a gate-context artifact path (`tmp/gate-context/<repo-slug>/pr-<N>/<gate>-<headSha>.json`, written by `scripts/github/write-gate-context.mjs`).
33
+
34
+ Its full execution shape is owned elsewhere read those owners before reviewing and do not re-derive their rules here:
35
+
36
+ - The build-once neutral bundle seeding, fresh-context guard (`verify-fresh-review-context.mjs`), no-worktree-isolation prohibition (#1135), single-angle read-only scope, and briefing composition are owned by the [Gate Review Sub-Loop Contract](../docs/gate-review-sub-loop-contract.md) (`GATE-EXEC-BUILD-ONCE-SEED`, `GATE-EXEC-BRIEFING-PREFIX`) you receive only the neutral artifact + your angle, never the orchestrating agent's conversation, opinions, or state.
37
+ - The adversarial reviewing behavior is owned by `COPILOT-FOLLOWUP-ADVERSARIAL-BRIEFING` in the [Copilot PR Follow-up Skill](../skills/copilot-pr-followup/SKILL.md): read the FULL diff (from `scope.diffPath`, or reconstruct it with `git diff` against the change base when `scope.diffPath` is null/missingnever a hunk-only review) plus the bundled adjacent code rather than re-deriving them, then hunt concrete `file:line` defects (edge cases, input validation, numeric coercion incl. NaN/Infinity/floats/negatives, null/undefined, boundary conditions, mismatched caller/callee contracts, dedup/identity bugs) over process nits, recording any scope-widening in the optional `contextWidened` field on your findings artifact.
38
+
39
+ Follow those owners, then return your findings via the structured artifact below (this agent's canonical output contract):
40
+
44
41
  - **Structured findings artifact:** return a single JSON object the fan-in consolidator (`@dev-loops/core/loop/gate-fanin`) can parse, written to the deterministic per-angle path `tmp/gate-reviews/<repo-slug>/pr-<N>/<gate>-<headSha>/<angle>.json`:
45
42
 
46
43
  ```json
@@ -101,7 +101,7 @@ The messaging distinguishes between local loop readiness and remote GitHub/Copil
101
101
 
102
102
  ## Configuration
103
103
 
104
- The dev-loop workflow is driven by a YAML config at `.pi/dev-loop/defaults.yaml` (shipped with the package) and an optional consumer settings file at `.devloops` at repo root (the loader also accepts `.devloops.yaml`, `.devloops.yml`, and `.devloops.json`; legacy `.pi/dev-loop/settings.*` and `overrides.*` still load as fallbacks with a deprecation warning).
104
+ The dev-loop workflow is driven by the shipped defaults in `packages/core/src/config/extension-defaults.yaml`, an optional repo-local `.pi/dev-loop/defaults.*` layer that merges on top of them, and an optional consumer settings file at `.devloops` at repo root (the loader also accepts `.devloops.yaml`, `.devloops.yml`, and `.devloops.json`; legacy `.pi/dev-loop/settings.*` and `.pi/dev-loop/overrides.*` load only as a fallback when no `.devloops` is present, and are ignored when `.devloops` exists — a deprecation warning still fires).
105
105
 
106
106
  ### How consumers customize config
107
107
 
@@ -191,8 +191,9 @@ workflow:
191
191
  ### Config precedence
192
192
 
193
193
  1. Built-in defaults (`packages/core/src/config/config.mjs` `BUILT_IN_DEFAULTS`)
194
- 2. Shipped defaults (`.pi/dev-loop/defaults.yaml` — committed in source repo)
195
- 3. Consumer settings (`.devloops` at repo root — preferred; `.devloops.yaml`/`.devloops.yml`/`.devloops.json` also load; legacy `.pi/dev-loop/settings.*` and `overrides.*` still load as fallbacks with deprecation warning)
194
+ 2. Shipped defaults (`packages/core/src/config/extension-defaults.yaml`)
195
+ 3. Repo-local override layer (`.pi/dev-loop/defaults.*` merges on top of the shipped defaults when present)
196
+ 4. Consumer settings (`.devloops` at repo root — preferred and authoritative when present; `.devloops.yaml`/`.devloops.yml`/`.devloops.json` also load; legacy `.pi/dev-loop/settings.*` and `.pi/dev-loop/overrides.*` load only as a fallback when no `.devloops` is present; when `.devloops` exists they are ignored, with a deprecation warning)
196
197
 
197
198
  ### Adding custom review angles
198
199
 
@@ -221,7 +222,7 @@ Current Phase 3+ contract:
221
222
  - this phase does not require a separate compiled build or `dist/` pipeline
222
223
 
223
224
  Root verification and test commands are intentionally explicit:
224
- - `npm run verify` is the canonical root verification path (`npm test` + `npm run test:dev-loop`)
225
+ - `npm run verify` is the canonical root verification path; it runs every suite in parallel via `run-p` as a single aggregated command, and any suite failing fails verify
225
226
  - `npm test` runs the current root test suite (`test:assets`, `test:extension`, `test:scripts`, `test:core`, and `test:docs`)
226
227
  - `npm run test:extension`
227
228
  - `npm run test:extension` currently expands to one `node --import tsx --test ...` invocation in `package.json`; prefer the script entrypoint over copying the file list into downstream docs or runbooks
package/package.json CHANGED
@@ -21,8 +21,8 @@
21
21
  "packages/*"
22
22
  ],
23
23
  "scripts": {
24
- "verify": "npm test && npm run test:dev-loop",
25
- "test": "npm run test:assets && npm run test:extension && npm run test:scripts && npm run test:core && npm run test:docs && npm run test:pack",
24
+ "verify": "run-p --aggregate-output --continue-on-error test:assets test:extension test:scripts test:core test:docs test:pack test:dev-loop",
25
+ "test": "run-p --aggregate-output --continue-on-error test:assets test:extension test:scripts test:core test:docs test:pack",
26
26
  "test:assets": "node --test --test-reporter ./test/failure-summary-reporter.mjs test/dev-loop-init-phase-smoke.test.mjs test/contracts/*.test.mjs test/workflow-handoff-contract.test.mjs",
27
27
  "test:extension": "node --import tsx --test --test-reporter ./test/failure-summary-reporter.mjs test/extension-checks.test.mjs test/extension-post-merge-update.test.mjs test/extension-command-contract.test.mjs test/extension-package-contract.test.mjs test/extension-pi-adapter.test.mjs test/dev-loops-core.test.mjs test/dev-loops-cli.test.mjs",
28
28
  "test:scripts": "GIT_CONFIG_COUNT=2 GIT_CONFIG_KEY_0=core.fsync GIT_CONFIG_VALUE_0=none GIT_CONFIG_KEY_1=core.fsyncObjectFiles GIT_CONFIG_VALUE_1=false node --test --test-reporter ./test/failure-summary-reporter.mjs test/github/*.test.mjs test/loop/*.test.mjs test/docs/*.test.mjs test/projects/*.test.mjs test/pages/*.test.mjs",
@@ -58,6 +58,7 @@
58
58
  },
59
59
  "devDependencies": {
60
60
  "@playwright/test": "^1.60.0",
61
+ "npm-run-all2": "^9.0.2",
61
62
  "tsx": "^4.22.0"
62
63
  },
63
64
  "pi": {
@@ -72,7 +73,7 @@
72
73
  ]
73
74
  },
74
75
  "dependencies": {
75
- "@dev-loops/core": "^0.7.2"
76
+ "@dev-loops/core": "^0.8.0"
76
77
  },
77
78
  "repository": {
78
79
  "type": "git",
@@ -82,7 +83,7 @@
82
83
  "url": "https://github.com/mfittko/dev-loops/issues"
83
84
  },
84
85
  "homepage": "https://github.com/mfittko/dev-loops#readme",
85
- "version": "0.7.2",
86
+ "version": "0.8.0",
86
87
  "files": [
87
88
  "cli/",
88
89
  "lib/",
@@ -63,6 +63,7 @@ import { evaluatePrGateCoordination, PR_CHECKPOINT, PR_CHECKPOINT_ACTION } from
63
63
  import { DISPOSITION, interpretLoopState, STATE, TRANSITIONS } from "@dev-loops/core/loop/copilot-loop-state";
64
64
  import { evaluateConductorRouting, getAllowedOuterTransitions, OUTER_STATE, OUTER_TERMINAL_STATES } from "@dev-loops/core/loop/conductor-routing";
65
65
  import { interpretReviewerLoopState, REVIEWER_STATE, REVIEWER_TRANSITIONS } from "@dev-loops/core/loop/reviewer-loop-state";
66
+ import { interpretRefinementGrillState, GRILL_STATE, GRILL_TRANSITIONS } from "@dev-loops/core/loop/refinement-grill-state";
66
67
  import { PR_LIFECYCLE_STATES, PR_LIFECYCLE_TERMINAL_STATES, PR_LIFECYCLE_TRANSITIONS } from "@dev-loops/core/loop/pr-lifecycle";
67
68
  import { DEV_LOOP_GATE, DEV_LOOP_ROUTE_KIND, PUBLIC_DEV_LOOP_GATE_CONTRACT, DEV_LOOP_PUBLIC_INTENT, DEV_LOOP_TARGET_KIND, DEV_LOOP_ACTOR, DEV_LOOP_STATUS, DEV_LOOP_AUTHORIZATION, DEV_LOOP_TARGET_PREFERENCE, INTERNAL_DEV_LOOP_STRATEGY, evaluatePublicDevLoopRouting } from "@dev-loops/core/loop/public-dev-loop-routing";
68
69
 
@@ -939,7 +940,83 @@ const REVIEWER_LOOP_STATE_MACHINE = {
939
940
  registerMachine(REVIEWER_LOOP_STATE_MACHINE);
940
941
 
941
942
  // ---------------------------------------------------------------------------
942
- // Fifth machine (issue #1233): public-dev-loop-routing.
943
+ // Fifth machine (issue #1267): refinement-grill-state.
944
+ //
945
+ // Doc side: docs/refinement-grill-state-graph.md's "## Required transitions" bullets, with
946
+ // one abstract row expanded via REFINEMENT_GRILL_ABSTRACT_ROWS below (the fail-closed
947
+ // load/parse failure edge fires from every non-terminal state into
948
+ // blocked_needs_user_decision). The interpreter checks loadFailed first (fail closed), then
949
+ // the unresolvedGapCount handoff (honest handoff outranks synthesis), then the
950
+ // iterate-to-clean branches.
951
+ //
952
+ // Code side: packages/core/src/loop/refinement-grill-state.mjs exports GRILL_STATE,
953
+ // GRILL_TRANSITIONS, and interpretRefinementGrillState. Checked the same two ways as
954
+ // copilot-loop-state / reviewer-loop-state above: structurally (the edge exists in the real
955
+ // GRILL_TRANSITIONS table) and behaviorally (a fixture reaching `to` lands on `to`).
956
+ // ---------------------------------------------------------------------------
957
+
958
+ const REFINEMENT_GRILL_ABSTRACT_ROWS = new Map([
959
+ ["any non-terminal grill state->`blocked_needs_user_decision`", [
960
+ [GRILL_STATE.LOAD_TARGET, GRILL_STATE.BLOCKED_NEEDS_USER_DECISION],
961
+ [GRILL_STATE.DETECT_GAPS, GRILL_STATE.BLOCKED_NEEDS_USER_DECISION],
962
+ [GRILL_STATE.AWAIT_ANSWERS, GRILL_STATE.BLOCKED_NEEDS_USER_DECISION],
963
+ [GRILL_STATE.SYNTHESIZE, GRILL_STATE.BLOCKED_NEEDS_USER_DECISION],
964
+ [GRILL_STATE.RE_GRILL, GRILL_STATE.BLOCKED_NEEDS_USER_DECISION],
965
+ ]],
966
+ ]);
967
+
968
+ const REFINEMENT_GRILL_DOC_TRANSITIONS = parseRequiredTransitions(
969
+ readFileSync(path.join(REPO_ROOT, "docs", "refinement-grill-state-graph.md"), "utf8"),
970
+ { abstractRows: REFINEMENT_GRILL_ABSTRACT_ROWS },
971
+ );
972
+
973
+ bindDocToCodeTable("refinement-grill-state", REFINEMENT_GRILL_DOC_TRANSITIONS, tableEdges(GRILL_TRANSITIONS));
974
+
975
+ const REFINEMENT_GRILL_TO_FIXTURE = new Map([
976
+ [GRILL_STATE.DETECT_GAPS, () => ({ loaded: true, detectRan: false })],
977
+ [GRILL_STATE.AWAIT_ANSWERS, () => ({ loaded: true, detectRan: true, openGapCount: 2 })],
978
+ [GRILL_STATE.SYNTHESIZE, () => ({ loaded: true, detectRan: true, answersReady: true })],
979
+ [GRILL_STATE.RE_GRILL, () => ({ loaded: true, synthesized: true, reGrillRan: false })],
980
+ [GRILL_STATE.GRILL_CLEAN, () => ({ loaded: true, detectRan: true, openGapCount: 0 })],
981
+ [GRILL_STATE.NEEDS_HUMAN_HANDOFF, () => ({ loaded: true, detectRan: true, unresolvedGapCount: 1 })],
982
+ [GRILL_STATE.BLOCKED_NEEDS_USER_DECISION, () => ({ loadFailed: true })],
983
+ ]);
984
+
985
+ const REFINEMENT_GRILL_TRANSITION_CHECKS = new Map();
986
+ for (const [from, to] of realEdges(REFINEMENT_GRILL_DOC_TRANSITIONS)) {
987
+ const buildFixture = REFINEMENT_GRILL_TO_FIXTURE.get(to);
988
+ if (!buildFixture) throw new Error(`refinement-grill-state: no fixture registered for reachable state "${to}"`);
989
+ REFINEMENT_GRILL_TRANSITION_CHECKS.set(`${from}->${to}`, {
990
+ status: "verified",
991
+ verify: () => {
992
+ const f = buildFixture();
993
+ const i = interpretRefinementGrillState(f);
994
+ return {
995
+ ok: i.state === to && (GRILL_TRANSITIONS[from] || []).includes(to),
996
+ detail: i,
997
+ result: { state: i.state, loadFailed: Boolean(f.loadFailed), hasUnresolved: (f.unresolvedGapCount || 0) > 0 },
998
+ };
999
+ },
1000
+ });
1001
+ }
1002
+
1003
+ const REFINEMENT_GRILL_STATE_MACHINE = {
1004
+ name: "refinement-grill-state",
1005
+ states: Object.values(GRILL_STATE),
1006
+ terminalStates: tableTerminalStates(GRILL_TRANSITIONS),
1007
+ transitions: tableEdges(GRILL_TRANSITIONS),
1008
+ docTransitions: REFINEMENT_GRILL_DOC_TRANSITIONS,
1009
+ transitionChecks: REFINEMENT_GRILL_TRANSITION_CHECKS,
1010
+ safetyRules: [
1011
+ { name: "grill-load-failure-fails-closed", check: (o) => !o.loadFailed || o.state === GRILL_STATE.BLOCKED_NEEDS_USER_DECISION },
1012
+ { name: "grill-unresolved-gap-hands-off", check: (o) => !o.hasUnresolved || o.loadFailed || o.state === GRILL_STATE.NEEDS_HUMAN_HANDOFF },
1013
+ ],
1014
+ };
1015
+
1016
+ registerMachine(REFINEMENT_GRILL_STATE_MACHINE);
1017
+
1018
+ // ---------------------------------------------------------------------------
1019
+ // Sixth machine (issue #1233): public-dev-loop-routing.
943
1020
  //
944
1021
  // Doc side: skills/docs/public-dev-loop-contract.md's "## Required transitions" bullets,
945
1022
  // parsed at load time. Like conductor-routing, the gate graph is stateless per cycle, so each
@@ -0,0 +1,5 @@
1
+ // Canonical gate vocabulary — the single source of truth for gate names shared
2
+ // across the gate-review tooling. Imported by write-gate-context.mjs (artifact
3
+ // gate validation) and verify-briefing-prefixes.mjs (wrong-gate scope check) so
4
+ // the two can never drift.
5
+ export const GATE_NAMES = ["draft_gate", "pre_approval_gate"];
@@ -9,7 +9,7 @@ import {
9
9
  readInput,
10
10
  } from "../_core-helpers.mjs";
11
11
  import { parseArgs } from "node:util";
12
- import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
12
+ import { parsePrNumber, requireTokenValue, runChild as defaultRunChild } from "../_cli-primitives.mjs";
13
13
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
14
14
  import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
15
15
  export const REVIEW_THREADS_QUERY = [
@@ -118,7 +118,7 @@ export function parseCaptureCliArgs(argv) {
118
118
  }
119
119
  export async function fetchGithubReviewThreadsPayload(
120
120
  { repo, pr },
121
- { env = process.env, ghCommand = "gh" } = {},
121
+ { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {},
122
122
  ) {
123
123
  const { owner, name } = parseRepoSlug(repo);
124
124
  const result = await runChild(
@@ -11,7 +11,7 @@ import {
11
11
  } from "../_core-helpers.mjs";
12
12
  import { access, readFile } from "node:fs/promises";
13
13
  import path from "node:path";
14
- import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
14
+ import { parsePrNumber, requireTokenValue, runChild as defaultRunChild } from "../_cli-primitives.mjs";
15
15
  import { fetchGithubReviewThreadsPayload } from "./capture-review-threads.mjs";
16
16
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
17
17
  import { FANOUT_PROVENANCE_MIN_REVIEWERS, GATE_FULL_LABEL, loadDevLoopConfig, resolveGateAngleContract, resolveGateConfig, resolveLightMode, resolveRejectForeignAngles, resolveRequireFanoutEvidence, resolveRequireFanoutProvenance } from "@dev-loops/core/config";
@@ -148,7 +148,7 @@ export function parseDetectCheckpointEvidenceCliArgs(argv) {
148
148
  }
149
149
  return options;
150
150
  }
151
- async function runGhJson(args, { env, ghCommand }) {
151
+ async function runGhJson(args, { env, ghCommand, runChild = defaultRunChild }) {
152
152
  const result = await runChild(ghCommand, args, env);
153
153
  if (result.code !== 0) {
154
154
  const detail = result.stderr.trim() || `exit code ${result.code}`;
@@ -534,7 +534,7 @@ export async function buildFanoutEnforcement({ repo, pr, currentHeadSha, draftGa
534
534
  }
535
535
  return { required: true, requireProvenance, rejectForeignAngles, lightMode, hasFullLabel, gates };
536
536
  }
537
- export async function detectCheckpointEvidence(options, { env = process.env, ghCommand = "gh", cwd = process.cwd() } = {}) {
537
+ export async function detectCheckpointEvidence(options, { env = process.env, ghCommand = "gh", runChild = defaultRunChild, cwd = process.cwd() } = {}) {
538
538
  const runnerOwnership = await ensureAsyncRunnerOwnership({
539
539
  repo: options.repo,
540
540
  pr: options.pr,
@@ -558,8 +558,8 @@ export async function detectCheckpointEvidence(options, { env = process.env, ghC
558
558
  error.staleRunner = staleRunnerDetection;
559
559
  throw error;
560
560
  }
561
- const prPayload = await runGhJson(["pr", "view", String(options.pr), "--repo", options.repo, "--json", "headRefOid"], { env, ghCommand });
562
- const commentsPayload = normalizeIssueCommentsPayload(await runGhJson(["api", "--paginate", "--slurp", `repos/${options.repo}/issues/${options.pr}/comments?per_page=100`], { env, ghCommand }));
561
+ const prPayload = await runGhJson(["pr", "view", String(options.pr), "--repo", options.repo, "--json", "headRefOid"], { env, ghCommand, runChild });
562
+ const commentsPayload = normalizeIssueCommentsPayload(await runGhJson(["api", "--paginate", "--slurp", `repos/${options.repo}/issues/${options.pr}/comments?per_page=100`], { env, ghCommand, runChild }));
563
563
  const currentHeadSha = typeof prPayload?.headRefOid === "string" && prPayload.headRefOid.trim().length > 0
564
564
  ? prPayload.headRefOid.trim()
565
565
  : null;
@@ -571,7 +571,7 @@ export async function detectCheckpointEvidence(options, { env = process.env, ghC
571
571
  // as a PR review rather than an issue comment (root cause 3 from issue #692).
572
572
  let prReviews = [];
573
573
  try {
574
- const reviewsRaw = await runGhJson(["api", "--paginate", "--slurp", `repos/${options.repo}/pulls/${options.pr}/reviews?per_page=100`], { env, ghCommand });
574
+ const reviewsRaw = await runGhJson(["api", "--paginate", "--slurp", `repos/${options.repo}/pulls/${options.pr}/reviews?per_page=100`], { env, ghCommand, runChild });
575
575
  prReviews = normalizePrReviewsPayload(reviewsRaw);
576
576
  } catch {
577
577
  // Graceful fallback: PR reviews fetch failure is non-fatal.
@@ -601,7 +601,7 @@ export async function detectCheckpointEvidence(options, { env = process.env, ghC
601
601
  );
602
602
  if (config != null && resolveRequireFanoutEvidence(config) && resolveLightMode(config) != null && anyInlineVerdict) {
603
603
  try {
604
- const lightFacts = await runGhJson(["pr", "view", String(options.pr), "--repo", options.repo, "--json", "baseRefOid,labels"], { env, ghCommand });
604
+ const lightFacts = await runGhJson(["pr", "view", String(options.pr), "--repo", options.repo, "--json", "baseRefOid,labels"], { env, ghCommand, runChild });
605
605
  baseRef = typeof lightFacts?.baseRefOid === "string" && lightFacts.baseRefOid.trim().length > 0
606
606
  ? lightFacts.baseRefOid.trim()
607
607
  : null;
@@ -0,0 +1,259 @@
1
+ #!/usr/bin/env node
2
+ import { readFile } from "node:fs/promises";
3
+ import { readFileSync } from "node:fs";
4
+ import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
5
+ import { parseIssueNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
6
+ import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
7
+ import { parseArgs } from "node:util";
8
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
9
+
10
+ const USAGE = `Usage: edit-issue.mjs --repo <owner/name> --issue <number> [--title <t>] [--body <b> | --body-file <path>] [--add-assignee <u>] [--remove-assignee <u>] [--milestone <m>]
11
+ Edit issue title/body/assignees/milestone. Thin wrapper over \`gh issue edit\` — use this
12
+ instead of an agent-level raw \`gh issue edit\` so the loop's internal-tooling record
13
+ stays clean (siblings: edit-pr.mjs, comment-issue.mjs).
14
+ Required:
15
+ --repo <owner/name> Repository slug (e.g. owner/repo)
16
+ --issue <number> Issue number
17
+ At least one edit:
18
+ --title <t> New issue title
19
+ --body <b> New issue body as a single argument
20
+ --body-file <path> Read the new body from a file (- reads stdin)
21
+ --add-assignee <u> Assignee to add (repeatable)
22
+ --remove-assignee <u> Assignee to remove (repeatable)
23
+ --milestone <m> Milestone to set (empty string clears it)
24
+ (--title/--body/--body-file reject empty or
25
+ whitespace-only values; use --milestone "" only
26
+ to clear the milestone)
27
+ Output (stdout, JSON):
28
+ { "ok": true, "repo": "owner/repo", "issue": 17, "edited": ["title", "body", ...] }
29
+ Error output (stderr, JSON):
30
+ { "ok": false, "error": "...", "usage"?: "..." }
31
+ ${JQ_OUTPUT_USAGE}
32
+ Exit codes:
33
+ 0 Success
34
+ 1 Argument error or gh failure
35
+ 2 Invalid --jq filter`.trim();
36
+ const parseError = buildParseError(USAGE);
37
+
38
+ export function parseEditIssueCliArgs(argv) {
39
+ const { tokens } = parseArgs({
40
+ args: [...argv],
41
+ options: {
42
+ help: { type: "boolean", short: "h" },
43
+ repo: { type: "string" },
44
+ issue: { type: "string" },
45
+ title: { type: "string" },
46
+ body: { type: "string" },
47
+ "body-file": { type: "string" },
48
+ "add-assignee": { type: "string", multiple: true },
49
+ "remove-assignee": { type: "string", multiple: true },
50
+ milestone: { type: "string" },
51
+ ...JQ_OUTPUT_PARSE_OPTIONS,
52
+ },
53
+ allowPositionals: true,
54
+ strict: false,
55
+ tokens: true,
56
+ });
57
+ const options = {
58
+ help: false,
59
+ repo: undefined,
60
+ issue: undefined,
61
+ title: undefined,
62
+ body: undefined,
63
+ bodyFile: undefined,
64
+ addAssignees: [],
65
+ removeAssignees: [],
66
+ milestone: undefined,
67
+ jq: undefined,
68
+ silent: false,
69
+ };
70
+ for (const token of tokens) {
71
+ if (token.kind === "positional") {
72
+ throw parseError(`Unknown argument: ${token.value}`);
73
+ }
74
+ if (token.kind !== "option") {
75
+ continue;
76
+ }
77
+ if (token.name === "help") {
78
+ options.help = true;
79
+ return options;
80
+ }
81
+ if (token.name === "repo") {
82
+ options.repo = requireTokenValue(token, parseError).trim();
83
+ continue;
84
+ }
85
+ if (token.name === "issue") {
86
+ options.issue = parseIssueNumber(requireTokenValue(token, parseError), parseError);
87
+ continue;
88
+ }
89
+ if (token.name === "title") {
90
+ const title = requireTokenValue(token, parseError);
91
+ if (title.trim().length === 0) {
92
+ throw parseError("--title must not be empty or whitespace-only");
93
+ }
94
+ options.title = title;
95
+ continue;
96
+ }
97
+ if (token.name === "body") {
98
+ const body = requireTokenValue(token, parseError);
99
+ if (body.trim().length === 0) {
100
+ throw parseError("--body must not be empty or whitespace-only");
101
+ }
102
+ options.body = body;
103
+ continue;
104
+ }
105
+ if (token.name === "body-file") {
106
+ const rawPath = requireTokenValue(token, parseError).trim();
107
+ if (rawPath.length === 0) {
108
+ throw parseError("--body-file must be a non-empty path");
109
+ }
110
+ options.bodyFile = rawPath;
111
+ continue;
112
+ }
113
+ if (token.name === "add-assignee") {
114
+ const u = requireTokenValue(token, parseError).trim();
115
+ if (u.length === 0) throw parseError("--add-assignee must be a non-empty login");
116
+ options.addAssignees.push(u);
117
+ continue;
118
+ }
119
+ if (token.name === "remove-assignee") {
120
+ const u = requireTokenValue(token, parseError).trim();
121
+ if (u.length === 0) throw parseError("--remove-assignee must be a non-empty login");
122
+ options.removeAssignees.push(u);
123
+ continue;
124
+ }
125
+ if (token.name === "milestone") {
126
+ // Read the raw token value: an empty string is a valid milestone value
127
+ // (`gh issue edit --milestone ""` clears it), so this deliberately does NOT
128
+ // go through requireTokenValue (which rejects empty). Guard only a truly
129
+ // missing value (`--milestone` with no following token). A whitespace-only
130
+ // value is neither a clear nor a real milestone name — fail closed rather
131
+ // than forwarding it to gh for a less actionable error.
132
+ if (typeof token.value !== "string") {
133
+ throw parseError("--milestone requires a value (use an empty string to clear)");
134
+ }
135
+ if (token.value.length > 0 && token.value.trim().length === 0) {
136
+ throw parseError('--milestone must be a milestone name or "" to clear (whitespace-only is not allowed)');
137
+ }
138
+ options.milestone = token.value;
139
+ continue;
140
+ }
141
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
142
+ throw parseError(`Unknown argument: ${token.rawName}`);
143
+ }
144
+ if (options.repo === undefined || options.issue === undefined) {
145
+ throw parseError("Editing an issue requires both --repo <owner/name> and --issue <number>");
146
+ }
147
+ if (options.body !== undefined && options.bodyFile !== undefined) {
148
+ throw parseError("--body and --body-file are mutually exclusive; pass only one");
149
+ }
150
+ const hasEdit =
151
+ options.title !== undefined ||
152
+ options.body !== undefined ||
153
+ options.bodyFile !== undefined ||
154
+ options.addAssignees.length > 0 ||
155
+ options.removeAssignees.length > 0 ||
156
+ options.milestone !== undefined;
157
+ if (!hasEdit) {
158
+ throw parseError("Editing an issue requires at least one of --title/--body/--body-file/--add-assignee/--remove-assignee/--milestone");
159
+ }
160
+ try {
161
+ parseRepoSlug(options.repo);
162
+ } catch (error) {
163
+ throw parseError(error instanceof Error ? error.message : String(error));
164
+ }
165
+ return options;
166
+ }
167
+
168
+ async function resolveBody(options) {
169
+ if (options.bodyFile === undefined) return options.body;
170
+ // Stdin (fd 0): the fs/promises readFile does NOT accept an integer fd, so read
171
+ // it synchronously via the callback-style API (which does). A real path stays on
172
+ // the async promise read.
173
+ const body =
174
+ options.bodyFile === "-" ? readFileSync(0, "utf8") : await readFile(options.bodyFile, "utf8");
175
+ // Fail closed on an empty / whitespace-only file so a blank --body-file cannot
176
+ // silently clear the issue body (USAGE promises --body/--title reject empties).
177
+ if (body.trim().length === 0) {
178
+ throw new Error(`--body-file ${options.bodyFile} is empty`);
179
+ }
180
+ return body;
181
+ }
182
+
183
+ // Build the `gh issue edit` args and the parallel `edited` list (which fields were
184
+ // touched) so callers get a stable summary without re-reading the issue.
185
+ async function buildEditArgs(options) {
186
+ const args = ["issue", "edit", String(options.issue), "--repo", options.repo];
187
+ const edited = [];
188
+ if (options.title !== undefined) {
189
+ args.push("--title", options.title);
190
+ edited.push("title");
191
+ }
192
+ // resolveBody still runs for validation (reads the file, throws on empty /
193
+ // whitespace-only). A REAL --body-file path is handed straight to gh so large
194
+ // bodies avoid command-length limits. But `--body-file -` (stdin) was already
195
+ // consumed by resolveBody reading fd 0; re-emitting `--body-file -` makes gh
196
+ // re-read an exhausted stdin and clear the body, so pass the resolved string
197
+ // inline via --body instead.
198
+ const body = await resolveBody(options);
199
+ if (body !== undefined) {
200
+ if (options.bodyFile !== undefined && options.bodyFile !== "-") {
201
+ args.push("--body-file", options.bodyFile);
202
+ } else {
203
+ args.push("--body", body);
204
+ }
205
+ edited.push("body");
206
+ }
207
+ for (const u of options.addAssignees) {
208
+ args.push("--add-assignee", u);
209
+ }
210
+ if (options.addAssignees.length > 0) edited.push("add-assignee");
211
+ for (const u of options.removeAssignees) {
212
+ args.push("--remove-assignee", u);
213
+ }
214
+ if (options.removeAssignees.length > 0) edited.push("remove-assignee");
215
+ if (options.milestone !== undefined) {
216
+ args.push("--milestone", options.milestone);
217
+ edited.push("milestone");
218
+ }
219
+ return { args, edited };
220
+ }
221
+
222
+ export async function editIssue(options, { env = process.env, ghCommand = "gh", run = runChild } = {}) {
223
+ const { args, edited } = await buildEditArgs(options);
224
+ const result = await run(ghCommand, args, env);
225
+ if (result.code !== 0) {
226
+ const detail = result.stderr.trim() || `exit code ${result.code}`;
227
+ throw new Error(`gh issue edit failed: ${detail}`);
228
+ }
229
+ return { ok: true, repo: options.repo, issue: options.issue, edited };
230
+ }
231
+
232
+ export async function runCli(
233
+ argv = process.argv.slice(2),
234
+ { stdout = process.stdout, stderr = process.stderr, env = process.env, ghCommand = "gh", run = runChild } = {},
235
+ ) {
236
+ let options;
237
+ try {
238
+ options = parseEditIssueCliArgs(argv);
239
+ } catch (error) {
240
+ stderr.write(`${formatCliError(error)}\n`);
241
+ return 1;
242
+ }
243
+ if (options.help) {
244
+ stdout.write(`${USAGE}\n`);
245
+ return 0;
246
+ }
247
+ let result;
248
+ try {
249
+ result = await editIssue(options, { env, ghCommand, run });
250
+ } catch (error) {
251
+ stderr.write(`${JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) })}\n`);
252
+ return 1;
253
+ }
254
+ return emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
255
+ }
256
+
257
+ if (isDirectCliRun(import.meta.url)) {
258
+ runCli().then((code) => { process.exitCode = code; });
259
+ }
@@ -14,6 +14,8 @@ import {
14
14
  DEFAULT_POLL_INTERVAL_MS,
15
15
  COPILOT_REVIEW_WAIT_TIMEOUT_MS,
16
16
  } from "@dev-loops/core/loop/policy-constants";
17
+ import { ensureAsyncRunnerOwnership } from "../loop/_pr-runner-coordination.mjs";
18
+ import { resolveRepoRoot } from "../loop/_repo-root-resolver.mjs";
17
19
 
18
20
  /** Maximum interval between heartbeat outputs during watch delays.
19
21
  * Must be shorter than pi-subagents default needsAttentionAfterMs (60s). */
@@ -341,8 +343,10 @@ export async function watchCiStatus(
341
343
  ghCommand = "gh",
342
344
  delayImpl = delay,
343
345
  now = Date.now,
346
+ ensureOwnershipImpl = ensureAsyncRunnerOwnership,
344
347
  } = {},
345
348
  ) {
349
+ const leaseCwd = resolveRepoRoot(process.cwd());
346
350
  const { headSha: baselineSha, prVisibleCheckNames } = await fetchPrHeadSha(
347
351
  { repo: options.repo, pr: options.pr },
348
352
  { env, ghCommand },
@@ -382,6 +386,20 @@ export async function watchCiStatus(
382
386
  maxPolls: attemptBudget,
383
387
  }) + "\n",
384
388
  );
389
+ // The blocking CI wait can span the full watch budget, which equals the
390
+ // runner-coordination stale window; refresh the lease alongside each
391
+ // heartbeat so the claim stays fresh for every caller of this engine.
392
+ // No-ops without DEVLOOPS_RUN_ID; best-effort, never affects the watch.
393
+ try {
394
+ await ensureOwnershipImpl({
395
+ repo: options.repo,
396
+ pr: options.pr,
397
+ env,
398
+ cwd: leaseCwd,
399
+ claimIfMissing: true,
400
+ requireExisting: false,
401
+ });
402
+ } catch { /* best-effort: never affect the watch */ }
385
403
  }
386
404
  }
387
405
  }