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.
- package/.claude/.claude-plugin/plugin.json +1 -1
- package/.claude/agents/dev-loop.md +1 -1
- package/.claude/agents/refiner.md +2 -2
- package/.claude/agents/review.md +9 -12
- package/.claude/commands/loop-continue.md +2 -1
- package/.claude/commands/loop-enqueue.md +8 -1
- package/.claude/commands/loop-info.md +1 -1
- package/.claude/hooks/_bash-command-classify.mjs +7 -6
- package/.claude/hooks/_hook-decisions.mjs +5 -4
- package/.claude/skills/copilot-pr-followup/SKILL.md +5 -5
- package/.claude/skills/dev-loop/SKILL.md +7 -7
- package/.claude/skills/docs/acceptance-criteria-verification.md +12 -4
- package/.claude/skills/docs/anti-patterns.md +3 -3
- package/.claude/skills/docs/artifact-authority-contract.md +6 -4
- package/.claude/skills/docs/confirmation-rules.md +1 -1
- package/.claude/skills/docs/copilot-loop-operations.md +4 -2
- package/.claude/skills/docs/issue-intake-procedure.md +12 -3
- package/.claude/skills/docs/merge-preconditions.md +5 -3
- package/.claude/skills/docs/pr-lifecycle-contract.md +1 -1
- package/.claude/skills/docs/public-dev-loop-contract.md +2 -1
- package/.claude/skills/docs/retrospective-checkpoint-contract.md +9 -6
- package/.claude/skills/docs/validation-policy.md +1 -1
- package/.claude/skills/local-implementation/SKILL.md +3 -3
- package/.claude/skills/loop-grill/SKILL.md +34 -14
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +30 -0
- package/README.md +95 -189
- package/agents/refiner.agent.md +2 -2
- package/agents/review.agent.md +9 -12
- package/extension/README.md +5 -4
- package/package.json +5 -4
- package/scripts/docs/validate-state-machine-conformance.mjs +78 -1
- package/scripts/github/_gate-names.mjs +5 -0
- package/scripts/github/capture-review-threads.mjs +2 -2
- package/scripts/github/detect-checkpoint-evidence.mjs +7 -7
- package/scripts/github/edit-issue.mjs +259 -0
- package/scripts/github/probe-ci-status.mjs +18 -0
- package/scripts/github/probe-copilot-review.mjs +24 -3
- package/scripts/github/reconcile-draft-gate.mjs +13 -13
- package/scripts/github/request-copilot-review.mjs +17 -16
- package/scripts/github/upsert-checkpoint-verdict.mjs +25 -23
- package/scripts/github/verify-briefing-prefixes.mjs +224 -33
- package/scripts/github/write-gate-context.mjs +8 -4
- package/scripts/loop/_post-convergence-change.mjs +2 -2
- package/scripts/loop/_pr-runner-coordination.mjs +112 -13
- package/scripts/loop/check-retro-tooling.mjs +14 -9
- package/scripts/loop/copilot-pr-handoff.mjs +18 -14
- package/scripts/loop/detect-copilot-loop-state.mjs +11 -11
- package/scripts/loop/detect-internal-only-pr.mjs +6 -6
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +117 -15
- package/scripts/loop/detect-refinement-grill-state.mjs +136 -0
- package/scripts/loop/run-watch-cycle.mjs +42 -7
- package/scripts/loop/sanctioned-commands.mjs +1 -0
- package/scripts/pages/build-state-atlas.mjs +15 -0
- package/scripts/projects/add-queue-item.mjs +87 -4
- package/skills/copilot-pr-followup/SKILL.md +5 -5
- package/skills/dev-loop/SKILL.md +2 -2
- package/skills/docs/acceptance-criteria-verification.md +12 -4
- package/skills/docs/anti-patterns.md +3 -3
- package/skills/docs/artifact-authority-contract.md +6 -4
- package/skills/docs/confirmation-rules.md +1 -1
- package/skills/docs/copilot-loop-operations.md +4 -2
- package/skills/docs/issue-intake-procedure.md +12 -3
- package/skills/docs/merge-preconditions.md +5 -3
- package/skills/docs/pr-lifecycle-contract.md +1 -1
- package/skills/docs/public-dev-loop-contract.md +2 -1
- package/skills/docs/required-rules.json +17 -1
- package/skills/docs/retrospective-checkpoint-contract.md +9 -6
- package/skills/docs/validation-policy.md +1 -1
- package/skills/local-implementation/SKILL.md +3 -3
- package/skills/loop-grill/SKILL.md +38 -17
package/agents/review.agent.md
CHANGED
|
@@ -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.
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
-
|
|
37
|
-
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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/missing — never 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
|
package/extension/README.md
CHANGED
|
@@ -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
|
|
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 (
|
|
195
|
-
3.
|
|
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
|
|
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": "
|
|
25
|
-
"test": "
|
|
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.
|
|
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.
|
|
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 #
|
|
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
|
}
|