dev-loops 0.3.0 → 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.
Files changed (53) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +1 -1
  3. package/.claude/hooks/_run-context.mjs +9 -16
  4. package/.claude/skills/copilot-pr-followup/SKILL.md +9 -7
  5. package/.claude/skills/dev-loop/SKILL.md +6 -6
  6. package/.claude/skills/docs/anti-patterns.md +1 -1
  7. package/.claude/skills/docs/copilot-loop-operations.md +1 -1
  8. package/.claude/skills/docs/issue-intake-procedure.md +4 -0
  9. package/.claude/skills/docs/merge-preconditions.md +65 -1
  10. package/.claude/skills/docs/stop-conditions.md +1 -0
  11. package/.claude/skills/docs/tracker-first-loop-state.md +6 -6
  12. package/.claude/skills/local-implementation/SKILL.md +8 -2
  13. package/CHANGELOG.md +26 -0
  14. package/README.md +8 -2
  15. package/cli/index.mjs +21 -2
  16. package/extension/README.md +1 -1
  17. package/package.json +3 -3
  18. package/scripts/README.md +2 -2
  19. package/scripts/github/offer-human-handoff.mjs +147 -0
  20. package/scripts/github/probe-ci-status.mjs +468 -0
  21. package/scripts/github/request-copilot-review.mjs +3 -3
  22. package/scripts/github/resolve-handoff-candidates.mjs +412 -0
  23. package/scripts/github/upsert-checkpoint-verdict.mjs +2 -2
  24. package/scripts/loop/_stale-runner-detection.mjs +1 -1
  25. package/scripts/loop/_worktree-path.mjs +27 -0
  26. package/scripts/loop/cleanup-worktree.mjs +175 -0
  27. package/scripts/loop/copilot-pr-handoff.mjs +1 -1
  28. package/scripts/loop/detect-stale-runner.mjs +3 -4
  29. package/scripts/loop/ensure-worktree.mjs +219 -0
  30. package/scripts/loop/outer-loop.mjs +1 -1
  31. package/scripts/loop/pr-runner-coordination.mjs +1 -1
  32. package/scripts/loop/pre-flight-gate.mjs +10 -7
  33. package/scripts/loop/pre-push-main-guard.mjs +4 -4
  34. package/scripts/loop/provision-worktree.mjs +243 -0
  35. package/scripts/loop/resolve-dev-loop-startup.mjs +5 -5
  36. package/scripts/loop/run-queue.mjs +25 -1
  37. package/scripts/loop/run-watch-cycle.mjs +75 -22
  38. package/scripts/projects/add-queue-item.mjs +20 -5
  39. package/scripts/projects/archive-done-items.mjs +2 -1
  40. package/scripts/projects/ensure-queue-board.mjs +2 -1
  41. package/scripts/projects/list-queue-items.mjs +2 -1
  42. package/scripts/projects/move-queue-item.mjs +2 -1
  43. package/scripts/projects/reorder-queue-item.mjs +5 -4
  44. package/scripts/projects/sync-item-status.mjs +2 -1
  45. package/skills/copilot-pr-followup/SKILL.md +9 -7
  46. package/skills/dev-loop/SKILL.md +2 -2
  47. package/skills/docs/anti-patterns.md +1 -1
  48. package/skills/docs/copilot-loop-operations.md +1 -1
  49. package/skills/docs/issue-intake-procedure.md +4 -0
  50. package/skills/docs/merge-preconditions.md +65 -1
  51. package/skills/docs/stop-conditions.md +1 -0
  52. package/skills/docs/tracker-first-loop-state.md +6 -6
  53. package/skills/local-implementation/SKILL.md +8 -2
@@ -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 (or the PI_SUBAGENT_RUN_ID alias) is set, to prevent hidden detached-process fallback
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
- (falling back to the PI_SUBAGENT_RUN_ID alias).
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 PI_PREFLIGHT_BYPASS_VAR = "PI_PREFLIGHT_BYPASS";
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
- PI_PREFLIGHT_BYPASS=1 Skip all checks (for development/testing only).`.trim();
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
- " git worktree add -b <branch> tmp/worktrees/<slug>/ origin/main\n" +
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
- " git worktree add -b <branch> tmp/worktrees/<slug>/ origin/main\n" +
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
- " git worktree add -b <branch> tmp/worktrees/<slug>/ origin/main\n" +
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[PI_PREFLIGHT_BYPASS_VAR] ?? "").trim() === "1") {
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 PI_PREFLIGHT_BYPASS=1",
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 PI_PREPUSH_BYPASS_VAR = "PI_PREPUSH_BYPASS";
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
- PI_PREPUSH_BYPASS=1 Skip all checks (for emergencies only).
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[PI_PREPUSH_BYPASS_VAR] === "1") {
92
- stdout.write(JSON.stringify({ ok: true, bypassed: true, reason: `${PI_PREPUSH_BYPASS_VAR}=1` }) + "\n");
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 PI_WORKTREE_BYPASS_VAR = "PI_WORKTREE_BYPASS";
440
+ const DEVLOOPS_WORKTREE_BYPASS_VAR = "DEVLOOPS_WORKTREE_BYPASS";
441
441
  if (
442
442
  strategyKey === "local_implementation" &&
443
- (effectiveEnv[PI_WORKTREE_BYPASS_VAR] ?? "").trim() !== "1"
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}). Create a worktree under tmp/worktrees/<slug>/ and re-run.`
457
- : "Local implementation requires worktree isolation. Current directory is not under tmp/worktrees/. Create a worktree and re-run.";
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 \`git worktree add\` and re-run.`;
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",
@@ -20,6 +20,7 @@ import { computeParallelSchedule } from "@dev-loops/core/loop/queue-parallel";
20
20
  import { readQueue } from "@dev-loops/core/loop/queue-state";
21
21
  import { reconcileBoardMembership } from "@dev-loops/core/loop/queue-membership";
22
22
  import { parsePositiveInteger } from "@dev-loops/core/cli/primitives";
23
+ import { loadDevLoopConfig, resolveEffectiveMergeAuthorizedFromLoad } from "@dev-loops/core/config";
23
24
 
24
25
  const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url));
25
26
 
@@ -181,9 +182,32 @@ async function main() {
181
182
  console.error("Parallel dispatch via async subagents not yet wired; falling back to sequential.");
182
183
  }
183
184
 
185
+ // Authoritative merge-authorization gate: when the repo enforces
186
+ // humanMergeOnly, --merge-authorized is ignored (fails closed) so the queue
187
+ // driver never auto-merges. `loadDevLoopConfig` never throws — it returns an
188
+ // `errors` array — so a try/catch would not catch an unreadable/invalid
189
+ // `.devloops` (which may be the very file declaring humanMergeOnly). FAIL
190
+ // CLOSED on any config load/validation error: if the config cannot be
191
+ // confirmed, do NOT grant merge authorization. A compliance invariant must
192
+ // never be silently dropped because its config could not be read.
193
+ let effectiveMergeAuthorized = args.mergeAuthorized;
194
+ if (args.mergeAuthorized) {
195
+ const load = await loadDevLoopConfig({ repoRoot: REPO_ROOT });
196
+ effectiveMergeAuthorized = resolveEffectiveMergeAuthorizedFromLoad(args.mergeAuthorized, load);
197
+ if ((load.errors?.length ?? 0) > 0) {
198
+ console.error(
199
+ JSON.stringify({
200
+ ok: true,
201
+ warning: "dev-loop config could not be loaded/validated; failing closed on merge authorization (not auto-merging).",
202
+ errors: load.errors.map((e) => (e && e.message) || String(e)),
203
+ }),
204
+ );
205
+ }
206
+ }
207
+
184
208
  const result = await runQueue(REPO_ROOT, args.repo, {
185
209
  ...DEFAULT_QUEUE_DRIVER_OPTIONS,
186
- mergeAuthorized: args.mergeAuthorized,
210
+ mergeAuthorized: effectiveMergeAuthorized,
187
211
  reDispatchMaxRetries: args.reDispatchMaxRetries,
188
212
  });
189
213