dev-loops 0.2.5 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/.claude/.claude-plugin/plugin.json +9 -2
  2. package/.claude/agents/dev-loop.md +2 -1
  3. package/.claude/hooks/_bash-command-classify.mjs +164 -0
  4. package/.claude/hooks/_hook-decisions.mjs +130 -0
  5. package/.claude/hooks/_hook-io.mjs +1 -1
  6. package/.claude/hooks/_run-context.mjs +179 -0
  7. package/.claude/hooks/post-tool-use-merge.mjs +1 -1
  8. package/.claude/hooks/pre-tool-use-bash-gate.mjs +2 -2
  9. package/.claude/hooks/pre-tool-use-write-guard.mjs +1 -1
  10. package/.claude/skills/dev-loop/SKILL.md +5 -5
  11. package/.claude/skills/docs/merge-preconditions.md +15 -0
  12. package/.claude/skills/docs/pr-lifecycle-contract.md +1 -0
  13. package/CHANGELOG.md +52 -0
  14. package/agents/dev-loop.agent.md +5 -1
  15. package/cli/index.mjs +3 -1
  16. package/package.json +2 -2
  17. package/scripts/_cli-primitives.mjs +2 -0
  18. package/scripts/claude/generate-claude-assets.mjs +71 -3
  19. package/scripts/docs/validate-links.mjs +20 -11
  20. package/scripts/github/capture-review-threads.mjs +32 -14
  21. package/scripts/github/detect-checkpoint-evidence.mjs +28 -11
  22. package/scripts/github/detect-linked-issue-pr.mjs +22 -10
  23. package/scripts/github/manage-sub-issues.mjs +37 -16
  24. package/scripts/github/probe-copilot-review.mjs +24 -12
  25. package/scripts/github/ready-for-review.mjs +22 -10
  26. package/scripts/github/reconcile-draft-gate.mjs +22 -10
  27. package/scripts/github/reply-resolve-review-threads.mjs +34 -15
  28. package/scripts/github/request-copilot-review.mjs +28 -11
  29. package/scripts/github/resolve-tracker-local-spec.mjs +29 -12
  30. package/scripts/github/stage-reviewer-draft.mjs +32 -14
  31. package/scripts/github/upsert-checkpoint-verdict.mjs +49 -26
  32. package/scripts/github/write-gate-findings-log.mjs +41 -20
  33. package/scripts/loop/_loop-pr-aggregation.mjs +45 -0
  34. package/scripts/loop/build-handoff-envelope.mjs +32 -14
  35. package/scripts/loop/conductor-monitor.mjs +26 -47
  36. package/scripts/loop/conductor.mjs +31 -12
  37. package/scripts/loop/copilot-pr-handoff.mjs +31 -14
  38. package/scripts/loop/debt-remediate.mjs +28 -11
  39. package/scripts/loop/detect-copilot-loop-state.mjs +29 -12
  40. package/scripts/loop/detect-copilot-session-activity.mjs +29 -12
  41. package/scripts/loop/detect-initial-copilot-pr-state.mjs +26 -10
  42. package/scripts/loop/detect-internal-only-pr.mjs +31 -13
  43. package/scripts/loop/detect-issue-refinement-artifact.mjs +29 -12
  44. package/scripts/loop/detect-pr-gate-coordination-state.mjs +28 -11
  45. package/scripts/loop/detect-reviewer-loop-state.mjs +35 -16
  46. package/scripts/loop/detect-stale-runner.mjs +32 -14
  47. package/scripts/loop/detect-tracker-pr-state.mjs +23 -8
  48. package/scripts/loop/info.mjs +28 -10
  49. package/scripts/loop/inspect-run-viewer/cli.mjs +44 -21
  50. package/scripts/loop/inspect-run.mjs +35 -16
  51. package/scripts/loop/outer-loop.mjs +35 -16
  52. package/scripts/loop/pr-runner-coordination.mjs +31 -12
  53. package/scripts/loop/pre-commit-branch-guard.mjs +26 -9
  54. package/scripts/loop/pre-flight-gate.mjs +25 -9
  55. package/scripts/loop/pre-pr-ready-gate.mjs +24 -8
  56. package/scripts/loop/pre-push-main-guard.mjs +19 -5
  57. package/scripts/loop/pre-write-remote-freshness-guard.mjs +23 -7
  58. package/scripts/loop/resolve-dev-loop-startup.mjs +29 -12
  59. package/scripts/loop/run-conductor-cycle.mjs +25 -47
  60. package/scripts/loop/run-refinement-audit.mjs +44 -22
  61. package/scripts/loop/run-watch-cycle.mjs +28 -12
  62. package/scripts/loop/steer-loop.mjs +122 -62
  63. package/scripts/loop/watch-initial-copilot-pr.mjs +28 -12
  64. package/scripts/projects/archive-done-items.mjs +410 -0
  65. package/scripts/projects/reorder-queue-item.mjs +334 -76
  66. package/scripts/refine/_refine-helpers.mjs +21 -9
  67. package/scripts/refine/verify.mjs +31 -13
  68. package/skills/dev-loop/SKILL.md +9 -5
  69. package/skills/docs/merge-preconditions.md +15 -0
  70. package/skills/docs/pr-lifecycle-contract.md +1 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dev-loops",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
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
  }
@@ -21,7 +21,8 @@ The envelope is the primary handoff artifact — it is derived from resolver out
21
21
  - `acceptance` — self-validation criteria for declaring completion
22
22
 
23
23
  **Construction sequence:**
24
- 1. Run the deterministic startup resolver to produce the authoritative state bundle: `npx dev-loops loop startup --issue <n>` for issues, or `npx dev-loops loop startup --pr <n>` for PRs.
24
+
25
+ 1. Run the deterministic startup resolver to produce the authoritative state bundle: `npx dev-loops@0.2.7 loop startup --issue <n>` for issues, or `npx dev-loops@0.2.7 loop startup --pr <n>` for PRs.
25
26
  2. Pass the resolver output, resolved settings (merged from `.devloops` and `.pi/dev-loop/defaults.yaml`), and current gate state to `buildDevLoopHandoffEnvelope()`.
26
27
  3. **Validate the envelope** with `validateHandoffEnvelope()` before consuming any field. If validation returns `ok: false`, reject the handoff with the structured error — do not load requiredReads, do not execute nextAction, do not delegate.
27
28
  4. Read the envelope as the first artifact.
@@ -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
 
@@ -25,7 +25,7 @@ Required installed runtime contract docs are shared bundled copies under `../doc
25
25
 
26
26
  > Under the Claude Code harness the dev-loop runs as a single agent: run these steps directly — no read-only boundary and no separate async-subagent dispatch. See [Main Agent Contract](../docs/main-agent-contract.md).
27
27
 
28
- Resolve authoritative state via the startup resolver (`npx dev-loops loop startup --issue <n>` for issues, `npx dev-loops loop startup --pr <n>` for PRs), then immediately build the handoff envelope via `npx dev-loops loop build-envelope --input <resolver-output.json>`. The envelope determines `requiredReads`, `nextAction`, `stopRules`, and `acceptance` — load only those files, execute only that bounded task. It is the first handoff artifact consumed before loading any route pack. See [Workflow Handoff Contract](../docs/workflow-handoff-contract.md) for the derivation contract.
28
+ Resolve authoritative state via the startup resolver (`npx dev-loops@0.2.7 loop startup --issue <n>` for issues, `npx dev-loops@0.2.7 loop startup --pr <n>` for PRs), then immediately build the handoff envelope via `npx dev-loops@0.2.7 loop build-envelope --input <resolver-output.json>`. The envelope determines `requiredReads`, `nextAction`, `stopRules`, and `acceptance` — load only those files, execute only that bounded task. It is the first handoff artifact consumed before loading any route pack. See [Workflow Handoff Contract](../docs/workflow-handoff-contract.md) for the derivation contract.
29
29
 
30
30
  **Retrospective checkpoint gate:** the resolver reads `.pi/dev-loop-retrospective-checkpoint.json` and injects the state. When the checkpoint is `missing` and the repo config `workflow.requireRetrospective` (set via `.devloops` at repo root) is `true`, the resolver returns `needs_reconcile`. Complete or explicitly skip the retrospective before starting.
31
31
 
@@ -99,10 +99,10 @@ When `@dev-loops/core` is available again, switch back to the full helper. The f
99
99
 
100
100
  ## Read-only info shortcut
101
101
 
102
- Info/handoff requests can be served directly via `npx dev-loops loop info` (read-only; no full dev-loop run required):
103
- - `npx dev-loops loop info --issue <n>` — human-readable issue state summary (strategy, route, linked PR, next action)
104
- - `npx dev-loops loop info --pr <n>` — human-readable PR state summary (branch, CI, threads, rounds, action)
105
- - `npx dev-loops loop info --issue <n> --json` — machine-readable JSON output
102
+ Info/handoff requests can be served directly via `npx dev-loops@0.2.7 loop info` (read-only; no full dev-loop run required):
103
+ - `npx dev-loops@0.2.7 loop info --issue <n>` — human-readable issue state summary (strategy, route, linked PR, next action)
104
+ - `npx dev-loops@0.2.7 loop info --pr <n>` — human-readable PR state summary (branch, CI, threads, rounds, action)
105
+ - `npx dev-loops@0.2.7 loop info --issue <n> --json` — machine-readable JSON output
106
106
 
107
107
  ## Guard rules
108
108
 
@@ -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,58 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## 0.2.7
6
+
7
+ ### Fixed
8
+
9
+ - **Deterministic, harness-aware dev-loops CLI invocation** (#801, #833). Pi runtime skills/agents now invoke the package-local `node <dev-loops-package-root>/cli/index.mjs`; the generated Claude tree pins `npx dev-loops@<version>` (version injected at generation time) so the plugin and CLI no longer drift.
10
+ - **Round-cap Copilot-gate deadlock resolved** (#848). At the round cap with clean threads + green CI, the loop routes to a clean fallback instead of dead-ending at `waiting_for_copilot_review` when a lingering reviewer assignment / post-cap push leaves the head unreviewed. The pre-approval gate still reviews any post-cap head.
11
+ - **Draft-gate ordering after external un-draft** (#836). Verified + regression-guarded: a non-draft PR without clean `draft_gate` evidence is routed to `reconcile_draft_gate` and cannot merge; the relayed-authorization deadlock is moot under the single-agent Claude harness.
12
+
13
+ ### Added
14
+
15
+ - **Projects board reorder + Done-cleanup CLI** (#789). `project reorder move-to-top|move-after|order` (with `--dry-run`, diff-friendly output, cross-column fail-closed) and `project archive-done [--older-than]`.
16
+ - **Loop-state-driven board status sync** (#793). Board Status column is derived from the loop state via a pure, config-driven mapping (`queue.statusColumns` / `queue.stateColumnMap`), opt-in, fail-open, reverse-safe.
17
+
18
+ ### Changed
19
+
20
+ - **Arg parsing migrated to `node:util.parseArgs`** (#808). All hand-rolled `while/shift` parsers (49 scripts/modules + 3 core files) now use `parseArgs` via shared adapters, with CLI contracts preserved. (Index-based parsers tracked in #857.)
21
+
22
+ ## 0.2.6
23
+
24
+ ### Fixed
25
+
26
+ - **Claude plugin hooks are self-contained** (#843). The bundled PreToolUse/PostToolUse hooks
27
+ imported a bare `@dev-loops/core`, which is unresolvable from the marketplace plugin cache (no
28
+ `node_modules` there), so every hook crashed on load — the two PreToolUse gates were silently
29
+ failing open. The asset generator now emits a vendored, relative-import hook bundle
30
+ (`.claude/hooks/_*.mjs`) from the canonical core modules, drift-guarded by the no-drift check.
31
+ - **Retrospective gate is opt-in for consumers** (#841). `extension-defaults.yaml` shipped
32
+ `requireRetrospective`/`requireRetrospectiveGate: true`, forcing the retrospective merge gate on
33
+ every consumer's product PRs against the code default and the contract. Both now default `false`;
34
+ the dev-loops repo opts in via its own `.devloops`.
35
+ - **Dev mode is opt-in for consumers** (#846). `extension-defaults.yaml` shipped
36
+ `devModeDefault: true`, pushing every consumer's product phases into the loop's self-improvement
37
+ mode (which edits the loop's own skill/agent prompts). Now defaults `false`; the dev-loops repo
38
+ opts in via `.devloops`.
39
+
40
+ ### Added
41
+
42
+ - **Merge-blocking PR-title gate** (#842). The gate pipeline now flags `WIP`/`[WIP]`/`DRAFT`/
43
+ `DO NOT MERGE`/`🚧` (case-insensitive) in the PR **title**, blocking the draft→ready transition
44
+ and — for non-draft PRs — entry to the pre-approval gate and final approval. Documented in the
45
+ merge-preconditions and PR-lifecycle contracts.
46
+ - **Effective async-start mode is surfaced** (#834). The handoff envelope now reports
47
+ `asyncStartEffective` and `asyncStartRelaxedBy` alongside the unchanged configured
48
+ `asyncStartMode`, so the Claude harness relaxation (`required`→`allowed`) is visible instead of
49
+ reading as a contradiction.
50
+
51
+ ### Changed
52
+
53
+ - **Deduplicated PR aggregation** (#809). The duplicated `listOpenPrs` helper is extracted into a
54
+ shared `scripts/loop/_loop-pr-aggregation.mjs` and reused by `conductor-monitor.mjs` and
55
+ `run-conductor-cycle.mjs`. No behavior change.
56
+
5
57
  ## 0.2.5
6
58
 
7
59
  ### Changed