@dev-loops/core 0.1.3 → 0.2.1
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/package.json +6 -1
- package/src/claude/asset-generation.mjs +182 -0
- package/src/claude/headless-entry.mjs +69 -0
- package/src/claude/hook-decisions.mjs +129 -0
- package/src/harness/claude-extension-adapter.mjs +102 -0
- package/src/harness/extension-adapter.mjs +70 -0
- package/src/harness/index.mjs +2 -0
- package/src/loop/async-start-contract.mjs +27 -21
- package/src/loop/bash-command-classify.mjs +163 -0
- package/src/loop/run-context.mjs +157 -0
- package/src/loop/worktree-guard.mjs +25 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dev-loops/core",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Shared deterministic support package for dev-loop skills, repo-local scripts, and GitHub automation.",
|
|
6
6
|
"exports": {
|
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
"./cli/primitives": "./src/cli/primitives.mjs",
|
|
10
10
|
"./cli/retry-wrapper": "./src/cli/retry-wrapper.mjs",
|
|
11
11
|
"./cli/subcommand-runner": "./src/cli/subcommand-runner.mjs",
|
|
12
|
+
"./claude/asset-generation": "./src/claude/asset-generation.mjs",
|
|
13
|
+
"./claude/headless-entry": "./src/claude/headless-entry.mjs",
|
|
14
|
+
"./claude/hook-decisions": "./src/claude/hook-decisions.mjs",
|
|
12
15
|
"./config": "./src/config/config.mjs",
|
|
13
16
|
"./debt/cluster": "./src/debt/cluster.mjs",
|
|
14
17
|
"./debt/finding": "./src/debt/debt-finding.mjs",
|
|
@@ -21,6 +24,7 @@
|
|
|
21
24
|
"./github/repo-slug": "./src/github/repo-slug.mjs",
|
|
22
25
|
"./github/review-threads": "./src/github/review-threads.mjs",
|
|
23
26
|
"./loop/async-start-contract": "./src/loop/async-start-contract.mjs",
|
|
27
|
+
"./loop/bash-command-classify": "./src/loop/bash-command-classify.mjs",
|
|
24
28
|
"./loop/conductor-routing": "./src/loop/conductor-routing.mjs",
|
|
25
29
|
"./loop/copilot-ci-status": "./src/loop/copilot-ci-status.mjs",
|
|
26
30
|
"./loop/copilot-loop-iterations": "./src/loop/copilot-loop-iterations.mjs",
|
|
@@ -36,6 +40,7 @@
|
|
|
36
40
|
"./loop/queue-parallel": "./src/loop/queue-parallel.mjs",
|
|
37
41
|
"./loop/queue-state": "./src/loop/queue-state.mjs",
|
|
38
42
|
"./loop/reviewer-loop-state": "./src/loop/reviewer-loop-state.mjs",
|
|
43
|
+
"./loop/run-context": "./src/loop/run-context.mjs",
|
|
39
44
|
"./loop/run-inspection": "./src/loop/run-inspection.mjs",
|
|
40
45
|
"./loop/steering": "./src/loop/steering.mjs",
|
|
41
46
|
"./loop/timeout-policy": "./src/loop/timeout-policy.mjs",
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate Claude Code assets (.claude/agents/*.md, .claude/skills/<name>/SKILL.md) from the
|
|
3
|
+
* canonical Pi sources (agents/*.agent.md, skills/**\/SKILL.md), which remain the single
|
|
4
|
+
* source of truth. These are pure transforms (no file IO) so they are deterministic and
|
|
5
|
+
* unit-testable; the orchestration/IO lives in scripts/claude/generate-claude-assets.mjs.
|
|
6
|
+
*
|
|
7
|
+
* Pi→Claude tool-name mapping (confirmed against Claude Code docs):
|
|
8
|
+
* read→Read, search→Grep+Glob, execute→Bash, bash→Bash, edit→Edit, write→Write,
|
|
9
|
+
* agent→Agent, subagent→Agent, todo→TodoWrite, review_loop→Agent (the review subagent).
|
|
10
|
+
* Frontmatter *tool lists* are rewritten, and bodies are copied through `stripPiOnlyBlocks`
|
|
11
|
+
* (#817): `<!-- pi-only -->`…`<!-- /pi-only -->` sections are removed for the Claude output so
|
|
12
|
+
* Pi-runtime-specific prose (e.g. `tools: [subagent]`/`maxSubagentDepth` assertions, the
|
|
13
|
+
* `contact_supervisor`/`pi-intercom` bug guidance) doesn't contradict the Claude assets. The
|
|
14
|
+
* source stays Pi-complete; general `subagent` prose is preserved (Claude has subagents too).
|
|
15
|
+
*
|
|
16
|
+
* Frontmatter handling:
|
|
17
|
+
* - Agents keep name/description/tools (comma-separated, per Claude's agent format) and drop
|
|
18
|
+
* Pi-only fields that have no faithful Claude agent equivalent: argument-hint,
|
|
19
|
+
* systemPromptMode, inheritProjectContext, inheritSkills, maxSubagentDepth, user-invocable.
|
|
20
|
+
* (Subagents are never user-invocable in Claude; maxSubagentDepth/inherit* are Pi
|
|
21
|
+
* async-dispatch concerns. The user entrypoint under Claude is the dev-loop *skill*.)
|
|
22
|
+
* - Skills keep name/description/allowed-tools (space-separated) and preserve `user-invocable`
|
|
23
|
+
* (Claude honors it 1:1 — `user-invocable: false` hides the skill from the `/` menu). The
|
|
24
|
+
* Pi-specific `compatibility` text is dropped (no Claude field).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { parse as parseYaml } from "yaml";
|
|
28
|
+
|
|
29
|
+
/** Pi→Claude tool-name map. A Pi name may expand to multiple Claude tools (search→Grep,Glob). */
|
|
30
|
+
export const TOOL_NAME_MAP = Object.freeze({
|
|
31
|
+
read: ["Read"],
|
|
32
|
+
search: ["Grep", "Glob"],
|
|
33
|
+
execute: ["Bash"],
|
|
34
|
+
bash: ["Bash"],
|
|
35
|
+
edit: ["Edit"],
|
|
36
|
+
write: ["Write"],
|
|
37
|
+
agent: ["Agent"],
|
|
38
|
+
subagent: ["Agent"],
|
|
39
|
+
todo: ["TodoWrite"],
|
|
40
|
+
review_loop: ["Agent"],
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const GENERATED_NOTE = (source) =>
|
|
44
|
+
`<!-- GENERATED from ${source} by scripts/claude/generate-claude-assets.mjs — do not edit; edit the source and regenerate. -->`;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Strip Pi-runtime-only prose blocks from a body for the Claude output (#817).
|
|
48
|
+
*
|
|
49
|
+
* The canonical sources stay Pi-complete; sections that are Pi-runtime-specific and misleading
|
|
50
|
+
* under Claude (e.g. `tools: [subagent]`/`maxSubagentDepth` assertions that contradict the
|
|
51
|
+
* mapped Claude frontmatter, or the `contact_supervisor`/`pi-intercom` Pi-bug guidance) are
|
|
52
|
+
* wrapped in the source with `<!-- pi-only -->` … `<!-- /pi-only -->` and removed here. Resulting
|
|
53
|
+
* blank-line runs are collapsed so the stripped body stays clean. `subagent` prose in general is
|
|
54
|
+
* NOT stripped — Claude has subagents too.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} body
|
|
57
|
+
* @returns {string}
|
|
58
|
+
*/
|
|
59
|
+
export function stripPiOnlyBlocks(body) {
|
|
60
|
+
const s = String(body);
|
|
61
|
+
// True no-op when there are no markers — must NOT touch blank-line runs in marker-free
|
|
62
|
+
// bodies (they may carry intentional spacing; collapsing them would drift the committed tree).
|
|
63
|
+
if (!s.includes("<!-- pi-only -->")) {
|
|
64
|
+
return s;
|
|
65
|
+
}
|
|
66
|
+
// Markers must not nest; pairs are matched non-greedily. After removing the block(s), collapse
|
|
67
|
+
// any run of 3+ newlines in this (marker-bearing) body to a single blank line — this tidies the
|
|
68
|
+
// gaps left by removal; marker-free bodies are returned untouched above, so their intentional
|
|
69
|
+
// blank runs are never affected.
|
|
70
|
+
return s
|
|
71
|
+
.replace(/[ \t]*<!-- pi-only -->[\s\S]*?<!-- \/pi-only -->[ \t]*\n?/g, "")
|
|
72
|
+
.replace(/\n{3,}/g, "\n\n");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Map a single Pi tool name to its Claude tool name(s).
|
|
77
|
+
* @param {string} name
|
|
78
|
+
* @returns {string[]} Claude tool names (empty if unknown).
|
|
79
|
+
*/
|
|
80
|
+
export function mapTool(name) {
|
|
81
|
+
return TOOL_NAME_MAP[String(name).trim()] ?? [];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Map a list of Pi tool names to deduped Claude tool names, preserving first-seen order.
|
|
86
|
+
* @param {string[]} tools
|
|
87
|
+
* @returns {string[]}
|
|
88
|
+
*/
|
|
89
|
+
export function mapTools(tools) {
|
|
90
|
+
const out = [];
|
|
91
|
+
for (const tool of tools ?? []) {
|
|
92
|
+
for (const mapped of mapTool(tool)) {
|
|
93
|
+
if (!out.includes(mapped)) {
|
|
94
|
+
out.push(mapped);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Split a source markdown file into its parsed frontmatter and verbatim body.
|
|
103
|
+
* @param {string} raw
|
|
104
|
+
* @returns {{ frontmatter: Record<string, unknown>, body: string }}
|
|
105
|
+
*/
|
|
106
|
+
export function splitFrontmatter(raw, source) {
|
|
107
|
+
const where = source ? ` (${source})` : "";
|
|
108
|
+
const match = String(raw).match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
109
|
+
if (!match) {
|
|
110
|
+
throw new Error(`source file is missing a leading YAML frontmatter block${where}`);
|
|
111
|
+
}
|
|
112
|
+
let frontmatter;
|
|
113
|
+
try {
|
|
114
|
+
frontmatter = parseYaml(match[1]) ?? {};
|
|
115
|
+
} catch (error) {
|
|
116
|
+
throw new Error(`failed to parse YAML frontmatter${where}: ${error instanceof Error ? error.message : String(error)}`);
|
|
117
|
+
}
|
|
118
|
+
return { frontmatter, body: match[2] };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Normalize a Pi `tools` value (YAML list) into a string[] of names. */
|
|
122
|
+
function normalizeToolList(value) {
|
|
123
|
+
if (Array.isArray(value)) {
|
|
124
|
+
return value.map((v) => String(v).trim()).filter(Boolean);
|
|
125
|
+
}
|
|
126
|
+
if (typeof value === "string") {
|
|
127
|
+
return value.split(/[\s,]+/).map((v) => v.trim()).filter(Boolean);
|
|
128
|
+
}
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Transform a canonical `agents/*.agent.md` into a Claude `.claude/agents/*.md` document.
|
|
134
|
+
* @param {{ source: string, raw: string }} input
|
|
135
|
+
* @returns {string} Full generated file content.
|
|
136
|
+
*/
|
|
137
|
+
export function transformAgent({ source, raw }) {
|
|
138
|
+
const { frontmatter, body: rawBody } = splitFrontmatter(raw, source);
|
|
139
|
+
const body = stripPiOnlyBlocks(rawBody);
|
|
140
|
+
const tools = mapTools(normalizeToolList(frontmatter.tools));
|
|
141
|
+
|
|
142
|
+
const lines = ["---"];
|
|
143
|
+
lines.push(`name: ${JSON.stringify(String(frontmatter.name ?? ""))}`);
|
|
144
|
+
if (frontmatter.description != null) {
|
|
145
|
+
lines.push(`description: ${JSON.stringify(String(frontmatter.description))}`);
|
|
146
|
+
}
|
|
147
|
+
if (tools.length > 0) {
|
|
148
|
+
lines.push(`tools: ${tools.join(", ")}`);
|
|
149
|
+
}
|
|
150
|
+
lines.push("---");
|
|
151
|
+
lines.push(GENERATED_NOTE(source));
|
|
152
|
+
lines.push("");
|
|
153
|
+
return `${lines.join("\n")}\n${body}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Transform a canonical `skills/<name>/SKILL.md` into a Claude `.claude/skills/<name>/SKILL.md`.
|
|
158
|
+
* @param {{ source: string, raw: string }} input
|
|
159
|
+
* @returns {string} Full generated file content.
|
|
160
|
+
*/
|
|
161
|
+
export function transformSkill({ source, raw }) {
|
|
162
|
+
const { frontmatter, body: rawBody } = splitFrontmatter(raw, source);
|
|
163
|
+
const body = stripPiOnlyBlocks(rawBody);
|
|
164
|
+
const tools = mapTools(normalizeToolList(frontmatter["allowed-tools"]));
|
|
165
|
+
|
|
166
|
+
const lines = ["---"];
|
|
167
|
+
lines.push(`name: ${JSON.stringify(String(frontmatter.name ?? ""))}`);
|
|
168
|
+
if (frontmatter.description != null) {
|
|
169
|
+
lines.push(`description: ${JSON.stringify(String(frontmatter.description))}`);
|
|
170
|
+
}
|
|
171
|
+
if (tools.length > 0) {
|
|
172
|
+
lines.push(`allowed-tools: ${tools.join(" ")}`);
|
|
173
|
+
}
|
|
174
|
+
// user-invocable maps 1:1 — Claude honors `user-invocable: false` (hides from the / menu).
|
|
175
|
+
if (typeof frontmatter["user-invocable"] === "boolean") {
|
|
176
|
+
lines.push(`user-invocable: ${frontmatter["user-invocable"]}`);
|
|
177
|
+
}
|
|
178
|
+
lines.push("---");
|
|
179
|
+
lines.push(GENERATED_NOTE(source));
|
|
180
|
+
lines.push("");
|
|
181
|
+
return `${lines.join("\n")}\n${body}`;
|
|
182
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless dev-loop entry helpers for Claude Code (#775).
|
|
3
|
+
*
|
|
4
|
+
* Builds a non-interactive `claude -p` invocation that runs the dev-loop, with the CA2 run id
|
|
5
|
+
* propagated into the spawned process's environment via `runContextEnv`. That propagation is
|
|
6
|
+
* what lets the #773 PreToolUse write-guard recognize the headless session as the dev-loop
|
|
7
|
+
* subagent context (completing the CA2→CA4 wiring for the headless path). The repo's
|
|
8
|
+
* `.claude/settings.json` hooks apply automatically to the spawned session.
|
|
9
|
+
*
|
|
10
|
+
* Pure: builds the command/args/env; the actual spawn lives in the entry script
|
|
11
|
+
* (scripts/claude/headless-dev-loop.mjs), which is the only part that needs `claude` on PATH.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { runContextEnv } from "../loop/run-context.mjs";
|
|
15
|
+
|
|
16
|
+
/** Default Claude CLI binary name. */
|
|
17
|
+
export const DEFAULT_CLAUDE_BIN = "claude";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Build the headless dev-loop prompt for a target.
|
|
21
|
+
*
|
|
22
|
+
* @param {Object} [params]
|
|
23
|
+
* @param {number|string} [params.issue]
|
|
24
|
+
* @param {number|string} [params.pr]
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
export function buildDevLoopPrompt({ issue, pr } = {}) {
|
|
28
|
+
if (issue != null && `${issue}`.trim() && pr != null && `${pr}`.trim()) {
|
|
29
|
+
throw new TypeError("buildDevLoopPrompt: provide at most one target (issue or pr), not both");
|
|
30
|
+
}
|
|
31
|
+
if (issue != null && `${issue}`.trim()) {
|
|
32
|
+
return `Run the dev-loop for issue #${issue}. Use the /dev-loop skill; routing resolves the rest.`;
|
|
33
|
+
}
|
|
34
|
+
if (pr != null && `${pr}`.trim()) {
|
|
35
|
+
return `Run the dev-loop for PR #${pr}. Use the /dev-loop skill; routing resolves the rest.`;
|
|
36
|
+
}
|
|
37
|
+
return "Run the dev-loop. Use the /dev-loop skill; routing resolves the current state.";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Build a non-interactive `claude -p` invocation for the dev-loop.
|
|
42
|
+
*
|
|
43
|
+
* @param {Object} params
|
|
44
|
+
* @param {string} params.prompt - The headless prompt (see buildDevLoopPrompt).
|
|
45
|
+
* @param {string} params.runId - The dev-loop run id to propagate (see ensureRunId).
|
|
46
|
+
* @param {string} [params.claudeBin] - Claude CLI binary (default "claude").
|
|
47
|
+
* @param {string[]} [params.extraArgs] - Extra args appended after `-p <prompt>`.
|
|
48
|
+
* @param {Record<string,string|undefined>} [params.baseEnv] - Base env (default process.env).
|
|
49
|
+
* @returns {{ command: string, args: string[], env: Record<string,string|undefined> }}
|
|
50
|
+
*/
|
|
51
|
+
export function buildHeadlessClaudeInvocation({ prompt, runId, claudeBin = DEFAULT_CLAUDE_BIN, extraArgs = [], baseEnv = process.env }) {
|
|
52
|
+
if (typeof prompt !== "string" || prompt.trim().length === 0) {
|
|
53
|
+
throw new TypeError("buildHeadlessClaudeInvocation: prompt must be a non-empty string");
|
|
54
|
+
}
|
|
55
|
+
if (typeof runId !== "string" || runId.trim().length === 0) {
|
|
56
|
+
throw new TypeError("buildHeadlessClaudeInvocation: runId must be a non-empty string");
|
|
57
|
+
}
|
|
58
|
+
if (typeof claudeBin !== "string" || claudeBin.trim().length === 0) {
|
|
59
|
+
throw new TypeError("buildHeadlessClaudeInvocation: claudeBin must be a non-empty string");
|
|
60
|
+
}
|
|
61
|
+
if (!Array.isArray(extraArgs)) {
|
|
62
|
+
throw new TypeError("buildHeadlessClaudeInvocation: extraArgs must be an array");
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
command: claudeBin,
|
|
66
|
+
args: ["-p", prompt, ...extraArgs],
|
|
67
|
+
env: { ...baseEnv, ...runContextEnv(runId) },
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure decision logic for the Claude Code dev-loop hooks (#773).
|
|
3
|
+
*
|
|
4
|
+
* The hook *scripts* are thin: they read the PreToolUse/PostToolUse stdin payload, gather facts
|
|
5
|
+
* (git tracked/ignored status, gate-evidence result), and call these pure deciders. Keeping the
|
|
6
|
+
* decisions here makes the deny/allow boundary fully unit-testable without spawning hooks, and
|
|
7
|
+
* keeps the Claude-specific stdin/stdout IO at the edge.
|
|
8
|
+
*
|
|
9
|
+
* Pure and side-effect free.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { resolveRunId } from "../loop/run-context.mjs";
|
|
13
|
+
import {
|
|
14
|
+
isGhPrReadyCommand,
|
|
15
|
+
extractPrNumberFromGhPrReady,
|
|
16
|
+
extractRepoFlagFromGhPrReady,
|
|
17
|
+
TARGET_REPO_SLUG,
|
|
18
|
+
} from "../loop/bash-command-classify.mjs";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {Object} HookDecision
|
|
22
|
+
* @property {"allow"|"deny"} decision
|
|
23
|
+
* @property {string} [reason] - Human-readable reason (shown to Claude on deny).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const ALLOW = Object.freeze({ decision: "allow" });
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The agent type (Claude `agent_type` / the canonical agent name) that owns repo mutations.
|
|
30
|
+
* Only this subagent — not arbitrary subagents (Explore, Plan, generic Task agents) — may
|
|
31
|
+
* bypass the main-agent read-only boundary.
|
|
32
|
+
*/
|
|
33
|
+
export const DEV_LOOP_AGENT_TYPE = "dev-loop";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Decide whether a PreToolUse Bash command must be blocked by the draft-gate boundary.
|
|
37
|
+
*
|
|
38
|
+
* Mirrors the Pi extension's `onUserBash`: the only blocked case is `gh pr ready` for the
|
|
39
|
+
* target repo without clean draft_gate evidence. Everything else (including merges, which
|
|
40
|
+
* trigger the post-merge step, not a block) is allowed through.
|
|
41
|
+
*
|
|
42
|
+
* @param {Object} params
|
|
43
|
+
* @param {string} params.command - The Bash command string.
|
|
44
|
+
* @param {string|null} [params.repoSlug] - Resolved owner/name of the cwd repo (null if unknown).
|
|
45
|
+
* @param {boolean} [params.gatePassed] - Whether `pre-pr-ready-gate` evidence exists for the PR.
|
|
46
|
+
* @param {string|null} [params.gateError] - Error detail when the gate guard could not run.
|
|
47
|
+
* @returns {HookDecision}
|
|
48
|
+
*/
|
|
49
|
+
export function decideBashGate({ command, repoSlug = null, gatePassed = false, gateError = null }) {
|
|
50
|
+
if (typeof command !== "string" || !isGhPrReadyCommand(command)) {
|
|
51
|
+
return ALLOW;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// An explicit `--repo other/repo` that is not the target → not our concern, pass through.
|
|
55
|
+
const explicitRepo = extractRepoFlagFromGhPrReady(command);
|
|
56
|
+
if (explicitRepo && explicitRepo.toLowerCase() !== TARGET_REPO_SLUG.toLowerCase()) {
|
|
57
|
+
return ALLOW;
|
|
58
|
+
}
|
|
59
|
+
// Only gate within the target repo (case-insensitive — callers may pass an un-lowercased slug).
|
|
60
|
+
if ((repoSlug ?? "").toLowerCase() !== TARGET_REPO_SLUG.toLowerCase()) {
|
|
61
|
+
return ALLOW;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const prNumber = extractPrNumberFromGhPrReady(command);
|
|
65
|
+
if (prNumber === null) {
|
|
66
|
+
return {
|
|
67
|
+
decision: "deny",
|
|
68
|
+
reason:
|
|
69
|
+
"gh pr ready blocked: could not determine the PR number from the command. Include the PR number explicitly.",
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (gateError) {
|
|
74
|
+
return {
|
|
75
|
+
decision: "deny",
|
|
76
|
+
reason: `gh pr ready blocked: draft-gate evidence check failed (${gateError}).`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!gatePassed) {
|
|
81
|
+
return {
|
|
82
|
+
decision: "deny",
|
|
83
|
+
reason: `gh pr ready blocked: no visible clean draft_gate checkpoint verdict comment found for PR #${prNumber}.`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return ALLOW;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Decide whether a PreToolUse Write/Edit must be blocked by the main-agent read-only boundary.
|
|
92
|
+
*
|
|
93
|
+
* Denies a mutation whose target is inside the repo working tree AND not gitignored, when the
|
|
94
|
+
* call originates from the MAIN agent. Allows it only inside the *dev-loop* subagent context:
|
|
95
|
+
* the CA2 run id (`DEVLOOPS_RUN_ID`) is present, or the Claude `agent_type` is the dev-loop
|
|
96
|
+
* agent. A generic subagent (Explore, Plan, an arbitrary Task agent) is NOT authorized — the
|
|
97
|
+
* contract requires mutations to flow through the dev-loop subagent specifically. Non-repo /
|
|
98
|
+
* gitignored paths are always allowed. Strict enforcement is opt-in via `enforce` (the hook
|
|
99
|
+
* derives it from `DEVLOOPS_MAIN_AGENT_READONLY=1`) so adopting the harness does not
|
|
100
|
+
* retroactively break a repo's own interactive dev; default is fail-open.
|
|
101
|
+
*
|
|
102
|
+
* @param {Object} params
|
|
103
|
+
* @param {string} params.filePath - Target file path.
|
|
104
|
+
* @param {boolean} params.isRepoMutation - True if inside the repo working tree AND not gitignored.
|
|
105
|
+
* @param {boolean} [params.enforce] - Strict mode (DEVLOOPS_MAIN_AGENT_READONLY=1).
|
|
106
|
+
* @param {Record<string,string|undefined>} [params.env] - Environment (for the CA2 run id).
|
|
107
|
+
* @param {string|null} [params.agentType] - Claude `agent_type` from the hook payload, if any.
|
|
108
|
+
* @returns {HookDecision}
|
|
109
|
+
*/
|
|
110
|
+
export function decideWriteGuard({ filePath, isRepoMutation, enforce = false, env = {}, agentType = null }) {
|
|
111
|
+
if (!enforce) {
|
|
112
|
+
return ALLOW; // strict enforcement not enabled — fail open
|
|
113
|
+
}
|
|
114
|
+
if (!isRepoMutation) {
|
|
115
|
+
return ALLOW; // non-repo or gitignored path (e.g. /tmp, tmp/) — allowed by the contract
|
|
116
|
+
}
|
|
117
|
+
// Authorized only inside the dev-loop subagent context: CA2 run id, or the dev-loop agent
|
|
118
|
+
// type. Any other subagent type is treated like the main agent and denied.
|
|
119
|
+
if (resolveRunId(env) || agentType === DEV_LOOP_AGENT_TYPE) {
|
|
120
|
+
return ALLOW;
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
decision: "deny",
|
|
124
|
+
reason:
|
|
125
|
+
`Main-agent read-only boundary: refusing to mutate repository path "${filePath}". ` +
|
|
126
|
+
"All repository mutations must flow through the dev-loop subagent. " +
|
|
127
|
+
"See skills/docs/main-agent-contract.md.",
|
|
128
|
+
};
|
|
129
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
import { createExtensionHarnessAdapter } from "./extension-adapter.mjs";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Create the Claude Code extension-surface adapter.
|
|
7
|
+
*
|
|
8
|
+
* Implements the same `ExtensionHarnessAdapter` interface as the Pi adapter so
|
|
9
|
+
* `executeDevLoopsCommand` and the extension wiring run unchanged under Claude.
|
|
10
|
+
*
|
|
11
|
+
* - `exec` shells out via `bash -lc` (Claude has no native exec API in core).
|
|
12
|
+
* - lifecycle `on(...)` registrations are stored in `listeners` for hook-driven
|
|
13
|
+
* dispatch (wired in CA4 / #773); they are not auto-fired here.
|
|
14
|
+
* - `registerCommand(...)` registrations are stored in `commands`.
|
|
15
|
+
* - Claude core has no interactive widget/status surface, so the default
|
|
16
|
+
* `HarnessContext` reports `hasUI: false` and routes `ui` calls to a sink.
|
|
17
|
+
*
|
|
18
|
+
* @param {Object} [options]
|
|
19
|
+
* @param {string} [options.cwd] - Default cwd for exec and contexts (default: process.cwd()).
|
|
20
|
+
* @param {NodeJS.ProcessEnv} [options.env] - Env for exec (default: process.env).
|
|
21
|
+
* @param {(message: string, level: string) => void} [options.onNotify] - Optional sink for
|
|
22
|
+
* `ui.notify` (e.g. console). Defaults to a no-op.
|
|
23
|
+
* @returns {import("./extension-adapter.mjs").ExtensionHarnessAdapter & {
|
|
24
|
+
* listeners: Map<string, Function>,
|
|
25
|
+
* commands: Map<string, import("./extension-adapter.mjs").HarnessCommandConfig>,
|
|
26
|
+
* makeContext: (overrides?: {cwd?: string}) => import("./extension-adapter.mjs").HarnessContext,
|
|
27
|
+
* }}
|
|
28
|
+
*/
|
|
29
|
+
export function createClaudeExtensionAdapter({
|
|
30
|
+
cwd = process.cwd(),
|
|
31
|
+
env = process.env,
|
|
32
|
+
onNotify = () => {},
|
|
33
|
+
} = {}) {
|
|
34
|
+
const listeners = new Map();
|
|
35
|
+
const commands = new Map();
|
|
36
|
+
|
|
37
|
+
function exec(command, options = {}) {
|
|
38
|
+
return new Promise((resolve) => {
|
|
39
|
+
execFile(
|
|
40
|
+
"bash",
|
|
41
|
+
["-lc", command],
|
|
42
|
+
{
|
|
43
|
+
cwd: options.cwd ?? cwd,
|
|
44
|
+
env,
|
|
45
|
+
timeout: options.timeout ?? 0,
|
|
46
|
+
encoding: "utf8",
|
|
47
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
48
|
+
},
|
|
49
|
+
(error, stdout, stderr) => {
|
|
50
|
+
if (error) {
|
|
51
|
+
// Match the documented HarnessExecResult contract: `code` is undefined when
|
|
52
|
+
// the process was killed (e.g. timeout). Consumers branch on `killed` first.
|
|
53
|
+
const killed = Boolean(error.killed);
|
|
54
|
+
resolve({
|
|
55
|
+
code: killed ? undefined : (typeof error.code === "number" ? error.code : 1),
|
|
56
|
+
stdout: stdout ?? "",
|
|
57
|
+
stderr: stderr ?? "",
|
|
58
|
+
killed,
|
|
59
|
+
});
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
resolve({ code: 0, stdout: stdout ?? "", stderr: stderr ?? "", killed: false });
|
|
63
|
+
},
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function makeContext({ cwd: ctxCwd } = {}) {
|
|
69
|
+
return {
|
|
70
|
+
cwd: ctxCwd ?? cwd,
|
|
71
|
+
hasUI: false,
|
|
72
|
+
ui: {
|
|
73
|
+
notify(message, level = "info") {
|
|
74
|
+
onNotify(message, level);
|
|
75
|
+
},
|
|
76
|
+
setWidget() {},
|
|
77
|
+
setStatus() {},
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const adapter = createExtensionHarnessAdapter({
|
|
83
|
+
exec,
|
|
84
|
+
on(event, handler) {
|
|
85
|
+
listeners.set(event, handler);
|
|
86
|
+
},
|
|
87
|
+
registerCommand(name, config) {
|
|
88
|
+
commands.set(name, config);
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Freeze the composite so the validated interface guarantee from the factory carries
|
|
93
|
+
// through to the returned object (which also exposes the #773 dispatch registries).
|
|
94
|
+
return Object.freeze({
|
|
95
|
+
exec: adapter.exec,
|
|
96
|
+
on: adapter.on,
|
|
97
|
+
registerCommand: adapter.registerCommand,
|
|
98
|
+
listeners,
|
|
99
|
+
commands,
|
|
100
|
+
makeContext,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extension-surface harness adapter interface.
|
|
3
|
+
*
|
|
4
|
+
* Abstracts the *extension surface* that the dev-loops runtime uses today — process
|
|
5
|
+
* execution, session lifecycle events, slash-command registration, and a UI surface —
|
|
6
|
+
* so the extension is no longer bound directly to Pi's `ExtensionAPI`/`ExtensionContext`.
|
|
7
|
+
*
|
|
8
|
+
* This is distinct from the context seam in `./adapter.mjs` (cwd/env/repo-root): that one
|
|
9
|
+
* answers "where/how am I running"; this one answers "how do I talk to the harness".
|
|
10
|
+
*
|
|
11
|
+
* Concrete adapters (Pi, Claude, test) implement this so call sites stay harness-agnostic.
|
|
12
|
+
* Keep it minimal — add a method only when a real call site needs it.
|
|
13
|
+
*
|
|
14
|
+
* @typedef {Object} HarnessExecResult
|
|
15
|
+
* @property {number} [code] - Process exit code (undefined when killed).
|
|
16
|
+
* @property {string} [stdout]
|
|
17
|
+
* @property {string} [stderr]
|
|
18
|
+
* @property {boolean} [killed] - Whether the process was killed (e.g. timeout).
|
|
19
|
+
*
|
|
20
|
+
* @typedef {Object} HarnessExecOptions
|
|
21
|
+
* @property {string} [cwd]
|
|
22
|
+
* @property {number} [timeout] - Timeout in milliseconds.
|
|
23
|
+
*
|
|
24
|
+
* @typedef {Object} HarnessUi
|
|
25
|
+
* @property {(message: string, level?: 'info'|'warning'|'error') => void} notify
|
|
26
|
+
* @property {(key: string, lines: string[]|undefined, options?: object) => void} setWidget
|
|
27
|
+
* @property {(key: string, text: string|undefined) => void} setStatus
|
|
28
|
+
*
|
|
29
|
+
* @typedef {Object} HarnessContext
|
|
30
|
+
* @property {string} cwd - Working directory for the current invocation.
|
|
31
|
+
* @property {boolean} hasUI - Whether an interactive UI surface is attached.
|
|
32
|
+
* @property {HarnessUi} ui - UI operations for this invocation.
|
|
33
|
+
*
|
|
34
|
+
* @typedef {'session_start'|'tool_result'|'user_bash'|'agent_end'} HarnessLifecycleEvent
|
|
35
|
+
*
|
|
36
|
+
* @typedef {Object} HarnessCommandConfig
|
|
37
|
+
* @property {string} description
|
|
38
|
+
* @property {(args: string|string[], ctx: HarnessContext) => unknown} handler
|
|
39
|
+
*
|
|
40
|
+
* @typedef {Object} ExtensionHarnessAdapter
|
|
41
|
+
* @property {(command: string, options?: HarnessExecOptions) => Promise<HarnessExecResult>} exec
|
|
42
|
+
* @property {(event: HarnessLifecycleEvent, handler: (event: unknown, ctx: HarnessContext) => unknown) => void} on
|
|
43
|
+
* @property {(name: string, config: HarnessCommandConfig) => void} registerCommand
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
const REQUIRED_METHODS = ["exec", "on", "registerCommand"];
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Validate and freeze an extension-surface harness-adapter implementation.
|
|
50
|
+
*
|
|
51
|
+
* @param {Partial<ExtensionHarnessAdapter>} impl
|
|
52
|
+
* @returns {ExtensionHarnessAdapter}
|
|
53
|
+
*/
|
|
54
|
+
export function createExtensionHarnessAdapter(impl) {
|
|
55
|
+
if (!impl || typeof impl !== "object") {
|
|
56
|
+
throw new TypeError("createExtensionHarnessAdapter: impl must be an object");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for (const method of REQUIRED_METHODS) {
|
|
60
|
+
if (typeof impl[method] !== "function") {
|
|
61
|
+
throw new TypeError(`createExtensionHarnessAdapter: missing required method "${method}"`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return Object.freeze({
|
|
66
|
+
exec: impl.exec,
|
|
67
|
+
on: impl.on,
|
|
68
|
+
registerCommand: impl.registerCommand,
|
|
69
|
+
});
|
|
70
|
+
}
|
package/src/harness/index.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export { createHarnessAdapter, isHarnessAdapter } from "./adapter.mjs";
|
|
2
2
|
export { createPiAdapter } from "./pi-adapter.mjs";
|
|
3
3
|
export { createNoopAdapter } from "./noop-adapter.mjs";
|
|
4
|
+
export { createExtensionHarnessAdapter } from "./extension-adapter.mjs";
|
|
5
|
+
export { createClaudeExtensionAdapter } from "./claude-extension-adapter.mjs";
|
|
@@ -2,17 +2,18 @@
|
|
|
2
2
|
* Async-start contract enforcement for the dev-loop startup path.
|
|
3
3
|
*
|
|
4
4
|
* This module enforces the requirement that dev-loop execution scripts
|
|
5
|
-
* (outer-loop, watch-cycle, etc.) must run within a visible
|
|
5
|
+
* (outer-loop, watch-cycle, etc.) must run within a visible harness-managed async
|
|
6
6
|
* context rather than as detached local processes (nohup, disowned shell jobs,
|
|
7
7
|
* tmux/screen sessions, ad hoc while/sleep loops, etc.).
|
|
8
8
|
*
|
|
9
|
-
* The enforcement seam is a startup check that verifies the presence of
|
|
10
|
-
*
|
|
9
|
+
* The enforcement seam is a startup check that verifies the presence of an
|
|
10
|
+
* async context marker. When the marker is absent, the check fails closed
|
|
11
11
|
* and returns a machine-readable rejection rather than silently proceeding.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* -
|
|
13
|
+
* Async context markers (required when workflow.asyncStartMode is `required`),
|
|
14
|
+
* neutral-first — see `@dev-loops/core/loop/run-context`:
|
|
15
|
+
* - DEVLOOPS_RUN_ID env var (neutral, harness-agnostic)
|
|
16
|
+
* - PI_SUBAGENT_RUN_ID env var (Pi subagent framework; retained as a compatibility alias)
|
|
16
17
|
*
|
|
17
18
|
* Allowed modes:
|
|
18
19
|
* - workflow.asyncStartMode: required | allowed
|
|
@@ -22,14 +23,18 @@
|
|
|
22
23
|
* This module is intentionally pure and side-effect free.
|
|
23
24
|
*/
|
|
24
25
|
|
|
26
|
+
import { RUN_ID_MARKERS } from "./run-context.mjs";
|
|
27
|
+
|
|
25
28
|
// ---------------------------------------------------------------------------
|
|
26
29
|
// Constants
|
|
27
30
|
// ---------------------------------------------------------------------------
|
|
28
31
|
|
|
29
|
-
/**
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Environment variable names that indicate an async context, neutral-first.
|
|
34
|
+
* Sourced from the shared run-context contract so the markers stay in one place.
|
|
35
|
+
* The historical name is kept for back-compat; it now includes DEVLOOPS_RUN_ID.
|
|
36
|
+
*/
|
|
37
|
+
export const PI_ASYNC_CONTEXT_MARKERS = RUN_ID_MARKERS;
|
|
33
38
|
|
|
34
39
|
/** Supported workflow async-start modes. */
|
|
35
40
|
export const ASYNC_START_MODE = Object.freeze({
|
|
@@ -39,13 +44,13 @@ export const ASYNC_START_MODE = Object.freeze({
|
|
|
39
44
|
|
|
40
45
|
/** Async-start validation result status values. */
|
|
41
46
|
export const ASYNC_START_STATUS = Object.freeze({
|
|
42
|
-
/** A valid
|
|
47
|
+
/** A valid harness-managed async context was detected. */
|
|
43
48
|
VALID: "valid",
|
|
44
49
|
/** The workflow explicitly allows non-async startup for this context. */
|
|
45
50
|
ALLOWED: "allowed",
|
|
46
51
|
/** The check was skipped because the caller is in snapshot/test mode. */
|
|
47
52
|
SNAPSHOT_MODE: "snapshot_mode",
|
|
48
|
-
/** No
|
|
53
|
+
/** No harness-managed async context was detected; fail closed. */
|
|
49
54
|
REJECTED: "rejected",
|
|
50
55
|
});
|
|
51
56
|
|
|
@@ -54,7 +59,7 @@ export const ASYNC_START_STATUS = Object.freeze({
|
|
|
54
59
|
// ---------------------------------------------------------------------------
|
|
55
60
|
|
|
56
61
|
/**
|
|
57
|
-
* Validate that the current execution context is a visible
|
|
62
|
+
* Validate that the current execution context is a visible harness-managed async run.
|
|
58
63
|
*
|
|
59
64
|
* Returns a result object describing whether the check passed, was allowed by
|
|
60
65
|
* config, or was rejected. Callers should treat `rejected` as a hard stop.
|
|
@@ -92,13 +97,13 @@ export function validateAsyncStartContext({
|
|
|
92
97
|
};
|
|
93
98
|
}
|
|
94
99
|
|
|
95
|
-
// Check for any
|
|
100
|
+
// Check for any async context marker (neutral DEVLOOPS_RUN_ID or the Pi alias)
|
|
96
101
|
for (const marker of PI_ASYNC_CONTEXT_MARKERS) {
|
|
97
102
|
const value = env[marker];
|
|
98
103
|
if (typeof value === "string" && value.trim().length > 0) {
|
|
99
104
|
return {
|
|
100
105
|
status: ASYNC_START_STATUS.VALID,
|
|
101
|
-
reason: `
|
|
106
|
+
reason: `Async context detected via ${marker}.`,
|
|
102
107
|
detectedMarker: marker,
|
|
103
108
|
};
|
|
104
109
|
}
|
|
@@ -123,8 +128,8 @@ export function validateAsyncStartContext({
|
|
|
123
128
|
status: ASYNC_START_STATUS.REJECTED,
|
|
124
129
|
reason:
|
|
125
130
|
`Detected ${sessionOnlyMarker}, but GitHub-first async-start requires a visible ` +
|
|
126
|
-
"
|
|
127
|
-
"Set PI_SUBAGENT_RUN_ID to proceed. Any exception must come from repository-maintained workflow policy.",
|
|
131
|
+
"subagent run id for inspectable startup/resume evidence. " +
|
|
132
|
+
"Set DEVLOOPS_RUN_ID (or the PI_SUBAGENT_RUN_ID alias) to proceed. Any exception must come from repository-maintained workflow policy.",
|
|
128
133
|
detectedMarker: null,
|
|
129
134
|
};
|
|
130
135
|
}
|
|
@@ -134,7 +139,7 @@ export function validateAsyncStartContext({
|
|
|
134
139
|
status: ASYNC_START_STATUS.REJECTED,
|
|
135
140
|
reason:
|
|
136
141
|
"Detected detached local background execution; detached/local fallback is diagnostic-only " +
|
|
137
|
-
"and does not satisfy the async-start contract. Restart via
|
|
142
|
+
"and does not satisfy the async-start contract. Restart via harness-managed async mode. " +
|
|
138
143
|
"Any relaxed posture must come from repository-maintained workflow policy.",
|
|
139
144
|
detectedMarker: null,
|
|
140
145
|
};
|
|
@@ -144,10 +149,11 @@ export function validateAsyncStartContext({
|
|
|
144
149
|
return {
|
|
145
150
|
status: ASYNC_START_STATUS.REJECTED,
|
|
146
151
|
reason:
|
|
147
|
-
"No
|
|
148
|
-
"The dev-loop must run within a visible
|
|
152
|
+
"No async context detected. " +
|
|
153
|
+
"The dev-loop must run within a visible async subagent session, " +
|
|
149
154
|
"not as a detached local process. " +
|
|
150
|
-
`Set ${PI_ASYNC_CONTEXT_MARKERS[0]}
|
|
155
|
+
`Set ${PI_ASYNC_CONTEXT_MARKERS[0]} (or the PI_SUBAGENT_RUN_ID alias) to proceed. ` +
|
|
156
|
+
"Repository-maintained workflow policy controls any exceptions.",
|
|
151
157
|
detectedMarker: null,
|
|
152
158
|
};
|
|
153
159
|
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure classification of shell command strings for the dev-loop gate boundary.
|
|
3
|
+
*
|
|
4
|
+
* Shared by the Pi extension (`extension/post-merge-update.ts`, which re-exports these so its
|
|
5
|
+
* behavior is unchanged) and the Claude Code PreToolUse Bash hook. Keeping one source of truth
|
|
6
|
+
* means the `gh pr ready` / merge detection is identical across harnesses.
|
|
7
|
+
*
|
|
8
|
+
* Pure and side-effect free.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** The repository these gate guards apply to. */
|
|
12
|
+
export const TARGET_REPO_SLUG = "mfittko/dev-loops";
|
|
13
|
+
|
|
14
|
+
/** Flags known to take a value argument for `gh pr ready` (not boolean flags). */
|
|
15
|
+
export const FLAGS_THAT_TAKE_VALUE = new Set(["-r", "--repo"]);
|
|
16
|
+
|
|
17
|
+
/** @param {string|null|undefined} value @returns {string|null} */
|
|
18
|
+
export function trimToNull(value) {
|
|
19
|
+
const trimmed = `${value ?? ""}`.trim();
|
|
20
|
+
return trimmed ? trimmed : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Normalize a git remote URL into an `owner/name` slug (lowercased), or null.
|
|
25
|
+
* @param {string} remoteUrl
|
|
26
|
+
* @returns {string|null}
|
|
27
|
+
*/
|
|
28
|
+
export function normalizeGitHubRepoSlug(remoteUrl) {
|
|
29
|
+
const normalized = trimToNull(remoteUrl);
|
|
30
|
+
if (!normalized) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const patterns = [
|
|
35
|
+
/^git@github\.com:([^\s]+?)(?:\.git)?$/i,
|
|
36
|
+
/^https?:\/\/github\.com\/([^\s]+?)(?:\.git)?$/i,
|
|
37
|
+
/^ssh:\/\/git@github\.com\/([^\s]+?)(?:\.git)?$/i,
|
|
38
|
+
/^git:\/\/github\.com\/([^\s]+?)(?:\.git)?$/i,
|
|
39
|
+
/^git:github\.com\/([^\s]+?)(?:\.git)?$/i,
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
for (const pattern of patterns) {
|
|
43
|
+
const match = normalized.match(pattern);
|
|
44
|
+
if (!match) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
return trimToNull(match[1])?.toLowerCase() ?? null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isGhPrMergeCommand(segment) {
|
|
54
|
+
if (!/^gh\s+pr\s+merge(?:\s|$)/i.test(segment)) {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
const remainder = segment.replace(/^gh\s+pr\s+merge(?:\s|$)/i, "").trim();
|
|
58
|
+
if (!remainder) {
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
const firstArg = remainder.match(/^(\S+)/)?.[1]?.toLowerCase() ?? "";
|
|
62
|
+
return !["--help", "-h"].includes(firstArg);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isGitMergeCompletionCommand(segment) {
|
|
66
|
+
if (!/^git\s+merge(?:\s|$)/i.test(segment)) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
const remainder = segment.replace(/^git\s+merge(?:\s|$)/i, "").trim();
|
|
70
|
+
if (!remainder) {
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
const firstArg = remainder.match(/^(\S+)/)?.[1]?.toLowerCase() ?? "";
|
|
74
|
+
return !["--abort", "--continue", "--quit", "--help", "-h"].includes(firstArg);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** @param {string} command @returns {boolean} */
|
|
78
|
+
export function isMergeCapableCommand(command) {
|
|
79
|
+
const normalized = command.trim();
|
|
80
|
+
if (!normalized) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
return normalized
|
|
84
|
+
.split(/\s*(?:&&|\|\||;|\|)\s*/)
|
|
85
|
+
.some((segment) => isGhPrMergeCommand(segment) || isGitMergeCompletionCommand(segment));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** @param {string} command @returns {string} */
|
|
89
|
+
export function firstShellSegment(command) {
|
|
90
|
+
return command.trim().split(/\s*(?:&&|\|\||;|\|)\s*/)[0]?.trim() ?? "";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** @param {string} command @returns {boolean} */
|
|
94
|
+
export function isGhPrReadyCommand(command) {
|
|
95
|
+
const segment = firstShellSegment(command);
|
|
96
|
+
if (!segment || !/^gh\s+pr\s+ready(?:\s|$)/i.test(segment)) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
const remainder = segment.replace(/^gh\s+pr\s+ready(?:\s|$)/i, "").trim();
|
|
100
|
+
if (!remainder) {
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
const args = remainder.split(/\s+/).map((a) => a.toLowerCase());
|
|
104
|
+
return !args.includes("--help") && !args.includes("-h");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** @param {string} command @returns {number|null} */
|
|
108
|
+
export function extractPrNumberFromGhPrReady(command) {
|
|
109
|
+
const segment = firstShellSegment(command);
|
|
110
|
+
if (!/^gh\s+pr\s+ready(?:\s|$)/i.test(segment)) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
const remainder = segment.replace(/^gh\s+pr\s+ready(?:\s|$)/i, "").trim();
|
|
114
|
+
if (!remainder) {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
const tokens = remainder.split(/\s+/);
|
|
118
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
119
|
+
const token = tokens[i];
|
|
120
|
+
if (token.startsWith("-")) {
|
|
121
|
+
const flagName = token.replace(/=.*$/, "").toLowerCase();
|
|
122
|
+
if (!token.includes("=") && FLAGS_THAT_TAKE_VALUE.has(flagName)) {
|
|
123
|
+
i++;
|
|
124
|
+
}
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (/^\d+$/.test(token)) {
|
|
128
|
+
const num = Number(token);
|
|
129
|
+
if (num > 0) {
|
|
130
|
+
return num;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** @param {string} command @returns {string|null} */
|
|
139
|
+
export function extractRepoFlagFromGhPrReady(command) {
|
|
140
|
+
const segment = firstShellSegment(command);
|
|
141
|
+
if (!/^gh\s+pr\s+ready(?:\s|$)/i.test(segment)) {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
const remainder = segment.replace(/^gh\s+pr\s+ready(?:\s|$)/i, "").trim();
|
|
145
|
+
if (!remainder) {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
const tokens = remainder.split(/\s+/);
|
|
149
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
150
|
+
const token = tokens[i];
|
|
151
|
+
const lower = token.toLowerCase();
|
|
152
|
+
if (lower === "-r" || lower === "--repo") {
|
|
153
|
+
if (i + 1 < tokens.length && !tokens[i + 1].startsWith("-")) {
|
|
154
|
+
return tokens[i + 1];
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const repoEqMatch = token.match(/^(?:--repo|-R)=(.+)$/i);
|
|
158
|
+
if (repoEqMatch) {
|
|
159
|
+
return repoEqMatch[1];
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Neutral run-id / async-context contract.
|
|
3
|
+
*
|
|
4
|
+
* The dev-loop async path historically keyed off Pi's `PI_SUBAGENT_RUN_ID` env var to
|
|
5
|
+
* identify an inspectable per-subagent run (runner ownership, async-start enforcement,
|
|
6
|
+
* human-comment gating). This module generalizes that into a harness-neutral
|
|
7
|
+
* `DEVLOOPS_RUN_ID`, keeping `PI_SUBAGENT_RUN_ID` as a backward-compatible alias, and
|
|
8
|
+
* provides a mint-and-propagate path for harnesses (e.g. Claude Code) that inject no
|
|
9
|
+
* native per-subagent run id.
|
|
10
|
+
*
|
|
11
|
+
* Marker precedence is neutral-first: a present `DEVLOOPS_RUN_ID` wins; otherwise the Pi
|
|
12
|
+
* alias is honored. Existing Pi runs that set only `PI_SUBAGENT_RUN_ID` behave identically.
|
|
13
|
+
*
|
|
14
|
+
* This module is pure except for the explicit file/IO helpers (writeRunContext/readRunContext),
|
|
15
|
+
* which take an injectable `fs` and `root` for testability.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import crypto from "node:crypto";
|
|
19
|
+
import fsDefault from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Env var names that carry the async-context run id, in resolution precedence order.
|
|
24
|
+
* Neutral `DEVLOOPS_RUN_ID` first; Pi `PI_SUBAGENT_RUN_ID` retained as a compatibility alias.
|
|
25
|
+
*/
|
|
26
|
+
export const RUN_ID_MARKERS = Object.freeze(["DEVLOOPS_RUN_ID", "PI_SUBAGENT_RUN_ID"]);
|
|
27
|
+
|
|
28
|
+
/** Neutral env var name used when minting/propagating a run id. */
|
|
29
|
+
export const NEUTRAL_RUN_ID_VAR = "DEVLOOPS_RUN_ID";
|
|
30
|
+
|
|
31
|
+
/** Pi-compatibility alias env var name. */
|
|
32
|
+
export const PI_RUN_ID_ALIAS_VAR = "PI_SUBAGENT_RUN_ID";
|
|
33
|
+
|
|
34
|
+
/** State-file name (under `.pi/`, consistent with existing dev-loop checkpoint files). */
|
|
35
|
+
export const RUN_CONTEXT_FILENAME = "dev-loop-run-context.json";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Resolve the active run id from the environment, neutral marker first.
|
|
39
|
+
*
|
|
40
|
+
* @param {Record<string, string|undefined>} [env]
|
|
41
|
+
* @returns {string|null} The trimmed run id, or null when none is set.
|
|
42
|
+
*/
|
|
43
|
+
export function resolveRunId(env = process.env) {
|
|
44
|
+
for (const marker of RUN_ID_MARKERS) {
|
|
45
|
+
const value = env?.[marker];
|
|
46
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
47
|
+
return value.trim();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Mint a fresh neutral run id.
|
|
55
|
+
*
|
|
56
|
+
* @returns {string} `devloops-<uuid>` (e.g. "devloops-3f2c1e84-...-9a0b")
|
|
57
|
+
*/
|
|
58
|
+
export function mintRunId() {
|
|
59
|
+
return `devloops-${crypto.randomUUID()}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Build the env fragment that propagates a run id to child processes.
|
|
64
|
+
*
|
|
65
|
+
* Sets the neutral var; child Bash scripts observe it via `resolveRunId`. Callers merge
|
|
66
|
+
* this into the child env (e.g. `{ ...process.env, ...runContextEnv(runId) }`).
|
|
67
|
+
*
|
|
68
|
+
* @param {string} runId
|
|
69
|
+
* @returns {{ DEVLOOPS_RUN_ID: string }}
|
|
70
|
+
*/
|
|
71
|
+
export function runContextEnv(runId) {
|
|
72
|
+
return { [NEUTRAL_RUN_ID_VAR]: runId };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Absolute path to the run-context state file for a repo root.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} root - Repository root (or any base dir).
|
|
79
|
+
* @returns {string}
|
|
80
|
+
*/
|
|
81
|
+
export function runContextPath(root) {
|
|
82
|
+
return path.join(root, ".pi", RUN_CONTEXT_FILENAME);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Persist the run-context state file (for inspection/recovery).
|
|
87
|
+
*
|
|
88
|
+
* @param {object} params
|
|
89
|
+
* @param {string} params.runId
|
|
90
|
+
* @param {string} params.root
|
|
91
|
+
* @param {string} [params.mintedAt] - ISO timestamp; defaults to now. Tests pass a fixed
|
|
92
|
+
* value for determinism; real runs get a useful inspection/recovery timestamp.
|
|
93
|
+
* @param {typeof import("node:fs")} [params.fs]
|
|
94
|
+
* @returns {string} The path written.
|
|
95
|
+
*/
|
|
96
|
+
export function writeRunContext({ runId, root, mintedAt, fs = fsDefault }) {
|
|
97
|
+
if (typeof runId !== "string" || runId.trim().length === 0) {
|
|
98
|
+
throw new TypeError("writeRunContext: runId must be a non-empty string");
|
|
99
|
+
}
|
|
100
|
+
const file = runContextPath(root);
|
|
101
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
102
|
+
const payload = {
|
|
103
|
+
runId: runId.trim(),
|
|
104
|
+
mintedAt: mintedAt ?? new Date().toISOString(),
|
|
105
|
+
};
|
|
106
|
+
fs.writeFileSync(file, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
107
|
+
return file;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Read the run-context state file, or null when absent/unparseable.
|
|
112
|
+
*
|
|
113
|
+
* @param {object} params
|
|
114
|
+
* @param {string} params.root
|
|
115
|
+
* @param {typeof import("node:fs")} [params.fs]
|
|
116
|
+
* @returns {{ runId: string, mintedAt: string|null }|null}
|
|
117
|
+
*/
|
|
118
|
+
export function readRunContext({ root, fs = fsDefault }) {
|
|
119
|
+
const file = runContextPath(root);
|
|
120
|
+
try {
|
|
121
|
+
const raw = fs.readFileSync(file, "utf8");
|
|
122
|
+
const parsed = JSON.parse(raw);
|
|
123
|
+
if (parsed && typeof parsed.runId === "string" && parsed.runId.trim().length > 0) {
|
|
124
|
+
return { runId: parsed.runId.trim(), mintedAt: parsed.mintedAt ?? null };
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
} catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Resolve the active run id, or mint one and persist a run-context state file.
|
|
134
|
+
*
|
|
135
|
+
* This is the "mint at startup and propagate" primitive a Claude dev-loop agent (or a
|
|
136
|
+
* headless entry) calls before dispatching child work. When the env already carries a run
|
|
137
|
+
* id (Pi alias or neutral), it is reused and no new id is minted.
|
|
138
|
+
*
|
|
139
|
+
* @param {object} [params]
|
|
140
|
+
* @param {Record<string, string|undefined>} [params.env]
|
|
141
|
+
* @param {string} [params.root]
|
|
142
|
+
* @param {string} [params.mintedAt] - ISO timestamp for the state file (determinism).
|
|
143
|
+
* @param {typeof import("node:fs")} [params.fs]
|
|
144
|
+
* @returns {{ runId: string, minted: boolean, statePath: string|null }}
|
|
145
|
+
*/
|
|
146
|
+
export function ensureRunId({ env = process.env, root, mintedAt, fs = fsDefault } = {}) {
|
|
147
|
+
const existing = resolveRunId(env);
|
|
148
|
+
if (existing) {
|
|
149
|
+
return { runId: existing, minted: false, statePath: null };
|
|
150
|
+
}
|
|
151
|
+
const runId = mintRunId();
|
|
152
|
+
let statePath = null;
|
|
153
|
+
if (typeof root === "string" && root.length > 0) {
|
|
154
|
+
statePath = writeRunContext({ runId, root, mintedAt, fs });
|
|
155
|
+
}
|
|
156
|
+
return { runId, minted: true, statePath };
|
|
157
|
+
}
|
|
@@ -118,24 +118,45 @@ export function isListedWorktree(cwd, worktreePaths) {
|
|
|
118
118
|
// ---------------------------------------------------------------------------
|
|
119
119
|
|
|
120
120
|
/**
|
|
121
|
-
*
|
|
121
|
+
* Neutral environment variable name checked by `detectSubagentAvailability`.
|
|
122
122
|
*
|
|
123
|
-
* Set `
|
|
123
|
+
* Set `DEVLOOPS_SUBAGENT_AVAILABLE=1` when the runtime supports subagent dispatch.
|
|
124
124
|
* This is consistent with the `PI_WORKTREE_BYPASS` pattern and other repo-local
|
|
125
125
|
* runtime configuration gates already present in the repo.
|
|
126
126
|
*/
|
|
127
|
+
export const DEVLOOPS_SUBAGENT_AVAILABLE_VAR = "DEVLOOPS_SUBAGENT_AVAILABLE";
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Pi-compatibility alias for {@link DEVLOOPS_SUBAGENT_AVAILABLE_VAR}; honored when the
|
|
131
|
+
* neutral var is unset so existing Pi runtimes keep working unchanged.
|
|
132
|
+
*/
|
|
127
133
|
export const PI_SUBAGENT_AVAILABLE_VAR = "PI_SUBAGENT_AVAILABLE";
|
|
128
134
|
|
|
135
|
+
/** Availability env var names, neutral-first. */
|
|
136
|
+
export const SUBAGENT_AVAILABLE_VARS = Object.freeze([
|
|
137
|
+
DEVLOOPS_SUBAGENT_AVAILABLE_VAR,
|
|
138
|
+
PI_SUBAGENT_AVAILABLE_VAR,
|
|
139
|
+
]);
|
|
140
|
+
|
|
129
141
|
/**
|
|
130
142
|
* Detect whether subagent dispatch is available in the current runtime.
|
|
131
143
|
*
|
|
132
144
|
* This is an env-var-based heuristic, consistent with other bypass/availability
|
|
133
145
|
* patterns in the repo. It is intentionally simple — the gate's subagent check
|
|
134
|
-
* is advisory (fails-open) and never hard-blocks on subagent absence.
|
|
146
|
+
* is advisory (fails-open) and never hard-blocks on subagent absence. Precedence is
|
|
147
|
+
* neutral-first: the first var that is *set* (non-blank) is authoritative — so an explicit
|
|
148
|
+
* `DEVLOOPS_SUBAGENT_AVAILABLE=0` is respected even when `PI_SUBAGENT_AVAILABLE=1`. The Pi
|
|
149
|
+
* alias is only consulted when the neutral var is unset/blank.
|
|
135
150
|
*
|
|
136
151
|
* @param {{ env?: Record<string, string | undefined> }} [options]
|
|
137
152
|
* @returns {boolean}
|
|
138
153
|
*/
|
|
139
154
|
export function detectSubagentAvailability({ env = process.env } = {}) {
|
|
140
|
-
|
|
155
|
+
for (const name of SUBAGENT_AVAILABLE_VARS) {
|
|
156
|
+
const raw = (env[name] ?? "").trim();
|
|
157
|
+
if (raw.length > 0) {
|
|
158
|
+
return raw === "1";
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return false;
|
|
141
162
|
}
|