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.
- package/.claude/.claude-plugin/plugin.json +1 -1
- package/.claude/agents/dev-loop.md +1 -1
- package/.claude/agents/refiner.md +1 -0
- package/.claude/hooks/_run-context.mjs +9 -16
- package/.claude/skills/copilot-pr-followup/SKILL.md +9 -7
- package/.claude/skills/dev-loop/SKILL.md +17 -7
- package/.claude/skills/dev-loop/templates/slides-story-review.md +54 -0
- package/.claude/skills/docs/anti-patterns.md +1 -1
- package/.claude/skills/docs/artifact-authority-contract.md +86 -31
- package/.claude/skills/docs/copilot-loop-operations.md +1 -1
- package/.claude/skills/docs/issue-intake-procedure.md +4 -0
- package/.claude/skills/docs/local-planning-flow.md +63 -0
- package/.claude/skills/docs/local-planning-worked-example.md +139 -0
- package/.claude/skills/docs/merge-preconditions.md +100 -1
- package/.claude/skills/docs/plan-file-contract.md +37 -0
- package/.claude/skills/docs/retrospective-checkpoint-contract.md +55 -7
- package/.claude/skills/docs/spike-mode-contract.md +237 -0
- 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 +8 -2
- package/CHANGELOG.md +69 -0
- package/README.md +9 -2
- package/agents/refiner.agent.md +1 -0
- package/cli/index.mjs +21 -2
- package/extension/README.md +1 -1
- package/package.json +6 -4
- package/scripts/README.md +2 -2
- package/scripts/github/capture-review-threads.mjs +20 -2
- package/scripts/github/offer-human-handoff.mjs +147 -0
- package/scripts/github/probe-ci-status.mjs +468 -0
- package/scripts/github/probe-copilot-review.mjs +69 -3
- package/scripts/github/request-copilot-review.mjs +3 -3
- package/scripts/github/resolve-handoff-candidates.mjs +412 -0
- package/scripts/github/upsert-checkpoint-verdict.mjs +20 -5
- package/scripts/lib/jq-output.mjs +297 -0
- package/scripts/loop/_stale-runner-detection.mjs +1 -1
- package/scripts/loop/_worktree-path.mjs +27 -0
- package/scripts/loop/check-retro-tooling.mjs +246 -0
- package/scripts/loop/cleanup-worktree.mjs +175 -0
- package/scripts/loop/copilot-pr-handoff.mjs +22 -4
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +35 -2
- package/scripts/loop/detect-stale-runner.mjs +3 -4
- package/scripts/loop/docs-grill-contract.mjs +70 -0
- package/scripts/loop/ensure-worktree.mjs +219 -0
- package/scripts/loop/info.mjs +21 -2
- package/scripts/loop/outer-loop.mjs +1 -1
- package/scripts/loop/pr-runner-coordination.mjs +21 -5
- 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 +181 -10
- package/scripts/loop/resolve-pr-conflicts.mjs +357 -0
- package/scripts/loop/run-queue.mjs +25 -1
- package/scripts/loop/run-watch-cycle.mjs +152 -25
- package/scripts/loop/slides-story-review-contract.mjs +123 -0
- package/scripts/pages/build-site.mjs +136 -0
- package/scripts/projects/add-queue-item.mjs +32 -7
- package/scripts/projects/archive-done-items.mjs +2 -1
- package/scripts/projects/ensure-queue-board.mjs +2 -1
- package/scripts/projects/list-queue-items.mjs +14 -3
- package/scripts/projects/move-queue-item.mjs +14 -3
- package/scripts/projects/reorder-queue-item.mjs +5 -4
- package/scripts/projects/sync-item-status.mjs +2 -1
- package/scripts/refine/_refine-helpers.mjs +20 -0
- package/scripts/refine/exit-spike.mjs +186 -0
- package/scripts/refine/promote-plan.mjs +387 -0
- package/scripts/refine/refine-plan-file.mjs +165 -0
- package/scripts/refine/validate-plan-file.mjs +64 -0
- package/scripts/refine/validate-spike-file.mjs +87 -0
- package/skills/copilot-pr-followup/SKILL.md +9 -7
- package/skills/dev-loop/SKILL.md +13 -3
- package/skills/dev-loop/templates/slides-story-review.md +54 -0
- package/skills/docs/anti-patterns.md +1 -1
- package/skills/docs/artifact-authority-contract.md +86 -31
- package/skills/docs/copilot-loop-operations.md +1 -1
- package/skills/docs/issue-intake-procedure.md +4 -0
- package/skills/docs/local-planning-flow.md +63 -0
- package/skills/docs/local-planning-worked-example.md +139 -0
- package/skills/docs/merge-preconditions.md +100 -1
- package/skills/docs/plan-file-contract.md +37 -0
- package/skills/docs/retrospective-checkpoint-contract.md +55 -7
- package/skills/docs/spike-mode-contract.md +237 -0
- package/skills/docs/stop-conditions.md +1 -0
- package/skills/docs/tracker-first-loop-state.md +6 -6
- package/skills/local-implementation/SKILL.md +8 -2
package/scripts/loop/info.mjs
CHANGED
|
@@ -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
|
|
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
|
-
(
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|