dev-loops 0.2.7 → 0.4.0
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 +1 -1
- package/.claude/agents/dev-loop.md +1 -1
- package/.claude/agents/developer.md +1 -0
- package/.claude/agents/fixer.md +1 -0
- package/.claude/agents/review.md +30 -0
- package/.claude/hooks/_run-context.mjs +9 -16
- package/.claude/skills/copilot-pr-followup/SKILL.md +62 -6
- package/.claude/skills/dev-loop/SKILL.md +6 -6
- package/.claude/skills/docs/anti-patterns.md +2 -0
- package/.claude/skills/docs/copilot-loop-operations.md +3 -3
- package/.claude/skills/docs/issue-intake-procedure.md +4 -0
- package/.claude/skills/docs/merge-preconditions.md +65 -1
- package/.claude/skills/docs/stop-conditions.md +1 -0
- package/.claude/skills/docs/tracker-first-loop-state.md +6 -6
- package/.claude/skills/local-implementation/SKILL.md +25 -5
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +69 -0
- package/README.md +8 -2
- package/agents/developer.agent.md +1 -0
- package/agents/fixer.agent.md +1 -0
- package/agents/review.agent.md +30 -0
- package/cli/index.mjs +60 -8
- package/extension/README.md +1 -1
- package/package.json +3 -3
- package/scripts/README.md +8 -7
- package/scripts/_core-helpers.mjs +1 -0
- package/scripts/claude/headless-dev-loop.mjs +53 -13
- package/scripts/claude/headless-info-smoke.mjs +45 -11
- package/scripts/github/build-adjacent-bundle.mjs +448 -0
- package/scripts/github/{create-draft-pr.mjs → create-pr.mjs} +28 -12
- package/scripts/github/detect-checkpoint-evidence.mjs +95 -4
- package/scripts/github/offer-human-handoff.mjs +147 -0
- package/scripts/github/post-gate-findings.mjs +392 -0
- package/scripts/github/probe-ci-status.mjs +468 -0
- package/scripts/github/reconcile-draft-gate.mjs +2 -2
- package/scripts/github/request-copilot-review.mjs +72 -11
- package/scripts/github/resolve-handoff-candidates.mjs +412 -0
- package/scripts/github/upsert-checkpoint-verdict.mjs +599 -17
- package/scripts/github/verify-fresh-review-context.mjs +12 -1
- package/scripts/github/write-gate-context.mjs +634 -0
- package/scripts/github/write-gate-findings-log.mjs +1 -1
- package/scripts/loop/_stale-runner-detection.mjs +1 -1
- package/scripts/loop/_worktree-path.mjs +27 -0
- package/scripts/loop/cleanup-worktree.mjs +175 -0
- package/scripts/loop/copilot-pr-handoff.mjs +1 -1
- package/scripts/loop/detect-change-scope.mjs +36 -11
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +30 -18
- package/scripts/loop/detect-stale-runner.mjs +3 -4
- package/scripts/loop/detect-tracker-first-loop-state.mjs +38 -11
- package/scripts/loop/ensure-worktree.mjs +219 -0
- package/scripts/loop/outer-loop.mjs +1 -1
- package/scripts/loop/pr-runner-coordination.mjs +1 -1
- package/scripts/loop/pre-flight-gate.mjs +10 -7
- package/scripts/loop/pre-push-main-guard.mjs +4 -4
- package/scripts/loop/provision-worktree.mjs +243 -0
- package/scripts/loop/resolve-dev-loop-startup.mjs +5 -5
- package/scripts/loop/run-queue.mjs +112 -16
- package/scripts/loop/run-watch-cycle.mjs +75 -22
- package/scripts/projects/add-queue-item.mjs +80 -48
- package/scripts/projects/archive-done-items.mjs +136 -39
- package/scripts/projects/ensure-queue-board.mjs +67 -65
- package/scripts/projects/list-queue-items.mjs +59 -57
- package/scripts/projects/move-queue-item.mjs +125 -125
- package/scripts/projects/reorder-queue-item.mjs +67 -48
- package/scripts/projects/sync-item-status.mjs +199 -0
- package/skills/copilot-pr-followup/SKILL.md +62 -6
- package/skills/dev-loop/SKILL.md +2 -2
- package/skills/dev-loop/scripts/log-bash-exit-1.mjs +2 -2
- package/skills/dev-loop/scripts/phase-files.mjs +2 -2
- package/skills/docs/anti-patterns.md +2 -0
- package/skills/docs/copilot-loop-operations.md +3 -3
- package/skills/docs/issue-intake-procedure.md +4 -0
- package/skills/docs/merge-preconditions.md +65 -1
- package/skills/docs/stop-conditions.md +1 -0
- package/skills/docs/tracker-first-loop-state.md +6 -6
- package/skills/local-implementation/SKILL.md +25 -5
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Ensure a loop-owned worktree exists at its canonical namespaced path, then
|
|
4
|
+
* provision it (issue #909). This is the lifecycle entrypoint: create OR reuse
|
|
5
|
+
* the worktree, then copy/link the configured gitignored files in one step.
|
|
6
|
+
*
|
|
7
|
+
* - Canonical path comes from the shared resolveWorktreePath (namespaced
|
|
8
|
+
* `tmp/worktrees/dev-loops/<kind>-<n>`), so create/provision/cleanup agree.
|
|
9
|
+
* - `git fetch <base-origin>` then `git worktree add` if absent. If a worktree
|
|
10
|
+
* already exists at the exact path it is REUSED (idempotent); if one exists
|
|
11
|
+
* there on a DIFFERENT branch it is a hard conflict (we never clobber).
|
|
12
|
+
* - Provisioning is invoked via the imported provisionWorktree core (shared
|
|
13
|
+
* with provision-worktree.mjs's CLI) — not shelled out. It fails soft: a
|
|
14
|
+
* provision warning never aborts the worktree.
|
|
15
|
+
* - Does NOT run npm install (out of scope).
|
|
16
|
+
*
|
|
17
|
+
* Prints a JSON result to stdout:
|
|
18
|
+
* { ok, path, created|reused, provision: { actions, summary } }
|
|
19
|
+
* (`provision` is the full provisionWorktree() result, not just its summary.)
|
|
20
|
+
* A git create failure is a hard error (exit 1); provisioning is fail-soft.
|
|
21
|
+
*/
|
|
22
|
+
import { execFileSync } from "node:child_process";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
|
|
25
|
+
import { requireTokenValue } from "../_cli-primitives.mjs";
|
|
26
|
+
import { parseArgs } from "node:util";
|
|
27
|
+
import { resolveWorktreePath } from "@dev-loops/core/loop/handoff-envelope";
|
|
28
|
+
import { provisionWorktree } from "./provision-worktree.mjs";
|
|
29
|
+
import { canonicalize } from "./_worktree-path.mjs";
|
|
30
|
+
|
|
31
|
+
const USAGE = `Usage:
|
|
32
|
+
ensure-worktree.mjs --repo-root <p> (--issue <n> | --pr <n>) [--branch <name>] [--base <ref>]
|
|
33
|
+
Create (or reuse) a loop-owned worktree at its canonical namespaced path
|
|
34
|
+
(tmp/worktrees/dev-loops/<kind>-<n>) and provision it in one step.
|
|
35
|
+
Required:
|
|
36
|
+
--repo-root <p> Absolute path to the main checkout (git runs here).
|
|
37
|
+
one of:
|
|
38
|
+
--issue <n> Issue number (resolves the canonical path).
|
|
39
|
+
--pr <n> PR number (resolves the canonical path).
|
|
40
|
+
Optional:
|
|
41
|
+
--branch <name> Branch to create/check out (default: <kind>-<n>).
|
|
42
|
+
--base <ref> Base ref for a new worktree (default: origin/main).
|
|
43
|
+
-h, --help Show this help.
|
|
44
|
+
Output (stdout, JSON):
|
|
45
|
+
{ "ok": true, "path": <p>, "created": bool, "reused": bool,
|
|
46
|
+
"provision": { "actions": [...], "summary": {...} } }`.trim();
|
|
47
|
+
|
|
48
|
+
const parseError = buildParseError(USAGE);
|
|
49
|
+
|
|
50
|
+
function parsePositiveInt(value, flag) {
|
|
51
|
+
const n = Number(value);
|
|
52
|
+
if (!Number.isInteger(n) || n < 1) throw parseError(`${flag} must be a positive integer`);
|
|
53
|
+
return n;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function parseEnsureWorktreeCliArgs(argv) {
|
|
57
|
+
const options = {
|
|
58
|
+
help: false,
|
|
59
|
+
repoRoot: undefined,
|
|
60
|
+
issue: undefined,
|
|
61
|
+
pr: undefined,
|
|
62
|
+
branch: undefined,
|
|
63
|
+
base: "origin/main",
|
|
64
|
+
};
|
|
65
|
+
const { tokens } = parseArgs({
|
|
66
|
+
args: [...argv],
|
|
67
|
+
options: {
|
|
68
|
+
help: { type: "boolean", short: "h" },
|
|
69
|
+
"repo-root": { type: "string" },
|
|
70
|
+
issue: { type: "string" },
|
|
71
|
+
pr: { type: "string" },
|
|
72
|
+
branch: { type: "string" },
|
|
73
|
+
base: { type: "string" },
|
|
74
|
+
},
|
|
75
|
+
allowPositionals: true,
|
|
76
|
+
strict: false,
|
|
77
|
+
tokens: true,
|
|
78
|
+
});
|
|
79
|
+
for (const token of tokens) {
|
|
80
|
+
if (token.kind === "positional") throw parseError(`Unknown argument: ${token.value}`);
|
|
81
|
+
if (token.kind !== "option") continue;
|
|
82
|
+
if (token.name === "help") {
|
|
83
|
+
options.help = true;
|
|
84
|
+
return options;
|
|
85
|
+
}
|
|
86
|
+
if (token.name === "repo-root") {
|
|
87
|
+
options.repoRoot = requireTokenValue(token, parseError, { flagPattern: /^-/u });
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (token.name === "issue") {
|
|
91
|
+
options.issue = parsePositiveInt(requireTokenValue(token, parseError, { flagPattern: /^-/u }), "--issue");
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (token.name === "pr") {
|
|
95
|
+
options.pr = parsePositiveInt(requireTokenValue(token, parseError, { flagPattern: /^-/u }), "--pr");
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (token.name === "branch") {
|
|
99
|
+
options.branch = requireTokenValue(token, parseError, { flagPattern: /^-/u });
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (token.name === "base") {
|
|
103
|
+
options.base = requireTokenValue(token, parseError, { flagPattern: /^-/u });
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
107
|
+
}
|
|
108
|
+
if (options.help) return options;
|
|
109
|
+
if (!options.repoRoot) throw parseError("Missing required --repo-root");
|
|
110
|
+
const selectors = [options.issue, options.pr].filter((v) => v !== undefined);
|
|
111
|
+
if (selectors.length === 0) throw parseError("One of --issue or --pr is required");
|
|
112
|
+
if (selectors.length > 1) throw parseError("Provide exactly one of --issue or --pr");
|
|
113
|
+
return options;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Remote name from a base ref like "origin/main" → "origin". */
|
|
117
|
+
function remoteFromBase(base) {
|
|
118
|
+
const slash = base.indexOf("/");
|
|
119
|
+
return slash > 0 ? base.slice(0, slash) : "origin";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function runGit(gitCommand, args, cwd) {
|
|
123
|
+
return execFileSync(gitCommand, args, {
|
|
124
|
+
cwd,
|
|
125
|
+
encoding: "utf8",
|
|
126
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** True when a local branch ref already exists (non-zero exit → absent). */
|
|
131
|
+
function branchExists(gitCommand, branch, cwd) {
|
|
132
|
+
try {
|
|
133
|
+
runGit(gitCommand, ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`], cwd);
|
|
134
|
+
return true;
|
|
135
|
+
} catch {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Parse `git worktree list --porcelain` into [{ path, branch }]. Branch is the
|
|
142
|
+
* short ref (refs/heads/foo → foo) or null for a detached/bare entry.
|
|
143
|
+
*/
|
|
144
|
+
function parseWorktreeList(porcelain) {
|
|
145
|
+
const entries = [];
|
|
146
|
+
let cur = null;
|
|
147
|
+
for (const line of porcelain.split("\n")) {
|
|
148
|
+
if (line.startsWith("worktree ")) {
|
|
149
|
+
cur = { path: line.slice("worktree ".length).trim(), branch: null };
|
|
150
|
+
entries.push(cur);
|
|
151
|
+
} else if (cur && line.startsWith("branch ")) {
|
|
152
|
+
cur.branch = line.slice("branch ".length).trim().replace(/^refs\/heads\//, "");
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return entries;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function ensureWorktree(
|
|
159
|
+
{ repoRoot, issue, pr, branch, base = "origin/main" },
|
|
160
|
+
{ gitCommand = "git", provision = provisionWorktree } = {},
|
|
161
|
+
) {
|
|
162
|
+
const root = path.resolve(repoRoot);
|
|
163
|
+
const kind = issue !== undefined ? "issue" : "pr";
|
|
164
|
+
const number = issue !== undefined ? issue : pr;
|
|
165
|
+
const target = resolveWorktreePath({ repoRoot: root, kind, number });
|
|
166
|
+
const wantBranch = branch || `${kind}-${number}`;
|
|
167
|
+
|
|
168
|
+
// Idempotency / conflict check BEFORE any mutation.
|
|
169
|
+
const list = parseWorktreeList(runGit(gitCommand, ["worktree", "list", "--porcelain"], root));
|
|
170
|
+
const canonicalTarget = canonicalize(target);
|
|
171
|
+
const existing = list.find((e) => canonicalize(e.path) === canonicalTarget);
|
|
172
|
+
if (existing) {
|
|
173
|
+
if (existing.branch && existing.branch !== wantBranch) {
|
|
174
|
+
// Hard conflict — never clobber an unrelated worktree at our path.
|
|
175
|
+
throw new Error(
|
|
176
|
+
`worktree conflict: ${target} already checked out on branch "${existing.branch}", not "${wantBranch}"`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
// Reuse: still (re-)provision — provisioning is idempotent.
|
|
180
|
+
const summary = await provision({ worktreePath: target, repoRoot: root });
|
|
181
|
+
return { ok: true, path: target, created: false, reused: true, provision: summary };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Create. fetch is best-effort (offline reuse of a local base ref still works),
|
|
185
|
+
// but `git worktree add` failing is a HARD error.
|
|
186
|
+
try {
|
|
187
|
+
runGit(gitCommand, ["fetch", remoteFromBase(base)], root);
|
|
188
|
+
} catch (err) {
|
|
189
|
+
process.stderr.write(`[ensure-worktree] WARN fetch failed (continuing): ${(err.stderr ?? err.message ?? "").toString().trim()}\n`);
|
|
190
|
+
}
|
|
191
|
+
// The branch may already exist (worktree removed but branch left behind). `git
|
|
192
|
+
// worktree add -b` fails on an existing branch, so attach to it instead; only
|
|
193
|
+
// create-from-base when the branch is genuinely new.
|
|
194
|
+
if (branchExists(gitCommand, wantBranch, root)) {
|
|
195
|
+
runGit(gitCommand, ["worktree", "add", target, wantBranch], root);
|
|
196
|
+
} else {
|
|
197
|
+
runGit(gitCommand, ["worktree", "add", "-b", wantBranch, target, base], root);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const summary = await provision({ worktreePath: target, repoRoot: root });
|
|
201
|
+
return { ok: true, path: target, created: true, reused: false, provision: summary };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function runCli(argv = process.argv.slice(2), { stdout = process.stdout } = {}) {
|
|
205
|
+
const options = parseEnsureWorktreeCliArgs(argv);
|
|
206
|
+
if (options.help) {
|
|
207
|
+
stdout.write(`${USAGE}\n`);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const result = await ensureWorktree(options);
|
|
211
|
+
stdout.write(`${JSON.stringify(result)}\n`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
215
|
+
runCli().catch((error) => {
|
|
216
|
+
process.stderr.write(`${formatCliError(error)}\n`);
|
|
217
|
+
process.exitCode = 1;
|
|
218
|
+
});
|
|
219
|
+
}
|
|
@@ -72,7 +72,7 @@ Stop reasons:
|
|
|
72
72
|
Async-start contract:
|
|
73
73
|
This loop must run within a visible harness-managed async context when
|
|
74
74
|
workflow.asyncStartMode is set to required (default). It fails closed unless
|
|
75
|
-
DEVLOOPS_RUN_ID
|
|
75
|
+
DEVLOOPS_RUN_ID is set, to prevent hidden detached-process fallback
|
|
76
76
|
(nohup, disowned shell jobs, etc.). Snapshot/test input mode
|
|
77
77
|
(both --copilot-input and --reviewer-input) is exempt. Any relaxed
|
|
78
78
|
async-start posture is maintainer-controlled repository policy, not an
|
|
@@ -19,7 +19,7 @@ const USAGE = `Usage:
|
|
|
19
19
|
pr-runner-coordination.mjs release --repo <owner/name> --pr <number> [--run-id <id>]
|
|
20
20
|
Durable one-runner-per-PR coordination helper.
|
|
21
21
|
If --run-id is omitted for claim/assert/release/takeover, DEVLOOPS_RUN_ID is used
|
|
22
|
-
(
|
|
22
|
+
(via DEVLOOPS_RUN_ID).
|
|
23
23
|
Output:
|
|
24
24
|
stdout: { "ok": true, ... }
|
|
25
25
|
stderr: { "ok": false, "error": "...", ... }
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
isListedWorktree,
|
|
13
13
|
detectSubagentAvailability,
|
|
14
14
|
} from "@dev-loops/core/loop/worktree-guard";
|
|
15
|
-
const
|
|
15
|
+
const DEVLOOPS_PREFLIGHT_BYPASS_VAR = "DEVLOOPS_PREFLIGHT_BYPASS";
|
|
16
16
|
const USAGE = `Usage:
|
|
17
17
|
pre-flight-gate.mjs [--expected-branch <name>] [--check-subagents]
|
|
18
18
|
Gate local implementation mutations before planning or editing.
|
|
@@ -28,7 +28,7 @@ Violation output (stderr, JSON, exit 1):
|
|
|
28
28
|
{ "ok": false, "error": "<error_code>", "checks": { ... },
|
|
29
29
|
"guidance": "<actionable instruction for the agent>" }
|
|
30
30
|
Bypass:
|
|
31
|
-
|
|
31
|
+
DEVLOOPS_PREFLIGHT_BYPASS=1 Skip all checks (for development/testing only).`.trim();
|
|
32
32
|
const parseError = buildParseError(USAGE);
|
|
33
33
|
export function parsePreFlightGateCliArgs(argv) {
|
|
34
34
|
const options = {
|
|
@@ -95,7 +95,8 @@ function checkWorktreeIsolation({ cwd, env, gitCommand = "git" }) {
|
|
|
95
95
|
guidance:
|
|
96
96
|
`Current directory appears to be the main git checkout (${mainWorktreePath}).\n` +
|
|
97
97
|
"Local implementation requires worktree isolation. Create a worktree:\n" +
|
|
98
|
-
"
|
|
98
|
+
" node scripts/loop/ensure-worktree.mjs --repo-root <main> --issue <n>\n" +
|
|
99
|
+
" (creates+provisions tmp/worktrees/dev-loops/<kind>-<n> from origin/main)\n" +
|
|
99
100
|
"Then re-run from the worktree directory.",
|
|
100
101
|
mainWorktreePath,
|
|
101
102
|
};
|
|
@@ -105,7 +106,8 @@ function checkWorktreeIsolation({ cwd, env, gitCommand = "git" }) {
|
|
|
105
106
|
error: "not_in_worktree",
|
|
106
107
|
guidance:
|
|
107
108
|
"Local implementation requires worktree isolation. Create a worktree:\n" +
|
|
108
|
-
"
|
|
109
|
+
" node scripts/loop/ensure-worktree.mjs --repo-root <main> --issue <n>\n" +
|
|
110
|
+
" (creates+provisions tmp/worktrees/dev-loops/<kind>-<n> from origin/main)\n" +
|
|
109
111
|
"Then re-run from the worktree directory.",
|
|
110
112
|
mainWorktreePath: mainWorktreePath ?? undefined,
|
|
111
113
|
};
|
|
@@ -118,7 +120,8 @@ function checkWorktreeIsolation({ cwd, env, gitCommand = "git" }) {
|
|
|
118
120
|
guidance:
|
|
119
121
|
"Current directory is under tmp/worktrees/ but is not a real git worktree.\n" +
|
|
120
122
|
"Create a worktree with:\n" +
|
|
121
|
-
"
|
|
123
|
+
" node scripts/loop/ensure-worktree.mjs --repo-root <main> --issue <n>\n" +
|
|
124
|
+
" (creates+provisions tmp/worktrees/dev-loops/<kind>-<n> from origin/main)\n" +
|
|
122
125
|
"Then re-run from the worktree directory.",
|
|
123
126
|
mainWorktreePath: mainWorktreePath ?? undefined,
|
|
124
127
|
};
|
|
@@ -180,11 +183,11 @@ export async function runCli(
|
|
|
180
183
|
stdout.write(`${USAGE}\n`);
|
|
181
184
|
return { ok: true, help: true };
|
|
182
185
|
}
|
|
183
|
-
if ((effectiveEnv[
|
|
186
|
+
if ((effectiveEnv[DEVLOOPS_PREFLIGHT_BYPASS_VAR] ?? "").trim() === "1") {
|
|
184
187
|
const payload = {
|
|
185
188
|
ok: true,
|
|
186
189
|
checks: { worktree: true, branch: "skipped", subagents: "skipped" },
|
|
187
|
-
summary: "pre-flight gate bypassed via
|
|
190
|
+
summary: "pre-flight gate bypassed via DEVLOOPS_PREFLIGHT_BYPASS=1",
|
|
188
191
|
};
|
|
189
192
|
stdout.write(`${JSON.stringify(payload)}\n`);
|
|
190
193
|
return payload;
|
|
@@ -3,7 +3,7 @@ import { createInterface } from "node:readline";
|
|
|
3
3
|
import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
|
|
4
4
|
import { parseArgs } from "node:util";
|
|
5
5
|
|
|
6
|
-
const
|
|
6
|
+
const DEVLOOPS_PREPUSH_BYPASS_VAR = "DEVLOOPS_PREPUSH_BYPASS";
|
|
7
7
|
const BLOCKED_REFS = ["refs/heads/main"];
|
|
8
8
|
|
|
9
9
|
const USAGE = `Usage:
|
|
@@ -17,7 +17,7 @@ Exit codes:
|
|
|
17
17
|
1 Push blocked (target is a protected ref)
|
|
18
18
|
|
|
19
19
|
Bypass:
|
|
20
|
-
|
|
20
|
+
DEVLOOPS_PREPUSH_BYPASS=1 Skip all checks (for emergencies only).
|
|
21
21
|
Preferred: push a feature branch and open a PR.`.trim();
|
|
22
22
|
|
|
23
23
|
const parseError = buildParseError(USAGE);
|
|
@@ -88,8 +88,8 @@ export async function runCli(argv = process.argv.slice(2), { stdout = process.st
|
|
|
88
88
|
const options = parsePrePushGuardCliArgs(argv);
|
|
89
89
|
if (options.help) { stdout.write(`${USAGE}\n`); return { ok: true, help: true }; }
|
|
90
90
|
|
|
91
|
-
if (env[
|
|
92
|
-
stdout.write(JSON.stringify({ ok: true, bypassed: true, reason: `${
|
|
91
|
+
if ((env[DEVLOOPS_PREPUSH_BYPASS_VAR] ?? "").trim() === "1") {
|
|
92
|
+
stdout.write(JSON.stringify({ ok: true, bypassed: true, reason: `${DEVLOOPS_PREPUSH_BYPASS_VAR}=1` }) + "\n");
|
|
93
93
|
return { ok: true, bypassed: true };
|
|
94
94
|
}
|
|
95
95
|
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Provision a freshly-created worktree with the gitignored files/dirs the app
|
|
4
|
+
* and tests need, copied/symlinked from the main checkout per `.devloops`
|
|
5
|
+
* `worktree.copyOnInit` / `worktree.linkOnInit` (issue #909).
|
|
6
|
+
*
|
|
7
|
+
* - Sources resolve against the main checkout (`--repo-root`), never cwd.
|
|
8
|
+
* - Entries are repo-relative literal paths OR glob patterns (native fsp.glob).
|
|
9
|
+
* - copy = `fs.cp(recursive)`; link = absolute symlink into the main checkout.
|
|
10
|
+
* - Every resolved source MUST resolve inside the main checkout (traversal guard).
|
|
11
|
+
* - Fail-soft: a missing source / empty glob logs one warning and continues.
|
|
12
|
+
* - Idempotent: skips a dest that is already correct.
|
|
13
|
+
* - Does NOT run npm install (deps belong to `npm ci`-in-worktree).
|
|
14
|
+
*
|
|
15
|
+
* Prints a JSON summary of actions to stdout. Never throws on a per-entry
|
|
16
|
+
* problem; exits 0 unless its own arguments are invalid.
|
|
17
|
+
*/
|
|
18
|
+
import fsp from "node:fs/promises";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
|
|
21
|
+
import { requireTokenValue } from "../_cli-primitives.mjs";
|
|
22
|
+
import { parseArgs } from "node:util";
|
|
23
|
+
import { loadDevLoopConfig, resolveWorktreeConfig } from "@dev-loops/core/config";
|
|
24
|
+
|
|
25
|
+
const USAGE = `Usage:
|
|
26
|
+
provision-worktree.mjs --worktree-path <p> --repo-root <p>
|
|
27
|
+
Provision a worktree with gitignored files/dirs from the main checkout,
|
|
28
|
+
driven by .devloops worktree.copyOnInit / worktree.linkOnInit.
|
|
29
|
+
Required:
|
|
30
|
+
--worktree-path <p> Absolute path to the target worktree.
|
|
31
|
+
--repo-root <p> Absolute path to the main checkout (source of files).
|
|
32
|
+
Optional:
|
|
33
|
+
-h, --help Show this help.
|
|
34
|
+
Output (stdout, JSON):
|
|
35
|
+
{ "ok": true, "actions": [ { "mode": "copy"|"link"|"skip"|"reject", ... } ],
|
|
36
|
+
"summary": { "copied": n, "linked": n, "skipped": n, "rejected": n,
|
|
37
|
+
"warnings": n } }`.trim();
|
|
38
|
+
|
|
39
|
+
const parseError = buildParseError(USAGE);
|
|
40
|
+
|
|
41
|
+
export function parseProvisionWorktreeCliArgs(argv) {
|
|
42
|
+
const options = { help: false, worktreePath: undefined, repoRoot: undefined };
|
|
43
|
+
const { tokens } = parseArgs({
|
|
44
|
+
args: [...argv],
|
|
45
|
+
options: {
|
|
46
|
+
help: { type: "boolean", short: "h" },
|
|
47
|
+
"worktree-path": { type: "string" },
|
|
48
|
+
"repo-root": { type: "string" },
|
|
49
|
+
},
|
|
50
|
+
allowPositionals: true,
|
|
51
|
+
strict: false,
|
|
52
|
+
tokens: true,
|
|
53
|
+
});
|
|
54
|
+
for (const token of tokens) {
|
|
55
|
+
if (token.kind === "positional") throw parseError(`Unknown argument: ${token.value}`);
|
|
56
|
+
if (token.kind !== "option") continue;
|
|
57
|
+
if (token.name === "help") {
|
|
58
|
+
options.help = true;
|
|
59
|
+
return options;
|
|
60
|
+
}
|
|
61
|
+
if (token.name === "worktree-path") {
|
|
62
|
+
options.worktreePath = requireTokenValue(token, parseError, { flagPattern: /^-/u });
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (token.name === "repo-root") {
|
|
66
|
+
options.repoRoot = requireTokenValue(token, parseError, { flagPattern: /^-/u });
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
70
|
+
}
|
|
71
|
+
if (!options.worktreePath) throw parseError("Missing required --worktree-path");
|
|
72
|
+
if (!options.repoRoot) throw parseError("Missing required --repo-root");
|
|
73
|
+
return options;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Resolve a repo-relative entry (literal or glob) to matched absolute sources
|
|
78
|
+
* inside repoRoot. Returns `{ matches: string[], traversal: string[] }`.
|
|
79
|
+
* `traversal` holds matches that escaped repoRoot (rejected by caller).
|
|
80
|
+
*/
|
|
81
|
+
async function expandEntry(entry, repoRoot) {
|
|
82
|
+
const matches = [];
|
|
83
|
+
const traversal = [];
|
|
84
|
+
const isGlob = /[*?[\]{}]/.test(entry);
|
|
85
|
+
const inside = (abs) => abs === repoRoot || abs.startsWith(repoRoot + path.sep);
|
|
86
|
+
|
|
87
|
+
if (isGlob) {
|
|
88
|
+
// native fsp.glob (Node >=22) — expand against the main checkout.
|
|
89
|
+
for await (const rel of fsp.glob(entry, { cwd: repoRoot })) {
|
|
90
|
+
const abs = path.resolve(repoRoot, rel);
|
|
91
|
+
(inside(abs) ? matches : traversal).push(abs);
|
|
92
|
+
}
|
|
93
|
+
} else {
|
|
94
|
+
const abs = path.resolve(repoRoot, entry);
|
|
95
|
+
(inside(abs) ? matches : traversal).push(abs);
|
|
96
|
+
}
|
|
97
|
+
return { matches, traversal };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function pathExists(p) {
|
|
101
|
+
try {
|
|
102
|
+
await fsp.lstat(p);
|
|
103
|
+
return true;
|
|
104
|
+
} catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The lexical `inside()` guard in expandEntry can't see through symlinks: a
|
|
111
|
+
* source that is itself a symlink (or sits under a symlinked dir) pointing
|
|
112
|
+
* OUTSIDE repoRoot resolves clean lexically but escapes on realpath. Re-check
|
|
113
|
+
* the realpath here BEFORE any copy/link. Returns true when the resolved
|
|
114
|
+
* source is genuinely inside repoRoot. Non-existent source → treat as inside
|
|
115
|
+
* (the copy/link helpers handle missing sources with their own warning).
|
|
116
|
+
*/
|
|
117
|
+
async function realpathInside(src, repoRoot) {
|
|
118
|
+
let real;
|
|
119
|
+
try {
|
|
120
|
+
real = await fsp.realpath(src);
|
|
121
|
+
} catch {
|
|
122
|
+
return true; // missing/broken — let the copy/link helper report it
|
|
123
|
+
}
|
|
124
|
+
const realRoot = await fsp.realpath(repoRoot).catch(() => repoRoot);
|
|
125
|
+
return real === realRoot || real.startsWith(realRoot + path.sep);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Copy: idempotent skip when dest already exists (worktree reuse). */
|
|
129
|
+
async function provisionCopy(src, dest, logWarn) {
|
|
130
|
+
if (!(await pathExists(src))) {
|
|
131
|
+
logWarn(`copyOnInit source missing, skipping: ${src}`);
|
|
132
|
+
return { mode: "skip", reason: "source-missing", src, dest };
|
|
133
|
+
}
|
|
134
|
+
if (await pathExists(dest)) return { mode: "skip", reason: "exists", src, dest };
|
|
135
|
+
await fsp.mkdir(path.dirname(dest), { recursive: true });
|
|
136
|
+
await fsp.cp(src, dest, { recursive: true });
|
|
137
|
+
return { mode: "copy", src, dest };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Symlink: absolute link into the main checkout. Idempotent when correct. */
|
|
141
|
+
async function provisionLink(src, dest, logWarn) {
|
|
142
|
+
if (!(await pathExists(src))) {
|
|
143
|
+
logWarn(`linkOnInit source missing, skipping: ${src}`);
|
|
144
|
+
return { mode: "skip", reason: "source-missing", src, dest };
|
|
145
|
+
}
|
|
146
|
+
const existing = await fsp.readlink(dest).catch(() => null);
|
|
147
|
+
if (existing !== null) {
|
|
148
|
+
if (path.resolve(path.dirname(dest), existing) === src) {
|
|
149
|
+
return { mode: "skip", reason: "exists", src, dest };
|
|
150
|
+
}
|
|
151
|
+
return { mode: "skip", reason: "dest-conflict", src, dest };
|
|
152
|
+
}
|
|
153
|
+
if (await pathExists(dest)) return { mode: "skip", reason: "dest-conflict", src, dest };
|
|
154
|
+
await fsp.mkdir(path.dirname(dest), { recursive: true });
|
|
155
|
+
await fsp.symlink(src, dest); // absolute target — survives worktree moves under tmp/
|
|
156
|
+
return { mode: "link", src, dest };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function provisionWorktree({ worktreePath, repoRoot }, { loadConfig = loadDevLoopConfig } = {}) {
|
|
160
|
+
const root = path.resolve(repoRoot);
|
|
161
|
+
const dst = path.resolve(worktreePath);
|
|
162
|
+
const actions = [];
|
|
163
|
+
const warnings = [];
|
|
164
|
+
const logWarn = (msg) => {
|
|
165
|
+
warnings.push(msg);
|
|
166
|
+
process.stderr.write(`[provision-worktree] WARN ${msg}\n`);
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
// Fail-closed (repo convention): a config that failed to load/validate is
|
|
170
|
+
// treated as EMPTY — zero provisioning actions, one WARN. We never act on an
|
|
171
|
+
// unvalidated config (a bad entry could copy/link the wrong source).
|
|
172
|
+
const { config, errors } = await loadConfig({ repoRoot: root });
|
|
173
|
+
const safeConfig = errors && errors.length > 0 ? null : config;
|
|
174
|
+
if (safeConfig === null && errors && errors.length > 0) {
|
|
175
|
+
logWarn(`dev-loop config invalid (${errors.length} error(s)) — provisioning EMPTY config (no copy/link)`);
|
|
176
|
+
}
|
|
177
|
+
const { copyOnInit, linkOnInit } = resolveWorktreeConfig(safeConfig);
|
|
178
|
+
|
|
179
|
+
for (const [entries, kind] of [[copyOnInit, "copy"], [linkOnInit, "link"]]) {
|
|
180
|
+
for (const entry of entries) {
|
|
181
|
+
const { matches, traversal } = await expandEntry(entry, root);
|
|
182
|
+
for (const abs of traversal) {
|
|
183
|
+
logWarn(`rejected (outside main checkout): ${entry} → ${abs}`);
|
|
184
|
+
actions.push({ mode: "reject", reason: "traversal", entry, src: abs });
|
|
185
|
+
}
|
|
186
|
+
if (matches.length === 0 && traversal.length === 0) {
|
|
187
|
+
logWarn(`no match for ${kind}OnInit entry: ${entry}`);
|
|
188
|
+
actions.push({ mode: "skip", reason: "no-match", entry });
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
for (const src of matches) {
|
|
192
|
+
// Symlink-aware traversal guard: a source whose realpath escapes the
|
|
193
|
+
// main checkout is rejected before any copy/link (the lexical inside()
|
|
194
|
+
// above cannot see through symlinks).
|
|
195
|
+
if (!(await realpathInside(src, root))) {
|
|
196
|
+
logWarn(`rejected (symlink escapes main checkout): ${entry} → ${src}`);
|
|
197
|
+
actions.push({ mode: "reject", reason: "traversal", entry, src });
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const dest = path.join(dst, path.relative(root, src));
|
|
201
|
+
// Fail-soft per the header promise: a per-entry copy/link failure (e.g.
|
|
202
|
+
// EACCES, dest conflict the helper didn't catch) logs ONE WARN, records
|
|
203
|
+
// a skip action, and continues — it never aborts the whole run.
|
|
204
|
+
try {
|
|
205
|
+
const res = kind === "copy"
|
|
206
|
+
? await provisionCopy(src, dest, logWarn)
|
|
207
|
+
: await provisionLink(src, dest, logWarn);
|
|
208
|
+
actions.push({ entry, ...res });
|
|
209
|
+
} catch (err) {
|
|
210
|
+
const msg = (err && err.message) ? err.message : String(err);
|
|
211
|
+
logWarn(`${kind} failed, skipping: ${src} → ${dest}: ${msg}`);
|
|
212
|
+
actions.push({ entry, mode: "skip", reason: `${kind}-failed: ${msg}`, src, dest });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const summary = { copied: 0, linked: 0, skipped: 0, rejected: 0, warnings: warnings.length };
|
|
219
|
+
for (const a of actions) {
|
|
220
|
+
if (a.mode === "copy") summary.copied++;
|
|
221
|
+
else if (a.mode === "link") summary.linked++;
|
|
222
|
+
else if (a.mode === "skip") summary.skipped++;
|
|
223
|
+
else if (a.mode === "reject") summary.rejected++;
|
|
224
|
+
}
|
|
225
|
+
return { ok: true, actions, summary };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export async function runCli(argv = process.argv.slice(2), { stdout = process.stdout } = {}) {
|
|
229
|
+
const options = parseProvisionWorktreeCliArgs(argv);
|
|
230
|
+
if (options.help) {
|
|
231
|
+
stdout.write(`${USAGE}\n`);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const result = await provisionWorktree(options);
|
|
235
|
+
stdout.write(`${JSON.stringify(result)}\n`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
239
|
+
runCli().catch((error) => {
|
|
240
|
+
process.stderr.write(`${formatCliError(error)}\n`);
|
|
241
|
+
process.exitCode = 1;
|
|
242
|
+
});
|
|
243
|
+
}
|
|
@@ -437,10 +437,10 @@ export function buildResolveDevLoopStartupResult(input, { adapter = createPiAdap
|
|
|
437
437
|
return buildAsyncStartRejection(validation);
|
|
438
438
|
}
|
|
439
439
|
}
|
|
440
|
-
const
|
|
440
|
+
const DEVLOOPS_WORKTREE_BYPASS_VAR = "DEVLOOPS_WORKTREE_BYPASS";
|
|
441
441
|
if (
|
|
442
442
|
strategyKey === "local_implementation" &&
|
|
443
|
-
(effectiveEnv[
|
|
443
|
+
(effectiveEnv[DEVLOOPS_WORKTREE_BYPASS_VAR] ?? "").trim() !== "1"
|
|
444
444
|
) {
|
|
445
445
|
try {
|
|
446
446
|
const worktreeOutput = execFileSync("git", ["worktree", "list"], {
|
|
@@ -453,8 +453,8 @@ export function buildResolveDevLoopStartupResult(input, { adapter = createPiAdap
|
|
|
453
453
|
const allPaths = parseAllWorktreePaths(worktreeOutput);
|
|
454
454
|
if (!isUnderWorktreePath(effectiveCwd)) {
|
|
455
455
|
const reason = mainPath !== null && isMainCheckout(effectiveCwd, mainPath)
|
|
456
|
-
? `Local implementation requires worktree isolation. Current directory is the main git checkout (${mainPath}).
|
|
457
|
-
: "Local implementation requires worktree isolation. Current directory is not under tmp/worktrees/.
|
|
456
|
+
? `Local implementation requires worktree isolation. Current directory is the main git checkout (${mainPath}). Run \`node scripts/loop/ensure-worktree.mjs --repo-root ${mainPath} --issue <n>\` to create+provision the worktree under tmp/worktrees/dev-loops/<kind>-<n>, then re-run from there.`
|
|
457
|
+
: "Local implementation requires worktree isolation. Current directory is not under tmp/worktrees/. Run `node scripts/loop/ensure-worktree.mjs --repo-root <main> --issue <n>` to create+provision a worktree under tmp/worktrees/dev-loops/<kind>-<n>, then re-run from there.";
|
|
458
458
|
return {
|
|
459
459
|
ok: true,
|
|
460
460
|
bundleKind: "needs_reconcile",
|
|
@@ -466,7 +466,7 @@ export function buildResolveDevLoopStartupResult(input, { adapter = createPiAdap
|
|
|
466
466
|
};
|
|
467
467
|
}
|
|
468
468
|
if (!isListedWorktree(effectiveCwd, allPaths)) {
|
|
469
|
-
const reason = `Local implementation requires worktree isolation. Current directory is under tmp/worktrees/ but is not listed as a git worktree by \`git worktree list\`. Create a proper worktree with \`
|
|
469
|
+
const reason = `Local implementation requires worktree isolation. Current directory is under tmp/worktrees/ but is not listed as a git worktree by \`git worktree list\`. Create a proper worktree with \`node scripts/loop/ensure-worktree.mjs --repo-root <main> --issue <n>\` and re-run.`;
|
|
470
470
|
return {
|
|
471
471
|
ok: true,
|
|
472
472
|
bundleKind: "needs_reconcile",
|