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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dev-loops",
3
- "version": "0.2.5",
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": ["dev-loop", "workflow", "github", "copilot", "claude-code", "plugin"]
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 `@dev-loops/core/claude/hook-decisions` deciders — these helpers are only the edge IO.
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 "@dev-loops/core/loop/bash-command-classify";
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 "@dev-loops/core/claude/hook-decisions";
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 "@dev-loops/core/loop/bash-command-classify";
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 "@dev-loops/core/claude/hook-decisions";
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.5"
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.5",
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
- return [
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 { runChild, requireOptionValue } from "../_cli-primitives.mjs";
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 { runChild, requireOptionValue } from "../_cli-primitives.mjs";
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