dev-loops 0.3.0 → 0.5.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 (85) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +1 -1
  3. package/.claude/agents/refiner.md +1 -0
  4. package/.claude/hooks/_run-context.mjs +9 -16
  5. package/.claude/skills/copilot-pr-followup/SKILL.md +9 -7
  6. package/.claude/skills/dev-loop/SKILL.md +17 -7
  7. package/.claude/skills/dev-loop/templates/slides-story-review.md +54 -0
  8. package/.claude/skills/docs/anti-patterns.md +1 -1
  9. package/.claude/skills/docs/artifact-authority-contract.md +86 -31
  10. package/.claude/skills/docs/copilot-loop-operations.md +1 -1
  11. package/.claude/skills/docs/issue-intake-procedure.md +4 -0
  12. package/.claude/skills/docs/local-planning-flow.md +63 -0
  13. package/.claude/skills/docs/local-planning-worked-example.md +139 -0
  14. package/.claude/skills/docs/merge-preconditions.md +100 -1
  15. package/.claude/skills/docs/plan-file-contract.md +37 -0
  16. package/.claude/skills/docs/retrospective-checkpoint-contract.md +55 -7
  17. package/.claude/skills/docs/spike-mode-contract.md +237 -0
  18. package/.claude/skills/docs/stop-conditions.md +1 -0
  19. package/.claude/skills/docs/tracker-first-loop-state.md +6 -6
  20. package/.claude/skills/local-implementation/SKILL.md +8 -2
  21. package/CHANGELOG.md +69 -0
  22. package/README.md +9 -2
  23. package/agents/refiner.agent.md +1 -0
  24. package/cli/index.mjs +21 -2
  25. package/extension/README.md +1 -1
  26. package/package.json +6 -4
  27. package/scripts/README.md +2 -2
  28. package/scripts/github/capture-review-threads.mjs +20 -2
  29. package/scripts/github/offer-human-handoff.mjs +147 -0
  30. package/scripts/github/probe-ci-status.mjs +468 -0
  31. package/scripts/github/probe-copilot-review.mjs +69 -3
  32. package/scripts/github/request-copilot-review.mjs +3 -3
  33. package/scripts/github/resolve-handoff-candidates.mjs +412 -0
  34. package/scripts/github/upsert-checkpoint-verdict.mjs +20 -5
  35. package/scripts/lib/jq-output.mjs +297 -0
  36. package/scripts/loop/_stale-runner-detection.mjs +1 -1
  37. package/scripts/loop/_worktree-path.mjs +27 -0
  38. package/scripts/loop/check-retro-tooling.mjs +246 -0
  39. package/scripts/loop/cleanup-worktree.mjs +175 -0
  40. package/scripts/loop/copilot-pr-handoff.mjs +22 -4
  41. package/scripts/loop/detect-pr-gate-coordination-state.mjs +35 -2
  42. package/scripts/loop/detect-stale-runner.mjs +3 -4
  43. package/scripts/loop/docs-grill-contract.mjs +70 -0
  44. package/scripts/loop/ensure-worktree.mjs +219 -0
  45. package/scripts/loop/info.mjs +21 -2
  46. package/scripts/loop/outer-loop.mjs +1 -1
  47. package/scripts/loop/pr-runner-coordination.mjs +21 -5
  48. package/scripts/loop/pre-flight-gate.mjs +10 -7
  49. package/scripts/loop/pre-push-main-guard.mjs +4 -4
  50. package/scripts/loop/provision-worktree.mjs +243 -0
  51. package/scripts/loop/resolve-dev-loop-startup.mjs +181 -10
  52. package/scripts/loop/resolve-pr-conflicts.mjs +357 -0
  53. package/scripts/loop/run-queue.mjs +25 -1
  54. package/scripts/loop/run-watch-cycle.mjs +152 -25
  55. package/scripts/loop/slides-story-review-contract.mjs +123 -0
  56. package/scripts/pages/build-site.mjs +136 -0
  57. package/scripts/projects/add-queue-item.mjs +32 -7
  58. package/scripts/projects/archive-done-items.mjs +2 -1
  59. package/scripts/projects/ensure-queue-board.mjs +2 -1
  60. package/scripts/projects/list-queue-items.mjs +14 -3
  61. package/scripts/projects/move-queue-item.mjs +14 -3
  62. package/scripts/projects/reorder-queue-item.mjs +5 -4
  63. package/scripts/projects/sync-item-status.mjs +2 -1
  64. package/scripts/refine/_refine-helpers.mjs +20 -0
  65. package/scripts/refine/exit-spike.mjs +186 -0
  66. package/scripts/refine/promote-plan.mjs +387 -0
  67. package/scripts/refine/refine-plan-file.mjs +165 -0
  68. package/scripts/refine/validate-plan-file.mjs +64 -0
  69. package/scripts/refine/validate-spike-file.mjs +87 -0
  70. package/skills/copilot-pr-followup/SKILL.md +9 -7
  71. package/skills/dev-loop/SKILL.md +13 -3
  72. package/skills/dev-loop/templates/slides-story-review.md +54 -0
  73. package/skills/docs/anti-patterns.md +1 -1
  74. package/skills/docs/artifact-authority-contract.md +86 -31
  75. package/skills/docs/copilot-loop-operations.md +1 -1
  76. package/skills/docs/issue-intake-procedure.md +4 -0
  77. package/skills/docs/local-planning-flow.md +63 -0
  78. package/skills/docs/local-planning-worked-example.md +139 -0
  79. package/skills/docs/merge-preconditions.md +100 -1
  80. package/skills/docs/plan-file-contract.md +37 -0
  81. package/skills/docs/retrospective-checkpoint-contract.md +55 -7
  82. package/skills/docs/spike-mode-contract.md +237 -0
  83. package/skills/docs/stop-conditions.md +1 -0
  84. package/skills/docs/tracker-first-loop-state.md +6 -6
  85. package/skills/local-implementation/SKILL.md +8 -2
@@ -100,13 +100,32 @@ function formatCiDisplay(ciStatus, ciConclusion) {
100
100
  return `CI ${ciStatus}`;
101
101
  }
102
102
 
103
+ function formatMergeableDisplay(mergeable, mergeStateStatus) {
104
+ const m = typeof mergeable === "string" ? mergeable.toUpperCase() : null;
105
+ const s = typeof mergeStateStatus === "string" ? mergeStateStatus.toUpperCase() : null;
106
+ if (m === "CONFLICTING" || s === "DIRTY" || s === "CONFLICTING") {
107
+ return `❌ CONFLICTING${s ? ` (${s})` : ""} — resolve before any gate`;
108
+ }
109
+ if (s === "BEHIND") {
110
+ return "⚠️ BEHIND — update branch from base before any gate";
111
+ }
112
+ if (m === "UNKNOWN") {
113
+ return "⏳ UNKNOWN — GitHub still computing; recheck before proceeding";
114
+ }
115
+ if (m === "MERGEABLE") {
116
+ return `✅ MERGEABLE${s ? ` (${s})` : ""}`;
117
+ }
118
+ return s || m || "unknown";
119
+ }
120
+
103
121
  function formatPrSummary(prData, handoffResult) {
104
122
  const lines = [];
105
123
  lines.push(`PR #${prData.number}: ${prData.title}`);
106
124
  lines.push(` Branch: ${formatBranchDisplay(prData.headRefName, prData.baseRefName)}`);
107
125
  lines.push(` State: ${prData.state}${prData.isDraft ? " (draft)" : ""}`);
108
126
  lines.push(` Author: ${prData.author?.login || "unknown"}`);
109
-
127
+ lines.push(` Mergeable: ${formatMergeableDisplay(prData.mergeable, prData.mergeStateStatus)}`);
128
+
110
129
  if (handoffResult?.snapshot) {
111
130
  const s = handoffResult.snapshot;
112
131
  if (s.ciStatus !== undefined) {
@@ -189,7 +208,7 @@ function formatIssueSummary(issueData, startupBundle, linkedPrData) {
189
208
  }
190
209
 
191
210
  function buildPrInfo(prNumber, repo, cwd) {
192
- const prData = ghJson(["pr", "view", String(prNumber), "--repo", repo, "--json", "number,title,body,state,isDraft,headRefName,baseRefName,author,mergedAt,url,reviewRequests"], cwd);
211
+ const prData = ghJson(["pr", "view", String(prNumber), "--repo", repo, "--json", "number,title,body,state,isDraft,headRefName,baseRefName,author,mergedAt,mergeable,mergeStateStatus,url,reviewRequests"], cwd);
193
212
 
194
213
  let handoffResult = null;
195
214
  try {
@@ -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
@@ -5,6 +5,7 @@ import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helper
5
5
  import { parsePrNumber, requireTokenValue } from "../_cli-primitives.mjs";
6
6
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
7
7
  import { resolveRunId as resolveEnvRunId } from "@dev-loops/core/loop/run-context";
8
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
8
9
  import {
9
10
  assertRunnerOwnership,
10
11
  claimRunnerOwnership,
@@ -19,10 +20,11 @@ const USAGE = `Usage:
19
20
  pr-runner-coordination.mjs release --repo <owner/name> --pr <number> [--run-id <id>]
20
21
  Durable one-runner-per-PR coordination helper.
21
22
  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).
23
+ (via DEVLOOPS_RUN_ID).
23
24
  Output:
24
25
  stdout: { "ok": true, ... }
25
26
  stderr: { "ok": false, "error": "...", ... }
27
+ ${JQ_OUTPUT_USAGE}
26
28
  Exit codes:
27
29
  0 Success / clean stop-compatible result
28
30
  1 Argument error or coordination conflict`.trim();
@@ -36,6 +38,8 @@ function parseCliArgs(argv) {
36
38
  pr: undefined,
37
39
  runId: undefined,
38
40
  requireExisting: false,
41
+ jq: undefined,
42
+ silent: false,
39
43
  };
40
44
  const command = args.shift();
41
45
  if (command === undefined || command === "--help" || command === "-h") {
@@ -51,6 +55,7 @@ function parseCliArgs(argv) {
51
55
  pr: { type: "string" },
52
56
  "run-id": { type: "string" },
53
57
  "require-existing": { type: "boolean" },
58
+ ...JQ_OUTPUT_PARSE_OPTIONS,
54
59
  },
55
60
  allowPositionals: true,
56
61
  strict: false,
@@ -83,6 +88,14 @@ function parseCliArgs(argv) {
83
88
  options.requireExisting = true;
84
89
  continue;
85
90
  }
91
+ if (token.name === "jq") {
92
+ options.jq = requireTokenValue(token, parseError);
93
+ continue;
94
+ }
95
+ if (token.name === "silent") {
96
+ options.silent = true;
97
+ continue;
98
+ }
86
99
  throw parseError(`Unknown argument: ${token.rawName}`);
87
100
  }
88
101
  const validCommands = new Set(["status", "claim", "takeover", "assert", "release"]);
@@ -146,11 +159,14 @@ async function main() {
146
159
  }
147
160
  const result = await runPrRunnerCoordination(options, { env: process.env });
148
161
  if (!result.ok) {
149
- console.error(JSON.stringify(result));
150
- process.exitCode = 1;
151
- return;
162
+ // Preserve the established stderr-on-failure contract when not filtering.
163
+ if (options.jq === undefined && !options.silent) {
164
+ console.error(JSON.stringify(result));
165
+ process.exitCode = 1;
166
+ return;
167
+ }
152
168
  }
153
- console.log(JSON.stringify(result));
169
+ process.exitCode = emitResult(result, { jq: options.jq, silent: options.silent });
154
170
  } catch (error) {
155
171
  const payload = formatCliError(error, { usage: USAGE });
156
172
  console.error(JSON.stringify(payload));
@@ -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
+ }