@welluable/orch 1.1.0 → 1.2.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/README.md +201 -3
- package/agents/boundaries.js +26 -0
- package/agents/code-writer.js +5 -0
- package/agents/decomposer.js +62 -0
- package/agents/index.js +3 -0
- package/agents/integrator.js +41 -0
- package/agents/quick-fix.js +4 -0
- package/agents/test-writer.js +4 -0
- package/lib/agent.js +69 -6
- package/lib/commit.js +70 -0
- package/lib/fanout.js +355 -0
- package/lib/file-tracker.js +89 -0
- package/lib/integrate.js +51 -0
- package/lib/job-lifecycle.js +53 -0
- package/lib/jobs.js +284 -0
- package/lib/parse-decomposition.js +33 -0
- package/lib/run-context.js +13 -1
- package/lib/stage-summary.js +55 -6
- package/lib/worktree.js +4 -2
- package/main.js +2142 -214
- package/package.json +1 -1
package/lib/jobs.js
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
const ACTIVE_LIVE_STATES = ['running', 'pausing', 'paused'];
|
|
6
|
+
const TERMINAL_STATES = ['done', 'failed', 'stopped', 'crashed'];
|
|
7
|
+
|
|
8
|
+
/** Absolute paths for a job's on-disk artifacts under `<cwd>/.orch/<slug>/`. */
|
|
9
|
+
export function jobPaths(cwd, slug) {
|
|
10
|
+
const dir = path.join(path.resolve(cwd), '.orch', slug);
|
|
11
|
+
return {
|
|
12
|
+
dir,
|
|
13
|
+
runJsonPath: path.join(dir, 'run.json'),
|
|
14
|
+
lockPath: path.join(dir, '.run.lock'),
|
|
15
|
+
logPath: path.join(dir, 'orch.log'),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function atomicWriteJson(dir, filePath, data) {
|
|
20
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
21
|
+
const tmpPath = path.join(
|
|
22
|
+
dir,
|
|
23
|
+
`.${path.basename(filePath)}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`,
|
|
24
|
+
);
|
|
25
|
+
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2));
|
|
26
|
+
fs.renameSync(tmpPath, filePath);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Atomically (write-temp + rename) writes a job record to `run.json`. */
|
|
30
|
+
export function writeJob(cwd, slug, record) {
|
|
31
|
+
const { dir, runJsonPath } = jobPaths(cwd, slug);
|
|
32
|
+
atomicWriteJson(dir, runJsonPath, record);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Reads and parses `run.json`; `null` if missing; throws on invalid JSON. */
|
|
36
|
+
export function readJob(cwd, slug) {
|
|
37
|
+
const { runJsonPath } = jobPaths(cwd, slug);
|
|
38
|
+
let content;
|
|
39
|
+
try {
|
|
40
|
+
content = fs.readFileSync(runJsonPath, 'utf8');
|
|
41
|
+
} catch (err) {
|
|
42
|
+
if (err.code === 'ENOENT') return null;
|
|
43
|
+
throw err;
|
|
44
|
+
}
|
|
45
|
+
return JSON.parse(content);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** `process.kill(pid, 0)` wrapped in try/catch; never throws. */
|
|
49
|
+
export function isPidAlive(pid) {
|
|
50
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
51
|
+
try {
|
|
52
|
+
process.kill(pid, 0);
|
|
53
|
+
return true;
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function sleepSync(ms) {
|
|
60
|
+
const view = new Int32Array(new SharedArrayBuffer(4));
|
|
61
|
+
Atomics.wait(view, 0, 0, ms);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function sleep(ms) {
|
|
65
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Acquires `.run.lock` via exclusive create, busy-waiting on contention.
|
|
70
|
+
* A lock whose owner pid is no longer alive is treated as stale and removed.
|
|
71
|
+
*/
|
|
72
|
+
function acquireLock(lockPath, { timeoutMs = 5000, retryMs = 5 } = {}) {
|
|
73
|
+
const start = Date.now();
|
|
74
|
+
for (;;) {
|
|
75
|
+
try {
|
|
76
|
+
const fd = fs.openSync(lockPath, 'wx');
|
|
77
|
+
fs.writeSync(fd, JSON.stringify({ pid: process.pid }));
|
|
78
|
+
fs.closeSync(fd);
|
|
79
|
+
return;
|
|
80
|
+
} catch (err) {
|
|
81
|
+
if (err.code !== 'EEXIST') throw err;
|
|
82
|
+
|
|
83
|
+
let ownerPid = null;
|
|
84
|
+
try {
|
|
85
|
+
ownerPid = JSON.parse(fs.readFileSync(lockPath, 'utf8')).pid;
|
|
86
|
+
} catch {
|
|
87
|
+
// Lock file mid-write or briefly unreadable; treat as contention.
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (ownerPid != null && !isPidAlive(ownerPid)) {
|
|
91
|
+
try {
|
|
92
|
+
fs.unlinkSync(lockPath);
|
|
93
|
+
} catch {
|
|
94
|
+
// Another process may have removed it first; retry.
|
|
95
|
+
}
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (Date.now() - start > timeoutMs) {
|
|
100
|
+
throw new Error(`patchJob: timed out waiting for lock ${lockPath}`);
|
|
101
|
+
}
|
|
102
|
+
sleepSync(retryMs);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Locks, re-reads, shallow-merges `patchFnOrObject` (an object, or a
|
|
109
|
+
* `(current) => partialPatch` function) over the latest record, atomically
|
|
110
|
+
* writes, unlocks, and returns the updated record.
|
|
111
|
+
*/
|
|
112
|
+
export function patchJob(cwd, slug, patchFnOrObject) {
|
|
113
|
+
const { dir, runJsonPath, lockPath } = jobPaths(cwd, slug);
|
|
114
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
115
|
+
acquireLock(lockPath);
|
|
116
|
+
try {
|
|
117
|
+
const current = readJob(cwd, slug) ?? {};
|
|
118
|
+
const patch = typeof patchFnOrObject === 'function' ? patchFnOrObject(current) : patchFnOrObject;
|
|
119
|
+
const updated = { ...current, ...patch };
|
|
120
|
+
atomicWriteJson(dir, runJsonPath, updated);
|
|
121
|
+
return updated;
|
|
122
|
+
} finally {
|
|
123
|
+
try {
|
|
124
|
+
fs.unlinkSync(lockPath);
|
|
125
|
+
} catch {
|
|
126
|
+
// Already gone; nothing to clean up.
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* If `record` is in an active live state (running/pausing/paused) and its
|
|
133
|
+
* pid is dead, atomically rewrites it to `crashed` and returns the updated
|
|
134
|
+
* record. Otherwise returns `record` unchanged.
|
|
135
|
+
*/
|
|
136
|
+
export function reconcileJob(cwd, slug, record) {
|
|
137
|
+
if (!ACTIVE_LIVE_STATES.includes(record.state)) return record;
|
|
138
|
+
if (isPidAlive(record.pid)) return record;
|
|
139
|
+
return patchJob(cwd, slug, {
|
|
140
|
+
state: 'crashed',
|
|
141
|
+
finishedAt: new Date().toISOString(),
|
|
142
|
+
exitCode: null,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Every `run.json` under `<cwd>/.orch/*`, reconciled, most-recent-first by `startedAt`. */
|
|
147
|
+
export function listJobs(cwd) {
|
|
148
|
+
const orchDir = path.join(path.resolve(cwd), '.orch');
|
|
149
|
+
if (!fs.existsSync(orchDir)) return [];
|
|
150
|
+
|
|
151
|
+
const slugs = fs.readdirSync(orchDir, { withFileTypes: true })
|
|
152
|
+
.filter((entry) => entry.isDirectory())
|
|
153
|
+
.map((entry) => entry.name);
|
|
154
|
+
|
|
155
|
+
const jobs = [];
|
|
156
|
+
for (const slug of slugs) {
|
|
157
|
+
const record = readJob(cwd, slug);
|
|
158
|
+
if (!record) continue;
|
|
159
|
+
jobs.push(reconcileJob(cwd, slug, record));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
jobs.sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime());
|
|
163
|
+
return jobs;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Cooperative pause point: no-op if `pauseRequested` is falsy. Otherwise
|
|
168
|
+
* patches to `paused`, polls `run.json` (no lock/fd held) until
|
|
169
|
+
* `pauseRequested` clears, then patches back to `running`.
|
|
170
|
+
*/
|
|
171
|
+
export async function checkpointPause(cwd, slug, { pollIntervalMs = 500 } = {}) {
|
|
172
|
+
const record = readJob(cwd, slug);
|
|
173
|
+
if (!record?.pauseRequested) return;
|
|
174
|
+
|
|
175
|
+
patchJob(cwd, slug, { state: 'paused' });
|
|
176
|
+
|
|
177
|
+
for (;;) {
|
|
178
|
+
await sleep(pollIntervalMs);
|
|
179
|
+
const current = readJob(cwd, slug);
|
|
180
|
+
if (!current?.pauseRequested) break;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
patchJob(cwd, slug, { state: 'running' });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The pure operation behind `orch pause <slug>`. */
|
|
187
|
+
export function requestPause(cwd, slug) {
|
|
188
|
+
const record = readJob(cwd, slug);
|
|
189
|
+
if (!record) throw new Error(`requestPause: unknown job ${slug}`);
|
|
190
|
+
if (TERMINAL_STATES.includes(record.state)) {
|
|
191
|
+
throw new Error(`requestPause: job ${slug} is in terminal state ${record.state}`);
|
|
192
|
+
}
|
|
193
|
+
if (record.state === 'pausing' || record.state === 'paused') return record;
|
|
194
|
+
|
|
195
|
+
const patch = { pauseRequested: true };
|
|
196
|
+
if (record.state === 'running') patch.state = 'pausing';
|
|
197
|
+
return patchJob(cwd, slug, patch);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The pure operation behind `orch resume <slug>`. */
|
|
201
|
+
export function requestResume(cwd, slug) {
|
|
202
|
+
const record = readJob(cwd, slug);
|
|
203
|
+
if (!record) throw new Error(`requestResume: unknown job ${slug}`);
|
|
204
|
+
if (TERMINAL_STATES.includes(record.state)) {
|
|
205
|
+
throw new Error(`requestResume: job ${slug} is in terminal state ${record.state}`);
|
|
206
|
+
}
|
|
207
|
+
return patchJob(cwd, slug, { pauseRequested: false, state: 'running' });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The pure operation behind `orch stop <slug>`: signals a live pid (leaving
|
|
212
|
+
* the eventual `stopped` write to the child's own `shutdown()`), or
|
|
213
|
+
* reconciles a dead one to `crashed`. `kill` is injectable for tests.
|
|
214
|
+
*/
|
|
215
|
+
export function stopJob(cwd, slug, { kill = (pid, signal) => process.kill(pid, signal) } = {}) {
|
|
216
|
+
const record = readJob(cwd, slug);
|
|
217
|
+
if (!record) throw new Error(`stopJob: unknown job ${slug}`);
|
|
218
|
+
|
|
219
|
+
if (TERMINAL_STATES.includes(record.state)) {
|
|
220
|
+
return { action: 'already-terminal', record };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (isPidAlive(record.pid)) {
|
|
224
|
+
kill(record.pid, 'SIGTERM');
|
|
225
|
+
return { action: 'signaled', record };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const reconciled = reconcileJob(cwd, slug, record);
|
|
229
|
+
if (reconciled.state === 'crashed' && record.state !== 'crashed') {
|
|
230
|
+
return { action: 'crashed', record: reconciled };
|
|
231
|
+
}
|
|
232
|
+
return { action: 'already-terminal', record: reconciled };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const LIVE_PAUSE_STATES = ['running', 'pausing', 'paused'];
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Parent → children pause: `requestPause` on the parent, then the same pause
|
|
239
|
+
* write on every child with `parent === parentSlug` in a live pause state.
|
|
240
|
+
* Returns how many children were cascade-targeted (CLI prints this count).
|
|
241
|
+
*/
|
|
242
|
+
export function cascadePause(cwd, parentSlug) {
|
|
243
|
+
requestPause(cwd, parentSlug);
|
|
244
|
+
let childrenSignaled = 0;
|
|
245
|
+
for (const job of listJobs(cwd)) {
|
|
246
|
+
if (job.parent !== parentSlug) continue;
|
|
247
|
+
if (!LIVE_PAUSE_STATES.includes(job.state)) continue;
|
|
248
|
+
requestPause(cwd, job.slug);
|
|
249
|
+
childrenSignaled += 1;
|
|
250
|
+
}
|
|
251
|
+
return { childrenSignaled };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Parent → children resume: `requestResume` on the parent, then cascade
|
|
256
|
+
* resume only to non-terminal children that are `paused`/`pausing`.
|
|
257
|
+
*/
|
|
258
|
+
export function cascadeResume(cwd, parentSlug) {
|
|
259
|
+
requestResume(cwd, parentSlug);
|
|
260
|
+
let childrenSignaled = 0;
|
|
261
|
+
for (const job of listJobs(cwd)) {
|
|
262
|
+
if (job.parent !== parentSlug) continue;
|
|
263
|
+
if (job.state !== 'paused' && job.state !== 'pausing') continue;
|
|
264
|
+
requestResume(cwd, job.slug);
|
|
265
|
+
childrenSignaled += 1;
|
|
266
|
+
}
|
|
267
|
+
return { childrenSignaled };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* The pure operation behind `orch jobs clean`: removes every entry under
|
|
272
|
+
* `<cwd>/.orch/`. Returns the names that were deleted (empty if `.orch`
|
|
273
|
+
* is missing or already empty).
|
|
274
|
+
*/
|
|
275
|
+
export function cleanJobs(cwd) {
|
|
276
|
+
const orchDir = path.join(path.resolve(cwd), '.orch');
|
|
277
|
+
if (!fs.existsSync(orchDir)) return [];
|
|
278
|
+
|
|
279
|
+
const names = fs.readdirSync(orchDir);
|
|
280
|
+
for (const name of names) {
|
|
281
|
+
fs.rmSync(path.join(orchDir, name), { recursive: true, force: true });
|
|
282
|
+
}
|
|
283
|
+
return names;
|
|
284
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Parse decomposer agent final message as JSON. Returns null on any failure. */
|
|
2
|
+
export function parseDecomposition(result) {
|
|
3
|
+
if (typeof result !== 'string') return null;
|
|
4
|
+
|
|
5
|
+
const trimmed = result.trim();
|
|
6
|
+
if (!trimmed) return null;
|
|
7
|
+
|
|
8
|
+
const tryParse = (text) => {
|
|
9
|
+
try {
|
|
10
|
+
return JSON.parse(text);
|
|
11
|
+
} catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
let parsed = tryParse(trimmed);
|
|
17
|
+
if (parsed && typeof parsed === 'object') return parsed;
|
|
18
|
+
|
|
19
|
+
const fenceMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
20
|
+
if (fenceMatch) {
|
|
21
|
+
parsed = tryParse(fenceMatch[1].trim());
|
|
22
|
+
if (parsed && typeof parsed === 'object') return parsed;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const start = trimmed.indexOf('{');
|
|
26
|
+
const end = trimmed.lastIndexOf('}');
|
|
27
|
+
if (start !== -1 && end > start) {
|
|
28
|
+
parsed = tryParse(trimmed.slice(start, end + 1));
|
|
29
|
+
if (parsed && typeof parsed === 'object') return parsed;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return null;
|
|
33
|
+
}
|
package/lib/run-context.js
CHANGED
|
@@ -8,10 +8,22 @@ import { generateSlug as defaultGenerateSlug } from './slug.js';
|
|
|
8
8
|
* generated directory already exists; throws once attempts are exhausted
|
|
9
9
|
* rather than reusing or repairing an existing directory.
|
|
10
10
|
*/
|
|
11
|
-
export function createRunContext({ cwd, generateSlug = defaultGenerateSlug, maxAttempts = 5 } = {}) {
|
|
11
|
+
export function createRunContext({ cwd, slug, generateSlug = defaultGenerateSlug, maxAttempts = 5 } = {}) {
|
|
12
12
|
const absCwd = path.resolve(cwd);
|
|
13
13
|
const orchDir = path.join(absCwd, '.orch');
|
|
14
14
|
|
|
15
|
+
if (slug) {
|
|
16
|
+
const artifactDir = path.join(orchDir, slug);
|
|
17
|
+
fs.mkdirSync(artifactDir, { recursive: true });
|
|
18
|
+
return {
|
|
19
|
+
slug,
|
|
20
|
+
artifactDir,
|
|
21
|
+
researchPath: path.join(artifactDir, 'research.md'),
|
|
22
|
+
taskPath: path.join(artifactDir, 'task.md'),
|
|
23
|
+
statusPath: path.join(artifactDir, 'status.md'),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
15
27
|
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
16
28
|
const slug = generateSlug();
|
|
17
29
|
const artifactDir = path.join(orchDir, slug);
|
package/lib/stage-summary.js
CHANGED
|
@@ -38,6 +38,34 @@ function splitSummaryIntoBullets(summary) {
|
|
|
38
38
|
.filter(Boolean);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
const FILES_NOTES_HEADER_RE = /^\s*files\s*:?\s*$/i;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Pull an optional trailing `Files:` block (one `<path>: <one-line note>`
|
|
45
|
+
* per line) off the end of a stage summary, so writer agents can attach a
|
|
46
|
+
* short description to each file they touched. Returns the prose with that
|
|
47
|
+
* block removed plus a `path -> note` map; when no `Files:` line is present
|
|
48
|
+
* the summary passes through unchanged and the map is empty.
|
|
49
|
+
*/
|
|
50
|
+
function extractFileNotes(summary) {
|
|
51
|
+
const lines = summary.split('\n');
|
|
52
|
+
const headerIdx = lines.findIndex((line) => FILES_NOTES_HEADER_RE.test(line));
|
|
53
|
+
if (headerIdx === -1) return { prose: summary, notes: new Map() };
|
|
54
|
+
|
|
55
|
+
const notes = new Map();
|
|
56
|
+
for (const line of lines.slice(headerIdx + 1)) {
|
|
57
|
+
const trimmed = line.trim();
|
|
58
|
+
if (!trimmed) continue;
|
|
59
|
+
const sep = trimmed.indexOf(':');
|
|
60
|
+
if (sep === -1) continue;
|
|
61
|
+
const notePath = trimmed.slice(0, sep).trim();
|
|
62
|
+
const note = trimmed.slice(sep + 1).trim();
|
|
63
|
+
if (notePath && note) notes.set(notePath, note);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { prose: lines.slice(0, headerIdx).join('\n').trim(), notes };
|
|
67
|
+
}
|
|
68
|
+
|
|
41
69
|
/**
|
|
42
70
|
* Print a titled, bulleted block for a stage's summary paragraph, e.g.:
|
|
43
71
|
*
|
|
@@ -46,12 +74,24 @@ function splitSummaryIntoBullets(summary) {
|
|
|
46
74
|
* ──────────
|
|
47
75
|
* • Sentence one.
|
|
48
76
|
* • Sentence two.
|
|
77
|
+
* Files (2)
|
|
78
|
+
* ~ lib/agent.js - wired the tracker into onToolEvent
|
|
79
|
+
* + lib/file-tracker.js - added the ordered/deduped file collector
|
|
49
80
|
*
|
|
50
|
-
*
|
|
51
|
-
* a
|
|
81
|
+
* A trailing `Files:` block in `summary` (one `<path>: <note>` per line)
|
|
82
|
+
* supplies the per-file note; a file with no matching note prints as before
|
|
83
|
+
* (marker + path only). Prints when prose or files are nonempty; both
|
|
84
|
+
* empty → no-op.
|
|
85
|
+
*
|
|
86
|
+
* @param {string} label
|
|
87
|
+
* @param {string} summary
|
|
88
|
+
* @param {{ marker: string, path: string }[]=} files
|
|
52
89
|
*/
|
|
53
|
-
export function printStageSummary(label, summary) {
|
|
54
|
-
|
|
90
|
+
export function printStageSummary(label, summary, files) {
|
|
91
|
+
const { prose, notes } = extractFileNotes(summary || '');
|
|
92
|
+
const hasSummary = Boolean(prose);
|
|
93
|
+
const hasFiles = Array.isArray(files) && files.length > 0;
|
|
94
|
+
if (!hasSummary && !hasFiles) return;
|
|
55
95
|
|
|
56
96
|
const title = ` ${label} `;
|
|
57
97
|
const rule = '─'.repeat(title.length);
|
|
@@ -60,8 +100,17 @@ export function printStageSummary(label, summary) {
|
|
|
60
100
|
console.log(rule);
|
|
61
101
|
console.log(title);
|
|
62
102
|
console.log(rule);
|
|
63
|
-
|
|
64
|
-
|
|
103
|
+
if (hasSummary) {
|
|
104
|
+
for (const bullet of splitSummaryIntoBullets(prose)) {
|
|
105
|
+
console.log(` • ${bullet}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (hasFiles) {
|
|
109
|
+
console.log(` Files (${files.length})`);
|
|
110
|
+
for (const { marker, path: filePath } of files) {
|
|
111
|
+
const note = notes.get(filePath);
|
|
112
|
+
console.log(` ${marker} ${filePath}${note ? ` - ${note}` : ''}`);
|
|
113
|
+
}
|
|
65
114
|
}
|
|
66
115
|
console.log();
|
|
67
116
|
}
|
package/lib/worktree.js
CHANGED
|
@@ -22,7 +22,7 @@ function runGit(execFile, args) {
|
|
|
22
22
|
* `execFile` is injectable and defaults to a `child_process.execFileSync`
|
|
23
23
|
* wrapper.
|
|
24
24
|
*/
|
|
25
|
-
export function createWorktree({ cwd, slug, execFile = defaultExecFile }) {
|
|
25
|
+
export function createWorktree({ cwd, slug, execFile = defaultExecFile, base }) {
|
|
26
26
|
const repoRoot = runGit(execFile, ['-C', cwd, 'rev-parse', '--show-toplevel']).trim();
|
|
27
27
|
|
|
28
28
|
const worktreePath = `${path.join(path.dirname(repoRoot), path.basename(repoRoot))}-${slug}`;
|
|
@@ -32,7 +32,9 @@ export function createWorktree({ cwd, slug, execFile = defaultExecFile }) {
|
|
|
32
32
|
throw new Error(`createWorktree: refusing to overwrite existing path ${worktreePath}`);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
const args = ['-C', repoRoot, 'worktree', 'add', '-b', branch, worktreePath];
|
|
36
|
+
if (base) args.push(base);
|
|
37
|
+
runGit(execFile, args);
|
|
36
38
|
|
|
37
39
|
return { repoRoot, worktreePath, branch };
|
|
38
40
|
}
|