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.
- package/.claude/.claude-plugin/plugin.json +9 -2
- package/.claude/agents/dev-loop.md +2 -1
- 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/dev-loop/SKILL.md +5 -5
- package/.claude/skills/docs/merge-preconditions.md +15 -0
- package/.claude/skills/docs/pr-lifecycle-contract.md +1 -0
- package/CHANGELOG.md +52 -0
- package/agents/dev-loop.agent.md +5 -1
- package/cli/index.mjs +3 -1
- package/package.json +2 -2
- package/scripts/_cli-primitives.mjs +2 -0
- package/scripts/claude/generate-claude-assets.mjs +71 -3
- package/scripts/docs/validate-links.mjs +20 -11
- package/scripts/github/capture-review-threads.mjs +32 -14
- package/scripts/github/detect-checkpoint-evidence.mjs +28 -11
- package/scripts/github/detect-linked-issue-pr.mjs +22 -10
- package/scripts/github/manage-sub-issues.mjs +37 -16
- package/scripts/github/probe-copilot-review.mjs +24 -12
- package/scripts/github/ready-for-review.mjs +22 -10
- package/scripts/github/reconcile-draft-gate.mjs +22 -10
- package/scripts/github/reply-resolve-review-threads.mjs +34 -15
- package/scripts/github/request-copilot-review.mjs +28 -11
- package/scripts/github/resolve-tracker-local-spec.mjs +29 -12
- package/scripts/github/stage-reviewer-draft.mjs +32 -14
- package/scripts/github/upsert-checkpoint-verdict.mjs +49 -26
- package/scripts/github/write-gate-findings-log.mjs +41 -20
- package/scripts/loop/_loop-pr-aggregation.mjs +45 -0
- package/scripts/loop/build-handoff-envelope.mjs +32 -14
- package/scripts/loop/conductor-monitor.mjs +26 -47
- package/scripts/loop/conductor.mjs +31 -12
- package/scripts/loop/copilot-pr-handoff.mjs +31 -14
- package/scripts/loop/debt-remediate.mjs +28 -11
- package/scripts/loop/detect-copilot-loop-state.mjs +29 -12
- package/scripts/loop/detect-copilot-session-activity.mjs +29 -12
- package/scripts/loop/detect-initial-copilot-pr-state.mjs +26 -10
- package/scripts/loop/detect-internal-only-pr.mjs +31 -13
- package/scripts/loop/detect-issue-refinement-artifact.mjs +29 -12
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +28 -11
- package/scripts/loop/detect-reviewer-loop-state.mjs +35 -16
- package/scripts/loop/detect-stale-runner.mjs +32 -14
- package/scripts/loop/detect-tracker-pr-state.mjs +23 -8
- package/scripts/loop/info.mjs +28 -10
- package/scripts/loop/inspect-run-viewer/cli.mjs +44 -21
- package/scripts/loop/inspect-run.mjs +35 -16
- package/scripts/loop/outer-loop.mjs +35 -16
- package/scripts/loop/pr-runner-coordination.mjs +31 -12
- package/scripts/loop/pre-commit-branch-guard.mjs +26 -9
- package/scripts/loop/pre-flight-gate.mjs +25 -9
- package/scripts/loop/pre-pr-ready-gate.mjs +24 -8
- package/scripts/loop/pre-push-main-guard.mjs +19 -5
- package/scripts/loop/pre-write-remote-freshness-guard.mjs +23 -7
- package/scripts/loop/resolve-dev-loop-startup.mjs +29 -12
- package/scripts/loop/run-conductor-cycle.mjs +25 -47
- package/scripts/loop/run-refinement-audit.mjs +44 -22
- package/scripts/loop/run-watch-cycle.mjs +28 -12
- package/scripts/loop/steer-loop.mjs +122 -62
- package/scripts/loop/watch-initial-copilot-pr.mjs +28 -12
- package/scripts/projects/archive-done-items.mjs +410 -0
- package/scripts/projects/reorder-queue-item.mjs +334 -76
- package/scripts/refine/_refine-helpers.mjs +21 -9
- package/scripts/refine/verify.mjs +31 -13
- package/skills/dev-loop/SKILL.md +9 -5
- 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.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": [
|
|
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
|
-
|
|
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
|
|
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
|
|
|
@@ -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
|