dev-loops 0.2.5 → 0.2.6
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 +9 -2
- package/.claude/hooks/_bash-command-classify.mjs +164 -0
- package/.claude/hooks/_hook-decisions.mjs +130 -0
- package/.claude/hooks/_hook-io.mjs +1 -1
- package/.claude/hooks/_run-context.mjs +179 -0
- package/.claude/hooks/post-tool-use-merge.mjs +1 -1
- package/.claude/hooks/pre-tool-use-bash-gate.mjs +2 -2
- package/.claude/hooks/pre-tool-use-write-guard.mjs +1 -1
- package/.claude/skills/docs/merge-preconditions.md +15 -0
- package/.claude/skills/docs/pr-lifecycle-contract.md +1 -0
- package/CHANGELOG.md +35 -0
- package/package.json +2 -2
- package/scripts/claude/generate-claude-assets.mjs +59 -1
- package/scripts/github/ready-for-review.mjs +5 -2
- package/scripts/loop/_loop-pr-aggregation.mjs +45 -0
- package/scripts/loop/conductor-monitor.mjs +2 -39
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +2 -1
- package/scripts/loop/run-conductor-cycle.mjs +3 -40
- package/skills/docs/merge-preconditions.md +15 -0
- package/skills/docs/pr-lifecycle-contract.md +1 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dev-loops",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
4
4
|
"description": "Agent-harness-agnostic dev-loop: agents, skills, and hooks for GitHub/Copilot-driven development loops.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Manuel Fittko",
|
|
@@ -9,5 +9,12 @@
|
|
|
9
9
|
"license": "MIT",
|
|
10
10
|
"homepage": "https://github.com/mfittko/dev-loops#readme",
|
|
11
11
|
"repository": "https://github.com/mfittko/dev-loops.git",
|
|
12
|
-
"keywords": [
|
|
12
|
+
"keywords": [
|
|
13
|
+
"dev-loop",
|
|
14
|
+
"workflow",
|
|
15
|
+
"github",
|
|
16
|
+
"copilot",
|
|
17
|
+
"claude-code",
|
|
18
|
+
"plugin"
|
|
19
|
+
]
|
|
13
20
|
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// GENERATED from packages/core/src/loop/bash-command-classify.mjs by scripts/claude/generate-claude-assets.mjs — do not edit; edit the source and regenerate.
|
|
2
|
+
/**
|
|
3
|
+
* Pure classification of shell command strings for the dev-loop gate boundary.
|
|
4
|
+
*
|
|
5
|
+
* Shared by the Pi extension (`extension/post-merge-update.ts`, which re-exports these so its
|
|
6
|
+
* behavior is unchanged) and the Claude Code PreToolUse Bash hook. Keeping one source of truth
|
|
7
|
+
* means the `gh pr ready` / merge detection is identical across harnesses.
|
|
8
|
+
*
|
|
9
|
+
* Pure and side-effect free.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** The repository these gate guards apply to. */
|
|
13
|
+
export const TARGET_REPO_SLUG = "mfittko/dev-loops";
|
|
14
|
+
|
|
15
|
+
/** Flags known to take a value argument for `gh pr ready` (not boolean flags). */
|
|
16
|
+
export const FLAGS_THAT_TAKE_VALUE = new Set(["-r", "--repo"]);
|
|
17
|
+
|
|
18
|
+
/** @param {string|null|undefined} value @returns {string|null} */
|
|
19
|
+
export function trimToNull(value) {
|
|
20
|
+
const trimmed = `${value ?? ""}`.trim();
|
|
21
|
+
return trimmed ? trimmed : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Normalize a git remote URL into an `owner/name` slug (lowercased), or null.
|
|
26
|
+
* @param {string} remoteUrl
|
|
27
|
+
* @returns {string|null}
|
|
28
|
+
*/
|
|
29
|
+
export function normalizeGitHubRepoSlug(remoteUrl) {
|
|
30
|
+
const normalized = trimToNull(remoteUrl);
|
|
31
|
+
if (!normalized) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const patterns = [
|
|
36
|
+
/^git@github\.com:([^\s]+?)(?:\.git)?$/i,
|
|
37
|
+
/^https?:\/\/github\.com\/([^\s]+?)(?:\.git)?$/i,
|
|
38
|
+
/^ssh:\/\/git@github\.com\/([^\s]+?)(?:\.git)?$/i,
|
|
39
|
+
/^git:\/\/github\.com\/([^\s]+?)(?:\.git)?$/i,
|
|
40
|
+
/^git:github\.com\/([^\s]+?)(?:\.git)?$/i,
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
for (const pattern of patterns) {
|
|
44
|
+
const match = normalized.match(pattern);
|
|
45
|
+
if (!match) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
return trimToNull(match[1])?.toLowerCase() ?? null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isGhPrMergeCommand(segment) {
|
|
55
|
+
if (!/^gh\s+pr\s+merge(?:\s|$)/i.test(segment)) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
const remainder = segment.replace(/^gh\s+pr\s+merge(?:\s|$)/i, "").trim();
|
|
59
|
+
if (!remainder) {
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
const firstArg = remainder.match(/^(\S+)/)?.[1]?.toLowerCase() ?? "";
|
|
63
|
+
return !["--help", "-h"].includes(firstArg);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isGitMergeCompletionCommand(segment) {
|
|
67
|
+
if (!/^git\s+merge(?:\s|$)/i.test(segment)) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
const remainder = segment.replace(/^git\s+merge(?:\s|$)/i, "").trim();
|
|
71
|
+
if (!remainder) {
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
const firstArg = remainder.match(/^(\S+)/)?.[1]?.toLowerCase() ?? "";
|
|
75
|
+
return !["--abort", "--continue", "--quit", "--help", "-h"].includes(firstArg);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** @param {string} command @returns {boolean} */
|
|
79
|
+
export function isMergeCapableCommand(command) {
|
|
80
|
+
const normalized = command.trim();
|
|
81
|
+
if (!normalized) {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
return normalized
|
|
85
|
+
.split(/\s*(?:&&|\|\||;|\|)\s*/)
|
|
86
|
+
.some((segment) => isGhPrMergeCommand(segment) || isGitMergeCompletionCommand(segment));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** @param {string} command @returns {string} */
|
|
90
|
+
export function firstShellSegment(command) {
|
|
91
|
+
return command.trim().split(/\s*(?:&&|\|\||;|\|)\s*/)[0]?.trim() ?? "";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** @param {string} command @returns {boolean} */
|
|
95
|
+
export function isGhPrReadyCommand(command) {
|
|
96
|
+
const segment = firstShellSegment(command);
|
|
97
|
+
if (!segment || !/^gh\s+pr\s+ready(?:\s|$)/i.test(segment)) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
const remainder = segment.replace(/^gh\s+pr\s+ready(?:\s|$)/i, "").trim();
|
|
101
|
+
if (!remainder) {
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
const args = remainder.split(/\s+/).map((a) => a.toLowerCase());
|
|
105
|
+
return !args.includes("--help") && !args.includes("-h");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** @param {string} command @returns {number|null} */
|
|
109
|
+
export function extractPrNumberFromGhPrReady(command) {
|
|
110
|
+
const segment = firstShellSegment(command);
|
|
111
|
+
if (!/^gh\s+pr\s+ready(?:\s|$)/i.test(segment)) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
const remainder = segment.replace(/^gh\s+pr\s+ready(?:\s|$)/i, "").trim();
|
|
115
|
+
if (!remainder) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
const tokens = remainder.split(/\s+/);
|
|
119
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
120
|
+
const token = tokens[i];
|
|
121
|
+
if (token.startsWith("-")) {
|
|
122
|
+
const flagName = token.replace(/=.*$/, "").toLowerCase();
|
|
123
|
+
if (!token.includes("=") && FLAGS_THAT_TAKE_VALUE.has(flagName)) {
|
|
124
|
+
i++;
|
|
125
|
+
}
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (/^\d+$/.test(token)) {
|
|
129
|
+
const num = Number(token);
|
|
130
|
+
if (num > 0) {
|
|
131
|
+
return num;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** @param {string} command @returns {string|null} */
|
|
140
|
+
export function extractRepoFlagFromGhPrReady(command) {
|
|
141
|
+
const segment = firstShellSegment(command);
|
|
142
|
+
if (!/^gh\s+pr\s+ready(?:\s|$)/i.test(segment)) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
const remainder = segment.replace(/^gh\s+pr\s+ready(?:\s|$)/i, "").trim();
|
|
146
|
+
if (!remainder) {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
const tokens = remainder.split(/\s+/);
|
|
150
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
151
|
+
const token = tokens[i];
|
|
152
|
+
const lower = token.toLowerCase();
|
|
153
|
+
if (lower === "-r" || lower === "--repo") {
|
|
154
|
+
if (i + 1 < tokens.length && !tokens[i + 1].startsWith("-")) {
|
|
155
|
+
return tokens[i + 1];
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const repoEqMatch = token.match(/^(?:--repo|-R)=(.+)$/i);
|
|
159
|
+
if (repoEqMatch) {
|
|
160
|
+
return repoEqMatch[1];
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// GENERATED from packages/core/src/claude/hook-decisions.mjs by scripts/claude/generate-claude-assets.mjs — do not edit; edit the source and regenerate.
|
|
2
|
+
/**
|
|
3
|
+
* Pure decision logic for the Claude Code dev-loop hooks (#773).
|
|
4
|
+
*
|
|
5
|
+
* The hook *scripts* are thin: they read the PreToolUse/PostToolUse stdin payload, gather facts
|
|
6
|
+
* (git tracked/ignored status, gate-evidence result), and call these pure deciders. Keeping the
|
|
7
|
+
* decisions here makes the deny/allow boundary fully unit-testable without spawning hooks, and
|
|
8
|
+
* keeps the Claude-specific stdin/stdout IO at the edge.
|
|
9
|
+
*
|
|
10
|
+
* Pure and side-effect free.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { resolveRunId } from "./_run-context.mjs";
|
|
14
|
+
import {
|
|
15
|
+
isGhPrReadyCommand,
|
|
16
|
+
extractPrNumberFromGhPrReady,
|
|
17
|
+
extractRepoFlagFromGhPrReady,
|
|
18
|
+
TARGET_REPO_SLUG,
|
|
19
|
+
} from "./_bash-command-classify.mjs";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {Object} HookDecision
|
|
23
|
+
* @property {"allow"|"deny"} decision
|
|
24
|
+
* @property {string} [reason] - Human-readable reason (shown to Claude on deny).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const ALLOW = Object.freeze({ decision: "allow" });
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The agent type (Claude `agent_type` / the canonical agent name) that owns repo mutations.
|
|
31
|
+
* Only this subagent — not arbitrary subagents (Explore, Plan, generic Task agents) — may
|
|
32
|
+
* bypass the main-agent read-only boundary.
|
|
33
|
+
*/
|
|
34
|
+
export const DEV_LOOP_AGENT_TYPE = "dev-loop";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Decide whether a PreToolUse Bash command must be blocked by the draft-gate boundary.
|
|
38
|
+
*
|
|
39
|
+
* Mirrors the Pi extension's `onUserBash`: the only blocked case is `gh pr ready` for the
|
|
40
|
+
* target repo without clean draft_gate evidence. Everything else (including merges, which
|
|
41
|
+
* trigger the post-merge step, not a block) is allowed through.
|
|
42
|
+
*
|
|
43
|
+
* @param {Object} params
|
|
44
|
+
* @param {string} params.command - The Bash command string.
|
|
45
|
+
* @param {string|null} [params.repoSlug] - Resolved owner/name of the cwd repo (null if unknown).
|
|
46
|
+
* @param {boolean} [params.gatePassed] - Whether `pre-pr-ready-gate` evidence exists for the PR.
|
|
47
|
+
* @param {string|null} [params.gateError] - Error detail when the gate guard could not run.
|
|
48
|
+
* @returns {HookDecision}
|
|
49
|
+
*/
|
|
50
|
+
export function decideBashGate({ command, repoSlug = null, gatePassed = false, gateError = null }) {
|
|
51
|
+
if (typeof command !== "string" || !isGhPrReadyCommand(command)) {
|
|
52
|
+
return ALLOW;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// An explicit `--repo other/repo` that is not the target → not our concern, pass through.
|
|
56
|
+
const explicitRepo = extractRepoFlagFromGhPrReady(command);
|
|
57
|
+
if (explicitRepo && explicitRepo.toLowerCase() !== TARGET_REPO_SLUG.toLowerCase()) {
|
|
58
|
+
return ALLOW;
|
|
59
|
+
}
|
|
60
|
+
// Only gate within the target repo (case-insensitive — callers may pass an un-lowercased slug).
|
|
61
|
+
if ((repoSlug ?? "").toLowerCase() !== TARGET_REPO_SLUG.toLowerCase()) {
|
|
62
|
+
return ALLOW;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const prNumber = extractPrNumberFromGhPrReady(command);
|
|
66
|
+
if (prNumber === null) {
|
|
67
|
+
return {
|
|
68
|
+
decision: "deny",
|
|
69
|
+
reason:
|
|
70
|
+
"gh pr ready blocked: could not determine the PR number from the command. Include the PR number explicitly.",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (gateError) {
|
|
75
|
+
return {
|
|
76
|
+
decision: "deny",
|
|
77
|
+
reason: `gh pr ready blocked: draft-gate evidence check failed (${gateError}).`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!gatePassed) {
|
|
82
|
+
return {
|
|
83
|
+
decision: "deny",
|
|
84
|
+
reason: `gh pr ready blocked: no visible clean draft_gate checkpoint verdict comment found for PR #${prNumber}.`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return ALLOW;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Decide whether a PreToolUse Write/Edit must be blocked by the main-agent read-only boundary.
|
|
93
|
+
*
|
|
94
|
+
* Denies a mutation whose target is inside the repo working tree AND not gitignored, when the
|
|
95
|
+
* call originates from the MAIN agent. Allows it only inside the *dev-loop* subagent context:
|
|
96
|
+
* the CA2 run id (`DEVLOOPS_RUN_ID`) is present, or the Claude `agent_type` is the dev-loop
|
|
97
|
+
* agent. A generic subagent (Explore, Plan, an arbitrary Task agent) is NOT authorized — the
|
|
98
|
+
* contract requires mutations to flow through the dev-loop subagent specifically. Non-repo /
|
|
99
|
+
* gitignored paths are always allowed. Strict enforcement is opt-in via `enforce` (the hook
|
|
100
|
+
* derives it from `DEVLOOPS_MAIN_AGENT_READONLY=1`) so adopting the harness does not
|
|
101
|
+
* retroactively break a repo's own interactive dev; default is fail-open.
|
|
102
|
+
*
|
|
103
|
+
* @param {Object} params
|
|
104
|
+
* @param {string} params.filePath - Target file path.
|
|
105
|
+
* @param {boolean} params.isRepoMutation - True if inside the repo working tree AND not gitignored.
|
|
106
|
+
* @param {boolean} [params.enforce] - Strict mode (DEVLOOPS_MAIN_AGENT_READONLY=1).
|
|
107
|
+
* @param {Record<string,string|undefined>} [params.env] - Environment (for the CA2 run id).
|
|
108
|
+
* @param {string|null} [params.agentType] - Claude `agent_type` from the hook payload, if any.
|
|
109
|
+
* @returns {HookDecision}
|
|
110
|
+
*/
|
|
111
|
+
export function decideWriteGuard({ filePath, isRepoMutation, enforce = false, env = {}, agentType = null }) {
|
|
112
|
+
if (!enforce) {
|
|
113
|
+
return ALLOW; // strict enforcement not enabled — fail open
|
|
114
|
+
}
|
|
115
|
+
if (!isRepoMutation) {
|
|
116
|
+
return ALLOW; // non-repo or gitignored path (e.g. /tmp, tmp/) — allowed by the contract
|
|
117
|
+
}
|
|
118
|
+
// Authorized only inside the dev-loop subagent context: CA2 run id, or the dev-loop agent
|
|
119
|
+
// type. Any other subagent type is treated like the main agent and denied.
|
|
120
|
+
if (resolveRunId(env) || agentType === DEV_LOOP_AGENT_TYPE) {
|
|
121
|
+
return ALLOW;
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
decision: "deny",
|
|
125
|
+
reason:
|
|
126
|
+
`Main-agent read-only boundary: refusing to mutate repository path "${filePath}". ` +
|
|
127
|
+
"All repository mutations must flow through the dev-loop subagent. " +
|
|
128
|
+
"See skills/docs/main-agent-contract.md.",
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Hooks receive the event payload as JSON on stdin and signal a PreToolUse decision either via
|
|
5
5
|
* exit code 2 (stderr → Claude) or exit 0 with a `hookSpecificOutput` JSON object. We use the
|
|
6
6
|
* structured JSON form so the deny reason is explicit. The decision logic itself lives in the
|
|
7
|
-
* pure
|
|
7
|
+
* pure deciders in the vendored `./_hook-decisions.mjs` bundle — these helpers are only edge IO.
|
|
8
8
|
*/
|
|
9
9
|
import { readFileSync } from "node:fs";
|
|
10
10
|
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// GENERATED from packages/core/src/loop/run-context.mjs by scripts/claude/generate-claude-assets.mjs — do not edit; edit the source and regenerate.
|
|
2
|
+
/**
|
|
3
|
+
* Neutral run-id / async-context contract.
|
|
4
|
+
*
|
|
5
|
+
* The dev-loop async path historically keyed off Pi's `PI_SUBAGENT_RUN_ID` env var to
|
|
6
|
+
* identify an inspectable per-subagent run (runner ownership, async-start enforcement,
|
|
7
|
+
* human-comment gating). This module generalizes that into a harness-neutral
|
|
8
|
+
* `DEVLOOPS_RUN_ID`, keeping `PI_SUBAGENT_RUN_ID` as a backward-compatible alias, and
|
|
9
|
+
* provides a mint-and-propagate path for harnesses (e.g. Claude Code) that inject no
|
|
10
|
+
* native per-subagent run id.
|
|
11
|
+
*
|
|
12
|
+
* Marker precedence is neutral-first: a present `DEVLOOPS_RUN_ID` wins; otherwise the Pi
|
|
13
|
+
* alias is honored. Existing Pi runs that set only `PI_SUBAGENT_RUN_ID` behave identically.
|
|
14
|
+
*
|
|
15
|
+
* This module is pure except for the explicit file/IO helpers (writeRunContext/readRunContext),
|
|
16
|
+
* which take an injectable `fs` and `root` for testability.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import crypto from "node:crypto";
|
|
20
|
+
import fsDefault from "node:fs";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Env var names that carry the async-context run id, in resolution precedence order.
|
|
25
|
+
* Neutral `DEVLOOPS_RUN_ID` first; Pi `PI_SUBAGENT_RUN_ID` retained as a compatibility alias.
|
|
26
|
+
*/
|
|
27
|
+
export const RUN_ID_MARKERS = Object.freeze(["DEVLOOPS_RUN_ID", "PI_SUBAGENT_RUN_ID"]);
|
|
28
|
+
|
|
29
|
+
/** Neutral env var name used when minting/propagating a run id. */
|
|
30
|
+
export const NEUTRAL_RUN_ID_VAR = "DEVLOOPS_RUN_ID";
|
|
31
|
+
|
|
32
|
+
/** Pi-compatibility alias env var name. */
|
|
33
|
+
export const PI_RUN_ID_ALIAS_VAR = "PI_SUBAGENT_RUN_ID";
|
|
34
|
+
|
|
35
|
+
/** State-file name (under `.pi/`, consistent with existing dev-loop checkpoint files). */
|
|
36
|
+
export const RUN_CONTEXT_FILENAME = "dev-loop-run-context.json";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Env var Claude Code sets in every tool/subagent shell it spawns.
|
|
40
|
+
* Used as the harness signal — see `isClaudeHarness`.
|
|
41
|
+
*/
|
|
42
|
+
export const CLAUDE_HARNESS_MARKER = "CLAUDECODE";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* True when running under the Claude Code harness.
|
|
46
|
+
*
|
|
47
|
+
* Claude Code sets `CLAUDECODE=1` in the environment of every Bash tool and
|
|
48
|
+
* subagent it spawns. This is the harness-detection seam used to relax
|
|
49
|
+
* Pi-specific runtime contracts (e.g. the async-start contract) that do not
|
|
50
|
+
* apply to Claude's execution model.
|
|
51
|
+
*
|
|
52
|
+
* @param {Record<string, string|undefined>} [env]
|
|
53
|
+
* @returns {boolean}
|
|
54
|
+
*/
|
|
55
|
+
export function isClaudeHarness(env = process.env) {
|
|
56
|
+
return env?.[CLAUDE_HARNESS_MARKER] === "1";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Resolve the active run id from the environment, neutral marker first.
|
|
61
|
+
*
|
|
62
|
+
* @param {Record<string, string|undefined>} [env]
|
|
63
|
+
* @returns {string|null} The trimmed run id, or null when none is set.
|
|
64
|
+
*/
|
|
65
|
+
export function resolveRunId(env = process.env) {
|
|
66
|
+
for (const marker of RUN_ID_MARKERS) {
|
|
67
|
+
const value = env?.[marker];
|
|
68
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
69
|
+
return value.trim();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Mint a fresh neutral run id.
|
|
77
|
+
*
|
|
78
|
+
* @returns {string} `devloops-<uuid>` (e.g. "devloops-3f2c1e84-...-9a0b")
|
|
79
|
+
*/
|
|
80
|
+
export function mintRunId() {
|
|
81
|
+
return `devloops-${crypto.randomUUID()}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Build the env fragment that propagates a run id to child processes.
|
|
86
|
+
*
|
|
87
|
+
* Sets the neutral var; child Bash scripts observe it via `resolveRunId`. Callers merge
|
|
88
|
+
* this into the child env (e.g. `{ ...process.env, ...runContextEnv(runId) }`).
|
|
89
|
+
*
|
|
90
|
+
* @param {string} runId
|
|
91
|
+
* @returns {{ DEVLOOPS_RUN_ID: string }}
|
|
92
|
+
*/
|
|
93
|
+
export function runContextEnv(runId) {
|
|
94
|
+
return { [NEUTRAL_RUN_ID_VAR]: runId };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Absolute path to the run-context state file for a repo root.
|
|
99
|
+
*
|
|
100
|
+
* @param {string} root - Repository root (or any base dir).
|
|
101
|
+
* @returns {string}
|
|
102
|
+
*/
|
|
103
|
+
export function runContextPath(root) {
|
|
104
|
+
return path.join(root, ".pi", RUN_CONTEXT_FILENAME);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Persist the run-context state file (for inspection/recovery).
|
|
109
|
+
*
|
|
110
|
+
* @param {object} params
|
|
111
|
+
* @param {string} params.runId
|
|
112
|
+
* @param {string} params.root
|
|
113
|
+
* @param {string} [params.mintedAt] - ISO timestamp; defaults to now. Tests pass a fixed
|
|
114
|
+
* value for determinism; real runs get a useful inspection/recovery timestamp.
|
|
115
|
+
* @param {typeof import("node:fs")} [params.fs]
|
|
116
|
+
* @returns {string} The path written.
|
|
117
|
+
*/
|
|
118
|
+
export function writeRunContext({ runId, root, mintedAt, fs = fsDefault }) {
|
|
119
|
+
if (typeof runId !== "string" || runId.trim().length === 0) {
|
|
120
|
+
throw new TypeError("writeRunContext: runId must be a non-empty string");
|
|
121
|
+
}
|
|
122
|
+
const file = runContextPath(root);
|
|
123
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
124
|
+
const payload = {
|
|
125
|
+
runId: runId.trim(),
|
|
126
|
+
mintedAt: mintedAt ?? new Date().toISOString(),
|
|
127
|
+
};
|
|
128
|
+
fs.writeFileSync(file, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
129
|
+
return file;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Read the run-context state file, or null when absent/unparseable.
|
|
134
|
+
*
|
|
135
|
+
* @param {object} params
|
|
136
|
+
* @param {string} params.root
|
|
137
|
+
* @param {typeof import("node:fs")} [params.fs]
|
|
138
|
+
* @returns {{ runId: string, mintedAt: string|null }|null}
|
|
139
|
+
*/
|
|
140
|
+
export function readRunContext({ root, fs = fsDefault }) {
|
|
141
|
+
const file = runContextPath(root);
|
|
142
|
+
try {
|
|
143
|
+
const raw = fs.readFileSync(file, "utf8");
|
|
144
|
+
const parsed = JSON.parse(raw);
|
|
145
|
+
if (parsed && typeof parsed.runId === "string" && parsed.runId.trim().length > 0) {
|
|
146
|
+
return { runId: parsed.runId.trim(), mintedAt: parsed.mintedAt ?? null };
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
} catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Resolve the active run id, or mint one and persist a run-context state file.
|
|
156
|
+
*
|
|
157
|
+
* This is the "mint at startup and propagate" primitive a Claude dev-loop agent (or a
|
|
158
|
+
* headless entry) calls before dispatching child work. When the env already carries a run
|
|
159
|
+
* id (Pi alias or neutral), it is reused and no new id is minted.
|
|
160
|
+
*
|
|
161
|
+
* @param {object} [params]
|
|
162
|
+
* @param {Record<string, string|undefined>} [params.env]
|
|
163
|
+
* @param {string} [params.root]
|
|
164
|
+
* @param {string} [params.mintedAt] - ISO timestamp for the state file (determinism).
|
|
165
|
+
* @param {typeof import("node:fs")} [params.fs]
|
|
166
|
+
* @returns {{ runId: string, minted: boolean, statePath: string|null }}
|
|
167
|
+
*/
|
|
168
|
+
export function ensureRunId({ env = process.env, root, mintedAt, fs = fsDefault } = {}) {
|
|
169
|
+
const existing = resolveRunId(env);
|
|
170
|
+
if (existing) {
|
|
171
|
+
return { runId: existing, minted: false, statePath: null };
|
|
172
|
+
}
|
|
173
|
+
const runId = mintRunId();
|
|
174
|
+
let statePath = null;
|
|
175
|
+
if (typeof root === "string" && root.length > 0) {
|
|
176
|
+
statePath = writeRunContext({ runId, root, mintedAt, fs });
|
|
177
|
+
}
|
|
178
|
+
return { runId, minted: true, statePath };
|
|
179
|
+
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* action is a no-op. There is therefore nothing to run on Stop/SubagentStop; the merge is simply
|
|
10
10
|
* detected here on PostToolUse and an informational, non-blocking note is surfaced. Never blocks.
|
|
11
11
|
*/
|
|
12
|
-
import { isMergeCapableCommand } from "
|
|
12
|
+
import { isMergeCapableCommand } from "./_bash-command-classify.mjs";
|
|
13
13
|
|
|
14
14
|
import { readHookInput } from "./_hook-io.mjs";
|
|
15
15
|
|
|
@@ -10,13 +10,13 @@
|
|
|
10
10
|
import { execFileSync } from "node:child_process";
|
|
11
11
|
import path from "node:path";
|
|
12
12
|
|
|
13
|
-
import { decideBashGate } from "
|
|
13
|
+
import { decideBashGate } from "./_hook-decisions.mjs";
|
|
14
14
|
import {
|
|
15
15
|
isGhPrReadyCommand,
|
|
16
16
|
extractPrNumberFromGhPrReady,
|
|
17
17
|
normalizeGitHubRepoSlug,
|
|
18
18
|
TARGET_REPO_SLUG,
|
|
19
|
-
} from "
|
|
19
|
+
} from "./_bash-command-classify.mjs";
|
|
20
20
|
|
|
21
21
|
import { readHookInput, emitDeny, emitAllow } from "./_hook-io.mjs";
|
|
22
22
|
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { execFileSync } from "node:child_process";
|
|
13
13
|
import path from "node:path";
|
|
14
14
|
|
|
15
|
-
import { decideWriteGuard } from "
|
|
15
|
+
import { decideWriteGuard } from "./_hook-decisions.mjs";
|
|
16
16
|
|
|
17
17
|
import { readHookInput, emitDeny, emitAllow } from "./_hook-io.mjs";
|
|
18
18
|
|
|
@@ -10,6 +10,21 @@ Canonical owner for merge preconditions across all workflow families.
|
|
|
10
10
|
4. ✅ All review threads resolved
|
|
11
11
|
5. ✅ Explicit merge authorization from operator
|
|
12
12
|
6. ✅ PR body contains `Closes #N` or `Fixes #N`
|
|
13
|
+
7. ✅ PR **title** free of merge-blocking markers — `WIP`, `[WIP]`, `DRAFT`, `DO NOT MERGE`, `🚧` (case-insensitive)
|
|
14
|
+
|
|
15
|
+
## Title markers
|
|
16
|
+
|
|
17
|
+
The PR title is a contract surface, so a merge-blocking marker in the title is enforced
|
|
18
|
+
deterministically (`findBlockingTitleMarkers` in `@dev-loops/core/loop/pr-title-markers`), not
|
|
19
|
+
just reviewed:
|
|
20
|
+
|
|
21
|
+
- At the **draft → ready-for-review** transition: `ready-for-review` refuses `gh pr ready` while the
|
|
22
|
+
title carries a marker.
|
|
23
|
+
- At the **pre-approval gate boundary and final approval** (for non-draft PRs): the gate coordinator
|
|
24
|
+
returns `title_marker_blocked` so a PR un-drafted externally still cannot enter pre-approval or
|
|
25
|
+
reach merge-ready with a marked title.
|
|
26
|
+
|
|
27
|
+
A marker is allowed only while the PR is still in draft; it must be removed before the PR leaves draft.
|
|
13
28
|
|
|
14
29
|
## Merge authorization
|
|
15
30
|
|
|
@@ -44,6 +44,7 @@ It does not redefine helper transport mechanics, reviewer-loop internals, conduc
|
|
|
44
44
|
- Draft existence alone is **not** draft-gate readiness.
|
|
45
45
|
- A PR must clear the draft-stage gate for the current head before Copilot review may be requested.
|
|
46
46
|
- Ready -> draft resets the lifecycle back into draft-stage gating.
|
|
47
|
+
- A merge-blocking marker in the PR **title** (`WIP`/`[WIP]`/`DRAFT`/`DO NOT MERGE`/`🚧`, case-insensitive) blocks the draft -> ready transition and, for non-draft PRs, blocks entry to the pre-approval gate and final approval (`title_marker_blocked`). Markers are permitted only while the PR remains draft. See [Merge preconditions](merge-preconditions.md#title-markers).
|
|
47
48
|
- Human approval / merge are explicit external waits, not hidden remediation states.
|
|
48
49
|
|
|
49
50
|
## Two required local gates
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,41 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## 0.2.6
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **Claude plugin hooks are self-contained** (#843). The bundled PreToolUse/PostToolUse hooks
|
|
10
|
+
imported a bare `@dev-loops/core`, which is unresolvable from the marketplace plugin cache (no
|
|
11
|
+
`node_modules` there), so every hook crashed on load — the two PreToolUse gates were silently
|
|
12
|
+
failing open. The asset generator now emits a vendored, relative-import hook bundle
|
|
13
|
+
(`.claude/hooks/_*.mjs`) from the canonical core modules, drift-guarded by the no-drift check.
|
|
14
|
+
- **Retrospective gate is opt-in for consumers** (#841). `extension-defaults.yaml` shipped
|
|
15
|
+
`requireRetrospective`/`requireRetrospectiveGate: true`, forcing the retrospective merge gate on
|
|
16
|
+
every consumer's product PRs against the code default and the contract. Both now default `false`;
|
|
17
|
+
the dev-loops repo opts in via its own `.devloops`.
|
|
18
|
+
- **Dev mode is opt-in for consumers** (#846). `extension-defaults.yaml` shipped
|
|
19
|
+
`devModeDefault: true`, pushing every consumer's product phases into the loop's self-improvement
|
|
20
|
+
mode (which edits the loop's own skill/agent prompts). Now defaults `false`; the dev-loops repo
|
|
21
|
+
opts in via `.devloops`.
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- **Merge-blocking PR-title gate** (#842). The gate pipeline now flags `WIP`/`[WIP]`/`DRAFT`/
|
|
26
|
+
`DO NOT MERGE`/`🚧` (case-insensitive) in the PR **title**, blocking the draft→ready transition
|
|
27
|
+
and — for non-draft PRs — entry to the pre-approval gate and final approval. Documented in the
|
|
28
|
+
merge-preconditions and PR-lifecycle contracts.
|
|
29
|
+
- **Effective async-start mode is surfaced** (#834). The handoff envelope now reports
|
|
30
|
+
`asyncStartEffective` and `asyncStartRelaxedBy` alongside the unchanged configured
|
|
31
|
+
`asyncStartMode`, so the Claude harness relaxation (`required`→`allowed`) is visible instead of
|
|
32
|
+
reading as a contradiction.
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- **Deduplicated PR aggregation** (#809). The duplicated `listOpenPrs` helper is extracted into a
|
|
37
|
+
shared `scripts/loop/_loop-pr-aggregation.mjs` and reused by `conductor-monitor.mjs` and
|
|
38
|
+
`run-conductor-cycle.mjs`. No behavior change.
|
|
39
|
+
|
|
5
40
|
## 0.2.5
|
|
6
41
|
|
|
7
42
|
### Changed
|
package/package.json
CHANGED
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
},
|
|
69
69
|
"dependencies": {
|
|
70
70
|
"mermaid": "11.15.0",
|
|
71
|
-
"@dev-loops/core": "^0.2.
|
|
71
|
+
"@dev-loops/core": "^0.2.6"
|
|
72
72
|
},
|
|
73
73
|
"repository": {
|
|
74
74
|
"type": "git",
|
|
@@ -78,7 +78,7 @@
|
|
|
78
78
|
"url": "https://github.com/mfittko/dev-loops/issues"
|
|
79
79
|
},
|
|
80
80
|
"homepage": "https://github.com/mfittko/dev-loops#readme",
|
|
81
|
-
"version": "0.2.
|
|
81
|
+
"version": "0.2.6",
|
|
82
82
|
"files": [
|
|
83
83
|
"cli/",
|
|
84
84
|
"lib/",
|
|
@@ -60,9 +60,54 @@ export function collectGeneratedAssets({ repoRoot = process.cwd() } = {}) {
|
|
|
60
60
|
assets.push(...collectBundle(repoRoot, srcRel, targetRel));
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
// Self-contained hook bundle (#843). The PreToolUse/PostToolUse hook scripts under
|
|
64
|
+
// .claude/hooks/ ship inside the Claude plugin, which has no node_modules — so they cannot
|
|
65
|
+
// import `@dev-loops/core` (it is unresolvable from the plugin cache and crashes the hook on
|
|
66
|
+
// load). Vendor the pure deciders/classifiers they need as self-contained relative `_*.mjs`
|
|
67
|
+
// modules generated from the canonical core sources, with cross-module imports rewritten to
|
|
68
|
+
// local paths. The no-drift check keeps them in sync with packages/core/src.
|
|
69
|
+
assets.push(...collectHookBundle(repoRoot));
|
|
70
|
+
|
|
63
71
|
return assets;
|
|
64
72
|
}
|
|
65
73
|
|
|
74
|
+
/**
|
|
75
|
+
* The core modules vendored into `.claude/hooks/` for the self-contained hook bundle (#843).
|
|
76
|
+
* `rewrites` rewrites cross-module import specifiers to the sibling vendored copies; node:
|
|
77
|
+
* builtins are left untouched.
|
|
78
|
+
*/
|
|
79
|
+
const HOOK_BUNDLE = [
|
|
80
|
+
{ source: "packages/core/src/loop/bash-command-classify.mjs", target: ".claude/hooks/_bash-command-classify.mjs", rewrites: [] },
|
|
81
|
+
{ source: "packages/core/src/loop/run-context.mjs", target: ".claude/hooks/_run-context.mjs", rewrites: [] },
|
|
82
|
+
{
|
|
83
|
+
source: "packages/core/src/claude/hook-decisions.mjs",
|
|
84
|
+
target: ".claude/hooks/_hook-decisions.mjs",
|
|
85
|
+
rewrites: [
|
|
86
|
+
['"../loop/run-context.mjs"', '"./_run-context.mjs"'],
|
|
87
|
+
['"../loop/bash-command-classify.mjs"', '"./_bash-command-classify.mjs"'],
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
/** Marker that identifies a generated hook-bundle module (a JS comment, distinct from the
|
|
93
|
+
* markdown `<!-- GENERATED -->` banner used by generated skills/agents/docs). Used both to
|
|
94
|
+
* stamp generated bundle modules and to scope orphan detection to them within `.claude/hooks/`. */
|
|
95
|
+
const HOOK_BUNDLE_BANNER_PREFIX = "// GENERATED from ";
|
|
96
|
+
|
|
97
|
+
/** Collect the vendored self-contained hook bundle modules (#843). */
|
|
98
|
+
function collectHookBundle(repoRoot) {
|
|
99
|
+
const out = [];
|
|
100
|
+
for (const { source, target, rewrites } of HOOK_BUNDLE) {
|
|
101
|
+
const abs = path.join(repoRoot, source);
|
|
102
|
+
if (!fs.existsSync(abs)) continue; // no-op when core sources are absent (e.g. consumer tree)
|
|
103
|
+
let body = fs.readFileSync(abs, "utf8");
|
|
104
|
+
for (const [from, to] of rewrites) body = body.split(from).join(to);
|
|
105
|
+
const banner = `${HOOK_BUNDLE_BANNER_PREFIX}${source} by scripts/claude/generate-claude-assets.mjs — do not edit; edit the source and regenerate.\n`;
|
|
106
|
+
out.push({ target, content: banner + body });
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
66
111
|
/**
|
|
67
112
|
* Recursively collect `*.md` files under a source dir as {target, content} bundle assets.
|
|
68
113
|
* Bodies are passed through `stripPiOnlyBlocks` so bundled contract docs can scope Pi-runtime
|
|
@@ -114,10 +159,23 @@ function listFilesRecursive(repoRoot, rel) {
|
|
|
114
159
|
* bundled docs/templates whose source was removed — are detected, not just SKILL.md files.
|
|
115
160
|
*/
|
|
116
161
|
function listExistingAssetFiles(repoRoot) {
|
|
117
|
-
|
|
162
|
+
const files = [
|
|
118
163
|
...listFilesRecursive(repoRoot, ".claude/agents"),
|
|
119
164
|
...listFilesRecursive(repoRoot, ".claude/skills"),
|
|
120
165
|
];
|
|
166
|
+
// `.claude/hooks/` mixes hand-authored scripts (hooks.json, _hook-io.mjs, the three hook
|
|
167
|
+
// scripts) with generated bundle modules (#843). Only the generated ones — identified by the
|
|
168
|
+
// generator banner — participate in orphan detection, so a dropped/renamed HOOK_BUNDLE entry
|
|
169
|
+
// is caught without false-flagging the hand-authored files.
|
|
170
|
+
for (const rel of listFilesRecursive(repoRoot, ".claude/hooks")) {
|
|
171
|
+
const abs = path.join(repoRoot, rel);
|
|
172
|
+
try {
|
|
173
|
+
if (fs.readFileSync(abs, "utf8").startsWith(HOOK_BUNDLE_BANNER_PREFIX)) files.push(rel);
|
|
174
|
+
} catch {
|
|
175
|
+
// unreadable — skip
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return files;
|
|
121
179
|
}
|
|
122
180
|
|
|
123
181
|
/**
|
|
@@ -3,10 +3,11 @@ import { buildParseError, formatCliError, isDirectCliRun, parseJsonText, summari
|
|
|
3
3
|
import { parsePrNumber, requireOptionValue, runChild } from "../_cli-primitives.mjs";
|
|
4
4
|
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
5
5
|
import { loadDevLoopConfig, resolveGateConfig } from "@dev-loops/core/config";
|
|
6
|
+
import { findBlockingTitleMarkers } from "@dev-loops/core/loop/pr-title-markers";
|
|
6
7
|
|
|
7
8
|
const USAGE = `Usage: ready-for-review.mjs --repo <owner/name> --pr <number>\nWrapper around gh pr ready that enforces gate-evidence validation.`;
|
|
8
9
|
const parseError = buildParseError(USAGE);
|
|
9
|
-
const PR_VIEW_QUERY = `query($owner:String!, $name:String!, $number:Int!) { repository(owner:$owner, name:$name) { pullRequest(number:$number) { id, isDraft, headRefOid, state, mergeStateStatus } } }`;
|
|
10
|
+
const PR_VIEW_QUERY = `query($owner:String!, $name:String!, $number:Int!) { repository(owner:$owner, name:$name) { pullRequest(number:$number) { id, isDraft, headRefOid, state, mergeStateStatus, title } } }`;
|
|
10
11
|
|
|
11
12
|
export function parseReadyForReviewCliArgs(argv) {
|
|
12
13
|
const args = [...argv], opts = { help: false, repo: undefined, pr: undefined };
|
|
@@ -33,7 +34,7 @@ async function fetchPrState({ repo, pr }, { env, ghCommand }) {
|
|
|
33
34
|
const r = await runGhJson(["api", "graphql", "-f", `query=${PR_VIEW_QUERY}`, "-f", `owner=${owner}`, "-f", `name=${name}`, "-F", `number=${pr}`], { env, ghCommand });
|
|
34
35
|
const d = r?.data?.repository?.pullRequest;
|
|
35
36
|
if (!d) throw new Error(`Could not fetch PR #${pr}`);
|
|
36
|
-
return { id: d.id, isDraft: d.isDraft === true, headRefOid: typeof d.headRefOid === "string" ? d.headRefOid.trim() : null, state: typeof d.state === "string" ? d.state.trim() : null, mergeStateStatus: typeof d.mergeStateStatus === "string" ? d.mergeStateStatus.trim() : null };
|
|
37
|
+
return { id: d.id, isDraft: d.isDraft === true, headRefOid: typeof d.headRefOid === "string" ? d.headRefOid.trim() : null, state: typeof d.state === "string" ? d.state.trim() : null, mergeStateStatus: typeof d.mergeStateStatus === "string" ? d.mergeStateStatus.trim() : null, title: typeof d.title === "string" ? d.title : null };
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
async function fetchCiStatus({ repo, pr }, { env, ghCommand }) {
|
|
@@ -71,6 +72,8 @@ export async function readyForReview(options, { env = process.env, ghCommand = "
|
|
|
71
72
|
const headSha = prState.headRefOid;
|
|
72
73
|
if (!headSha) throw new Error(`Could not resolve head SHA`);
|
|
73
74
|
if (!prState.isDraft) throw new Error(`PR #${options.pr} is not in draft state`);
|
|
75
|
+
const titleMarkers = findBlockingTitleMarkers(prState.title);
|
|
76
|
+
if (titleMarkers.length > 0) throw new Error(`PR #${options.pr} cannot be marked ready: title contains merge-blocking marker(s): ${titleMarkers.join(", ")}. Remove them from the title first.`);
|
|
74
77
|
if (requireCi) { const ci = await fetchCiStatus({ repo: options.repo, pr: options.pr }, { env, ghCommand }); if (ci.status === "blocked") throw new Error(`PR #${options.pr} has blocking CI checks`); if (ci.status !== "success") throw new Error(`PR #${options.pr} CI is not green`); }
|
|
75
78
|
const gate = await fetchGateEvidence({ repo: options.repo, pr: options.pr, headSha }, { env, ghCommand });
|
|
76
79
|
if (!gate.cleanEvidenceExists && !gate.effectiveHeadClean) throw new Error(`No visible clean draft_gate evidence on ${headSha.slice(0,7)}`);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Shared loop PR-aggregation helpers used by both conductor-monitor.mjs and
|
|
2
|
+
// run-conductor-cycle.mjs. Spawns `gh`, so this lives at the scripts level
|
|
3
|
+
// rather than in @dev-loops/core.
|
|
4
|
+
import { runChild } from "../_cli-primitives.mjs";
|
|
5
|
+
import { parseJsonText } from "../_core-helpers.mjs";
|
|
6
|
+
|
|
7
|
+
export const OPEN_PR_LIST_LIMIT = 1000;
|
|
8
|
+
|
|
9
|
+
export async function listOpenPrs({ repo }, { env, ghCommand }) {
|
|
10
|
+
const result = await runChild(
|
|
11
|
+
ghCommand,
|
|
12
|
+
[
|
|
13
|
+
"pr",
|
|
14
|
+
"list",
|
|
15
|
+
"--repo",
|
|
16
|
+
repo,
|
|
17
|
+
"--state",
|
|
18
|
+
"open",
|
|
19
|
+
"--limit",
|
|
20
|
+
String(OPEN_PR_LIST_LIMIT),
|
|
21
|
+
"--json",
|
|
22
|
+
"number,title,url,isDraft,headRefName,author",
|
|
23
|
+
],
|
|
24
|
+
env,
|
|
25
|
+
);
|
|
26
|
+
if (result.code !== 0) {
|
|
27
|
+
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
28
|
+
throw new Error(`gh command failed: ${detail}`);
|
|
29
|
+
}
|
|
30
|
+
const payload = parseJsonText(result.stdout);
|
|
31
|
+
if (!Array.isArray(payload)) {
|
|
32
|
+
throw new Error("Invalid gh pr list payload: expected an array");
|
|
33
|
+
}
|
|
34
|
+
return payload
|
|
35
|
+
.map((pr) => ({
|
|
36
|
+
number: Number.isInteger(pr?.number) ? pr.number : null,
|
|
37
|
+
title: typeof pr?.title === "string" ? pr.title : "",
|
|
38
|
+
url: typeof pr?.url === "string" ? pr.url : null,
|
|
39
|
+
isDraft: Boolean(pr?.isDraft),
|
|
40
|
+
headRefName: typeof pr?.headRefName === "string" ? pr.headRefName : null,
|
|
41
|
+
authorLogin: typeof pr?.author?.login === "string" ? pr.author.login : null,
|
|
42
|
+
}))
|
|
43
|
+
.filter((pr) => pr.number !== null)
|
|
44
|
+
.sort((left, right) => left.number - right.number);
|
|
45
|
+
}
|
|
@@ -3,7 +3,7 @@ import { existsSync } from "node:fs";
|
|
|
3
3
|
import { access, open, readFile, readdir } from "node:fs/promises";
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
|
-
import {
|
|
6
|
+
import { requireOptionValue } from "../_cli-primitives.mjs";
|
|
7
7
|
import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
|
|
8
8
|
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
9
9
|
import { autoDetectSnapshot } from "./detect-copilot-loop-state.mjs";
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
parseRecordedHandoffContract,
|
|
14
14
|
} from "./_handoff-contract.mjs";
|
|
15
15
|
import { interpretLoopState, summarizeLoopInterpretation } from "@dev-loops/core/loop/copilot-loop-state";
|
|
16
|
+
import { listOpenPrs } from "./_loop-pr-aggregation.mjs";
|
|
16
17
|
const USAGE = `Usage: conductor-monitor.mjs --repo <owner/name> [--auto-resume]
|
|
17
18
|
Aggregate Copilot-loop status across all open PRs in one repo.
|
|
18
19
|
Required:
|
|
@@ -80,7 +81,6 @@ Exit codes:
|
|
|
80
81
|
0 Success
|
|
81
82
|
1 Argument error, gh failure, or indeterminate PR status`.trim();
|
|
82
83
|
const parseError = buildParseError(USAGE);
|
|
83
|
-
const OPEN_PR_LIST_LIMIT = 1000;
|
|
84
84
|
const DEFAULT_SESSION_ROOT = path.join(os.homedir(), ".pi", "agent", "sessions");
|
|
85
85
|
const RUN_STATE = {
|
|
86
86
|
COMPLETED: "completed",
|
|
@@ -145,43 +145,6 @@ function parseCliArgs(argv) {
|
|
|
145
145
|
}
|
|
146
146
|
return options;
|
|
147
147
|
}
|
|
148
|
-
async function listOpenPrs({ repo }, { env, ghCommand }) {
|
|
149
|
-
const result = await runChild(
|
|
150
|
-
ghCommand,
|
|
151
|
-
[
|
|
152
|
-
"pr",
|
|
153
|
-
"list",
|
|
154
|
-
"--repo",
|
|
155
|
-
repo,
|
|
156
|
-
"--state",
|
|
157
|
-
"open",
|
|
158
|
-
"--limit",
|
|
159
|
-
String(OPEN_PR_LIST_LIMIT),
|
|
160
|
-
"--json",
|
|
161
|
-
"number,title,url,isDraft,headRefName,author",
|
|
162
|
-
],
|
|
163
|
-
env,
|
|
164
|
-
);
|
|
165
|
-
if (result.code !== 0) {
|
|
166
|
-
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
167
|
-
throw new Error(`gh command failed: ${detail}`);
|
|
168
|
-
}
|
|
169
|
-
const payload = parseJsonText(result.stdout);
|
|
170
|
-
if (!Array.isArray(payload)) {
|
|
171
|
-
throw new Error("Invalid gh pr list payload: expected an array");
|
|
172
|
-
}
|
|
173
|
-
return payload
|
|
174
|
-
.map((pr) => ({
|
|
175
|
-
number: Number.isInteger(pr?.number) ? pr.number : null,
|
|
176
|
-
title: typeof pr?.title === "string" ? pr.title : "",
|
|
177
|
-
url: typeof pr?.url === "string" ? pr.url : null,
|
|
178
|
-
isDraft: Boolean(pr?.isDraft),
|
|
179
|
-
headRefName: typeof pr?.headRefName === "string" ? pr.headRefName : null,
|
|
180
|
-
authorLogin: typeof pr?.author?.login === "string" ? pr.author.login : null,
|
|
181
|
-
}))
|
|
182
|
-
.filter((pr) => pr.number !== null)
|
|
183
|
-
.sort((left, right) => left.number - right.number);
|
|
184
|
-
}
|
|
185
148
|
function summarizePrDisposition(loopDisposition) {
|
|
186
149
|
switch (loopDisposition) {
|
|
187
150
|
case "pending":
|
|
@@ -147,7 +147,7 @@ async function fetchRequestedReviewers({ repo, pr }, { env = process.env, ghComm
|
|
|
147
147
|
async function fetchPrFacts({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
|
|
148
148
|
const result = await runChild(
|
|
149
149
|
ghCommand,
|
|
150
|
-
["pr", "view", String(pr), "--repo", repo, "--json", "number,state,isDraft,headRefOid,mergeStateStatus,body,closingIssuesReferences,reviews,statusCheckRollup"],
|
|
150
|
+
["pr", "view", String(pr), "--repo", repo, "--json", "number,state,isDraft,headRefOid,mergeStateStatus,body,title,closingIssuesReferences,reviews,statusCheckRollup"],
|
|
151
151
|
env,
|
|
152
152
|
);
|
|
153
153
|
if (result.code !== 0) {
|
|
@@ -381,6 +381,7 @@ export async function detectPrGateCoordinationState(options, runtime = {}) {
|
|
|
381
381
|
prDraft: Boolean(context.prData?.isDraft),
|
|
382
382
|
prClosed: String(context.prData?.state || "").toUpperCase() === "CLOSED",
|
|
383
383
|
prMerged: String(context.prData?.state || "").toUpperCase() === "MERGED",
|
|
384
|
+
prTitle: context.prData?.title,
|
|
384
385
|
mergeStateStatus: context.mergeStateStatus,
|
|
385
386
|
conflictFiles: context.conflictFiles,
|
|
386
387
|
lifecycleState: context.interpretation.state,
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { requireOptionValue } from "../_cli-primitives.mjs";
|
|
3
3
|
import {
|
|
4
4
|
buildParseError,
|
|
5
5
|
formatCliError,
|
|
6
6
|
isDirectCliRun,
|
|
7
|
-
parseJsonText,
|
|
8
7
|
} from "../_core-helpers.mjs";
|
|
9
8
|
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
10
9
|
import { detectPrGateCoordinationState } from "./detect-pr-gate-coordination-state.mjs";
|
|
@@ -14,9 +13,10 @@ import {
|
|
|
14
13
|
SUBAGENT_ACTIONS as SHARED_SUBAGENT_ACTIONS,
|
|
15
14
|
buildHandoffContractForConductorAction,
|
|
16
15
|
} from "./_handoff-contract.mjs";
|
|
16
|
+
import { listOpenPrs } from "./_loop-pr-aggregation.mjs";
|
|
17
|
+
export { listOpenPrs };
|
|
17
18
|
const USAGE = `Usage: run-conductor-cycle.mjs --repo <owner/name>
|
|
18
19
|
Poll all open PRs, detect state, and output an ordered action queue.`.trim();
|
|
19
|
-
const OPEN_PR_LIST_LIMIT = 1000;
|
|
20
20
|
export const CHECKPOINT_ACTION_TO_CONDUCTOR_ACTION = Object.freeze({
|
|
21
21
|
[PR_CHECKPOINT_ACTION.ADDRESS_REVIEW_FEEDBACK]: "fix_threads",
|
|
22
22
|
[PR_CHECKPOINT_ACTION.REPLY_RESOLVE_REVIEW_THREADS]: "fix_threads",
|
|
@@ -90,43 +90,6 @@ export function parseCliArgs(argv) {
|
|
|
90
90
|
}
|
|
91
91
|
return options;
|
|
92
92
|
}
|
|
93
|
-
export async function listOpenPrs({ repo }, { env, ghCommand }) {
|
|
94
|
-
const result = await runChild(
|
|
95
|
-
ghCommand,
|
|
96
|
-
[
|
|
97
|
-
"pr",
|
|
98
|
-
"list",
|
|
99
|
-
"--repo",
|
|
100
|
-
repo,
|
|
101
|
-
"--state",
|
|
102
|
-
"open",
|
|
103
|
-
"--limit",
|
|
104
|
-
String(OPEN_PR_LIST_LIMIT),
|
|
105
|
-
"--json",
|
|
106
|
-
"number,title,url,isDraft,headRefName,author",
|
|
107
|
-
],
|
|
108
|
-
env,
|
|
109
|
-
);
|
|
110
|
-
if (result.code !== 0) {
|
|
111
|
-
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
112
|
-
throw new Error(`gh command failed: ${detail}`);
|
|
113
|
-
}
|
|
114
|
-
const payload = parseJsonText(result.stdout);
|
|
115
|
-
if (!Array.isArray(payload)) {
|
|
116
|
-
throw new Error("Invalid gh pr list payload: expected an array");
|
|
117
|
-
}
|
|
118
|
-
return payload
|
|
119
|
-
.map((pr) => ({
|
|
120
|
-
number: Number.isInteger(pr?.number) ? pr.number : null,
|
|
121
|
-
title: typeof pr?.title === "string" ? pr.title : "",
|
|
122
|
-
url: typeof pr?.url === "string" ? pr.url : null,
|
|
123
|
-
isDraft: Boolean(pr?.isDraft),
|
|
124
|
-
headRefName: typeof pr?.headRefName === "string" ? pr.headRefName : null,
|
|
125
|
-
authorLogin: typeof pr?.author?.login === "string" ? pr.author.login : null,
|
|
126
|
-
}))
|
|
127
|
-
.filter((pr) => pr.number !== null)
|
|
128
|
-
.sort((left, right) => left.number - right.number);
|
|
129
|
-
}
|
|
130
93
|
export async function detectPrState(
|
|
131
94
|
pr,
|
|
132
95
|
{
|
|
@@ -10,6 +10,21 @@ Canonical owner for merge preconditions across all workflow families.
|
|
|
10
10
|
4. ✅ All review threads resolved
|
|
11
11
|
5. ✅ Explicit merge authorization from operator
|
|
12
12
|
6. ✅ PR body contains `Closes #N` or `Fixes #N`
|
|
13
|
+
7. ✅ PR **title** free of merge-blocking markers — `WIP`, `[WIP]`, `DRAFT`, `DO NOT MERGE`, `🚧` (case-insensitive)
|
|
14
|
+
|
|
15
|
+
## Title markers
|
|
16
|
+
|
|
17
|
+
The PR title is a contract surface, so a merge-blocking marker in the title is enforced
|
|
18
|
+
deterministically (`findBlockingTitleMarkers` in `@dev-loops/core/loop/pr-title-markers`), not
|
|
19
|
+
just reviewed:
|
|
20
|
+
|
|
21
|
+
- At the **draft → ready-for-review** transition: `ready-for-review` refuses `gh pr ready` while the
|
|
22
|
+
title carries a marker.
|
|
23
|
+
- At the **pre-approval gate boundary and final approval** (for non-draft PRs): the gate coordinator
|
|
24
|
+
returns `title_marker_blocked` so a PR un-drafted externally still cannot enter pre-approval or
|
|
25
|
+
reach merge-ready with a marked title.
|
|
26
|
+
|
|
27
|
+
A marker is allowed only while the PR is still in draft; it must be removed before the PR leaves draft.
|
|
13
28
|
|
|
14
29
|
## Merge authorization
|
|
15
30
|
|
|
@@ -44,6 +44,7 @@ It does not redefine helper transport mechanics, reviewer-loop internals, conduc
|
|
|
44
44
|
- Draft existence alone is **not** draft-gate readiness.
|
|
45
45
|
- A PR must clear the draft-stage gate for the current head before Copilot review may be requested.
|
|
46
46
|
- Ready -> draft resets the lifecycle back into draft-stage gating.
|
|
47
|
+
- A merge-blocking marker in the PR **title** (`WIP`/`[WIP]`/`DRAFT`/`DO NOT MERGE`/`🚧`, case-insensitive) blocks the draft -> ready transition and, for non-draft PRs, blocks entry to the pre-approval gate and final approval (`title_marker_blocked`). Markers are permitted only while the PR remains draft. See [Merge preconditions](merge-preconditions.md#title-markers).
|
|
47
48
|
- Human approval / merge are explicit external waits, not hidden remediation states.
|
|
48
49
|
|
|
49
50
|
## Two required local gates
|