@nonbot/cli 0.5.15 → 0.7.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.
@@ -0,0 +1,228 @@
1
+ import { SCHEMA_VERSION, SUMMARY_MAX, EGRESS_ALLOWED_TOP_KEYS, EGRESS_FORBIDDEN_KEYS, BRANCH_RE, } from './types.js';
2
+ const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
3
+ const TIME_LEN = 10;
4
+ const RAND_LEN = 16;
5
+ function encodeTime(ms) {
6
+ let t = Math.floor(ms);
7
+ const out = new Array(TIME_LEN);
8
+ for (let i = TIME_LEN - 1; i >= 0; i--) {
9
+ const mod = t % 32;
10
+ out[i] = CROCKFORD[mod];
11
+ t = Math.floor(t / 32);
12
+ }
13
+ return out.join('');
14
+ }
15
+ function encodeRandom(random) {
16
+ const out = new Array(RAND_LEN);
17
+ for (let i = 0; i < RAND_LEN; i++) {
18
+ out[i] = Math.floor(random() * 32) % 32;
19
+ }
20
+ return out;
21
+ }
22
+ function indicesToString(idx) {
23
+ let s = '';
24
+ for (const i of idx)
25
+ s += CROCKFORD[i];
26
+ return s;
27
+ }
28
+ export function ulid(ms, random = Math.random) {
29
+ return encodeTime(ms) + indicesToString(encodeRandom(random));
30
+ }
31
+ export function makeUlidFactory(deps) {
32
+ const random = deps.random ?? Math.random;
33
+ let lastMs = -1;
34
+ let lastRand = [];
35
+ return function next() {
36
+ const ms = Math.floor(deps.now());
37
+ if (ms === lastMs) {
38
+ lastRand = incrementBase32(lastRand);
39
+ }
40
+ else {
41
+ lastMs = ms;
42
+ lastRand = encodeRandom(random);
43
+ }
44
+ return encodeTime(ms) + indicesToString(lastRand);
45
+ };
46
+ }
47
+ function incrementBase32(idx) {
48
+ const out = idx.slice();
49
+ for (let i = out.length - 1; i >= 0; i--) {
50
+ if (out[i] < 31) {
51
+ out[i] += 1;
52
+ return out;
53
+ }
54
+ out[i] = 0;
55
+ }
56
+ return out;
57
+ }
58
+ const STAGE_TABLE = {
59
+ queued: { category: 'lifecycle', severity: 'info', terminal: false },
60
+ accepted: { category: 'lifecycle', severity: 'info', terminal: false },
61
+ launching: { category: 'provisioning', severity: 'progress', terminal: false },
62
+ 'agent-started': { category: 'lifecycle', severity: 'progress', terminal: false },
63
+ working: { category: 'work', severity: 'progress', terminal: false },
64
+ 'awaiting-input': { category: 'work', severity: 'blocked', terminal: false },
65
+ resumed: { category: 'work', severity: 'progress', terminal: false },
66
+ finished: { category: 'lifecycle', severity: 'info', terminal: true },
67
+ failed: { category: 'fault', severity: 'error', terminal: true },
68
+ stopped: { category: 'control', severity: 'info', terminal: true },
69
+ 'session-created': { category: 'lifecycle', severity: 'info', terminal: false },
70
+ 'worktrees-provisioned': { category: 'provisioning', severity: 'progress', terminal: false },
71
+ 'panes-spawned': { category: 'provisioning', severity: 'progress', terminal: false },
72
+ 'collision-detected': { category: 'coordination', severity: 'attention-needed', terminal: false },
73
+ 'collision-cleared': { category: 'coordination', severity: 'progress', terminal: false },
74
+ 'all-complete': { category: 'lifecycle', severity: 'attention-needed', terminal: false },
75
+ 'reconcile-started': { category: 'reconcile', severity: 'progress', terminal: false },
76
+ 'reconcile-complete': { category: 'reconcile', severity: 'info', terminal: true },
77
+ 'reconcile-blocked': { category: 'reconcile', severity: 'blocked', terminal: true },
78
+ 'session-ended': { category: 'lifecycle', severity: 'info', terminal: true },
79
+ 'pane-spawned': { category: 'provisioning', severity: 'progress', terminal: false },
80
+ 'claim-announced': { category: 'coordination', severity: 'progress', terminal: false },
81
+ 'broadcast-sent': { category: 'coordination', severity: 'attention-needed', terminal: false },
82
+ 'pane-complete': { category: 'work', severity: 'progress', terminal: true },
83
+ 'pane-failed': { category: 'fault', severity: 'error', terminal: true },
84
+ 'pane-stopped': { category: 'control', severity: 'info', terminal: true },
85
+ 'merge-step': { category: 'reconcile', severity: 'progress', terminal: false },
86
+ 'build-gate': { category: 'verification', severity: 'progress', terminal: false },
87
+ slow: { category: 'fault', severity: 'attention-needed', terminal: false },
88
+ stalled: { category: 'fault', severity: 'attention-needed', terminal: false },
89
+ 'monitor-offline': { category: 'fault', severity: 'error', terminal: false },
90
+ };
91
+ const UNKNOWN_STAGE_META = {
92
+ category: 'work',
93
+ severity: 'progress',
94
+ terminal: false,
95
+ };
96
+ export function stageMapping(stage) {
97
+ return STAGE_TABLE[stage] ?? UNKNOWN_STAGE_META;
98
+ }
99
+ function clampStr(s, max = SUMMARY_MAX) {
100
+ if (typeof s !== 'string')
101
+ return '';
102
+ return s.slice(0, max);
103
+ }
104
+ export function makeEvent(input, deps) {
105
+ const meta = stageMapping(input.stage);
106
+ const eventId = ulid(Math.floor(deps.now()), deps.random);
107
+ const event = {
108
+ schemaVersion: SCHEMA_VERSION,
109
+ eventId,
110
+ sessionId: clampStr(input.sessionId),
111
+ engine: input.engine,
112
+ paneId: input.paneId ?? null,
113
+ parentEventId: input.parentEventId ?? null,
114
+ stage: input.stage,
115
+ category: meta.category,
116
+ severity: meta.severity,
117
+ terminal: meta.terminal,
118
+ summary: clampStr(input.summary),
119
+ ts: Math.floor(input.ts),
120
+ };
121
+ if (input.metrics) {
122
+ event.metrics = sanitizeMetrics(input.metrics);
123
+ }
124
+ if (input.refs) {
125
+ event.refs = sanitizeRefs(input.refs);
126
+ }
127
+ return event;
128
+ }
129
+ const FORBIDDEN_KEY_SET = new Set(EGRESS_FORBIDDEN_KEYS);
130
+ const ALLOWED_TOP_SET = new Set(EGRESS_ALLOWED_TOP_KEYS);
131
+ function isForbiddenKey(key) {
132
+ return FORBIDDEN_KEY_SET.has(key);
133
+ }
134
+ function sanitizeMetrics(m) {
135
+ const out = {};
136
+ if (m && typeof m === 'object') {
137
+ for (const [k, v] of Object.entries(m)) {
138
+ if (isForbiddenKey(k))
139
+ continue;
140
+ if (typeof v === 'number' && Number.isFinite(v))
141
+ out[k] = v;
142
+ }
143
+ }
144
+ return out;
145
+ }
146
+ function sanitizeRefs(r) {
147
+ return deepClean(r);
148
+ }
149
+ function deepClean(value) {
150
+ if (typeof value === 'string')
151
+ return value.slice(0, SUMMARY_MAX);
152
+ if (typeof value === 'number')
153
+ return Number.isFinite(value) ? value : null;
154
+ if (typeof value === 'boolean' || value === null)
155
+ return value;
156
+ if (Array.isArray(value))
157
+ return value.map((v) => deepClean(v));
158
+ if (value && typeof value === 'object') {
159
+ const out = {};
160
+ for (const [k, v] of Object.entries(value)) {
161
+ if (isForbiddenKey(k))
162
+ continue;
163
+ out[k] = deepClean(v);
164
+ }
165
+ return out;
166
+ }
167
+ return null;
168
+ }
169
+ export function sanitizeForEgress(obj) {
170
+ const out = {};
171
+ if (!obj || typeof obj !== 'object')
172
+ return out;
173
+ for (const [k, v] of Object.entries(obj)) {
174
+ if (!ALLOWED_TOP_SET.has(k))
175
+ continue;
176
+ if (isForbiddenKey(k))
177
+ continue;
178
+ if (k === 'metrics') {
179
+ out[k] = sanitizeMetrics(v);
180
+ continue;
181
+ }
182
+ out[k] = deepClean(v);
183
+ }
184
+ return out;
185
+ }
186
+ const SECRET_RE = /\b(pat_[A-Za-z0-9_-]{4,}|sk_[A-Za-z0-9_-]{4,})/;
187
+ const DIFF_RE = /^(diff --git |@@ |[+-]{3} )|(\n[+-])/;
188
+ const PATH_RE = /(^|[\s'"])\/[\w.-]+\/[\w./-]+/;
189
+ function looksLikePath(s) {
190
+ return PATH_RE.test(s);
191
+ }
192
+ function looksLikeDiff(s) {
193
+ return DIFF_RE.test(s);
194
+ }
195
+ function looksLikeSecret(s) {
196
+ return SECRET_RE.test(s);
197
+ }
198
+ export function assertNoForbiddenFields(payload, keyPath = '$') {
199
+ walk(payload, keyPath, false);
200
+ }
201
+ function walk(value, keyPath, parentIsBranch) {
202
+ if (typeof value === 'string') {
203
+ if (parentIsBranch && BRANCH_RE.test(value))
204
+ return;
205
+ if (looksLikeSecret(value)) {
206
+ throw new Error(`assertNoForbiddenFields: secret-like value at ${keyPath}`);
207
+ }
208
+ if (looksLikeDiff(value)) {
209
+ throw new Error(`assertNoForbiddenFields: diff-like value at ${keyPath}`);
210
+ }
211
+ if (looksLikePath(value)) {
212
+ throw new Error(`assertNoForbiddenFields: path-like value at ${keyPath}`);
213
+ }
214
+ return;
215
+ }
216
+ if (Array.isArray(value)) {
217
+ value.forEach((v, i) => walk(v, `${keyPath}[${i}]`, false));
218
+ return;
219
+ }
220
+ if (value && typeof value === 'object') {
221
+ for (const [k, v] of Object.entries(value)) {
222
+ if (isForbiddenKey(k)) {
223
+ throw new Error(`assertNoForbiddenFields: forbidden key '${k}' at ${keyPath}.${k}`);
224
+ }
225
+ walk(v, `${keyPath}.${k}`, k === 'branch');
226
+ }
227
+ }
228
+ }
@@ -0,0 +1,271 @@
1
+ import { spawnSync as nodeSpawnSync } from 'node:child_process';
2
+ import { assertValidBranch } from './names.js';
3
+ import { makeEvent } from './progress-events.js';
4
+ export function planMergeOrder(branches, baseBranch) {
5
+ assertValidBranch(baseBranch, 'base branch');
6
+ for (const b of branches)
7
+ assertValidBranch(b, 'branch');
8
+ const seen = new Set();
9
+ const out = [];
10
+ for (const b of branches) {
11
+ if (b === baseBranch)
12
+ continue;
13
+ if (seen.has(b))
14
+ continue;
15
+ seen.add(b);
16
+ out.push(b);
17
+ }
18
+ out.sort();
19
+ return out;
20
+ }
21
+ export function tokenizeGateCommand(cmd) {
22
+ const tokens = [];
23
+ let cur = '';
24
+ let inSingle = false;
25
+ let inDouble = false;
26
+ let started = false;
27
+ for (let i = 0; i < cmd.length; i++) {
28
+ const ch = cmd[i];
29
+ if (inSingle) {
30
+ if (ch === "'")
31
+ inSingle = false;
32
+ else
33
+ cur += ch;
34
+ continue;
35
+ }
36
+ if (inDouble) {
37
+ if (ch === '"')
38
+ inDouble = false;
39
+ else
40
+ cur += ch;
41
+ continue;
42
+ }
43
+ if (ch === "'") {
44
+ inSingle = true;
45
+ started = true;
46
+ continue;
47
+ }
48
+ if (ch === '"') {
49
+ inDouble = true;
50
+ started = true;
51
+ continue;
52
+ }
53
+ if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') {
54
+ if (cur.length > 0 || started) {
55
+ tokens.push(cur);
56
+ cur = '';
57
+ started = false;
58
+ }
59
+ continue;
60
+ }
61
+ cur += ch;
62
+ started = true;
63
+ }
64
+ if (cur.length > 0 || started)
65
+ tokens.push(cur);
66
+ return tokens;
67
+ }
68
+ function runGit(spawnImpl, repoRoot, args) {
69
+ return spawnImpl('git', ['-C', repoRoot, ...args], {
70
+ encoding: 'utf-8',
71
+ timeout: 60_000,
72
+ windowsHide: true,
73
+ });
74
+ }
75
+ function countConflictFiles(stdout) {
76
+ let n = 0;
77
+ for (const line of stdout.split('\n')) {
78
+ if (/^CONFLICT\b/.test(line.trim()))
79
+ n += 1;
80
+ }
81
+ return n;
82
+ }
83
+ export function runReconcile(args) {
84
+ const { repoRoot, baseBranch, buildTestCommand, sessionId = 'choir', spawnImpl = nodeSpawnSync, emit = () => { }, now = () => Date.now(), random, } = args;
85
+ const order = planMergeOrder(args.branches, baseBranch);
86
+ const evDeps = { now, random };
87
+ const ts = () => Math.floor(now());
88
+ const total = order.length;
89
+ const startEvent = makeEvent({
90
+ sessionId,
91
+ engine: 'choir',
92
+ paneId: null,
93
+ stage: 'reconcile-started',
94
+ summary: `Reconciling ${total} branch${total === 1 ? '' : 'es'} into ${baseBranch}`,
95
+ ts: ts(),
96
+ refs: { branch: baseBranch },
97
+ metrics: { paneTotal: total },
98
+ }, evDeps);
99
+ emit(startEvent);
100
+ const parentEventId = startEvent.eventId;
101
+ const gateTokens = buildTestCommand.trim() ? tokenizeGateCommand(buildTestCommand) : [];
102
+ const merged = [];
103
+ for (let i = 0; i < order.length; i++) {
104
+ const branch = order[i];
105
+ const r = runGit(spawnImpl, repoRoot, ['merge', '--no-ff', branch]);
106
+ const stdout = typeof r.stdout === 'string' ? r.stdout : '';
107
+ const conflictFiles = countConflictFiles(stdout);
108
+ const isConflict = r.status !== 0 && (conflictFiles > 0 || /^CONFLICT\b/m.test(stdout));
109
+ if (isConflict) {
110
+ runGit(spawnImpl, repoRoot, ['merge', '--abort']);
111
+ emitBlockedMergeStep(emit, evDeps, {
112
+ sessionId,
113
+ parentEventId,
114
+ branch,
115
+ baseBranch,
116
+ conflictFiles,
117
+ index: i + 1,
118
+ total,
119
+ ts: ts(),
120
+ });
121
+ emit(blockedEvent(evDeps, {
122
+ sessionId,
123
+ parentEventId,
124
+ branch,
125
+ baseBranch,
126
+ reason: 'merge-conflict',
127
+ conflictFiles,
128
+ merged: merged.length,
129
+ total,
130
+ ts: ts(),
131
+ }));
132
+ return { blocked: true, merged, conflictedBranch: branch, reason: 'merge-conflict' };
133
+ }
134
+ if (r.status !== 0) {
135
+ runGit(spawnImpl, repoRoot, ['merge', '--abort']);
136
+ emit(blockedEvent(evDeps, {
137
+ sessionId,
138
+ parentEventId,
139
+ branch,
140
+ baseBranch,
141
+ reason: 'merge-error',
142
+ merged: merged.length,
143
+ total,
144
+ ts: ts(),
145
+ }));
146
+ return { blocked: true, merged, erroredBranch: branch, reason: 'merge-error' };
147
+ }
148
+ emit(makeEvent({
149
+ sessionId,
150
+ engine: 'choir',
151
+ paneId: null,
152
+ parentEventId,
153
+ stage: 'merge-step',
154
+ summary: `Merged ${branch} into ${baseBranch}`,
155
+ ts: ts(),
156
+ refs: { branch },
157
+ metrics: { storyIndex: i + 1, storyTotal: total, panesComplete: merged.length + 1 },
158
+ }, evDeps));
159
+ if (gateTokens.length > 0) {
160
+ const [gateCmd, ...gateArgs] = gateTokens;
161
+ const g = spawnImpl(gateCmd, gateArgs, {
162
+ cwd: repoRoot,
163
+ encoding: 'utf-8',
164
+ timeout: 600_000,
165
+ windowsHide: true,
166
+ });
167
+ if (g.status !== 0) {
168
+ emit(gateEvent(evDeps, {
169
+ sessionId,
170
+ parentEventId,
171
+ branch,
172
+ pass: false,
173
+ exitCode: typeof g.status === 'number' ? g.status : 1,
174
+ index: i + 1,
175
+ total,
176
+ ts: ts(),
177
+ }));
178
+ emit(blockedEvent(evDeps, {
179
+ sessionId,
180
+ parentEventId,
181
+ branch,
182
+ baseBranch,
183
+ reason: 'build-failed',
184
+ merged: merged.length,
185
+ total,
186
+ ts: ts(),
187
+ }));
188
+ return { blocked: true, merged, gateFailedAfter: branch, reason: 'build-failed' };
189
+ }
190
+ emit(gateEvent(evDeps, {
191
+ sessionId,
192
+ parentEventId,
193
+ branch,
194
+ pass: true,
195
+ exitCode: 0,
196
+ index: i + 1,
197
+ total,
198
+ ts: ts(),
199
+ }));
200
+ }
201
+ merged.push(branch);
202
+ }
203
+ emit(makeEvent({
204
+ sessionId,
205
+ engine: 'choir',
206
+ paneId: null,
207
+ parentEventId,
208
+ stage: 'reconcile-complete',
209
+ summary: `Reconciled ${merged.length} branch${merged.length === 1 ? '' : 'es'} into ${baseBranch}, build green`,
210
+ ts: ts(),
211
+ refs: { branch: baseBranch },
212
+ metrics: { paneTotal: total, panesComplete: merged.length },
213
+ }, evDeps));
214
+ return { blocked: false, merged };
215
+ }
216
+ function emitBlockedMergeStep(emit, deps, o) {
217
+ const base = makeEvent({
218
+ sessionId: o.sessionId,
219
+ engine: 'choir',
220
+ paneId: null,
221
+ parentEventId: o.parentEventId,
222
+ stage: 'merge-step',
223
+ summary: `Conflict merging ${o.branch} into ${o.baseBranch} (${o.conflictFiles} file${o.conflictFiles === 1 ? '' : 's'})`,
224
+ ts: o.ts,
225
+ refs: { branch: o.branch, blockReason: 'merge-conflict' },
226
+ metrics: { filesChanged: o.conflictFiles, storyIndex: o.index, storyTotal: o.total },
227
+ }, deps);
228
+ emit({ ...base, severity: 'blocked' });
229
+ }
230
+ function gateEvent(deps, o) {
231
+ const base = makeEvent({
232
+ sessionId: o.sessionId,
233
+ engine: 'choir',
234
+ paneId: null,
235
+ parentEventId: o.parentEventId,
236
+ stage: 'build-gate',
237
+ summary: o.pass
238
+ ? `Build gate passed after ${o.branch}`
239
+ : `Build gate FAILED after ${o.branch}`,
240
+ ts: o.ts,
241
+ refs: o.pass ? { branch: o.branch } : { branch: o.branch, blockReason: 'build-failed' },
242
+ metrics: { storyIndex: o.index, storyTotal: o.total, exitCode: o.exitCode },
243
+ }, deps);
244
+ return o.pass ? base : { ...base, severity: 'blocked' };
245
+ }
246
+ function blockedEvent(deps, o) {
247
+ const blockReason = o.reason === 'merge-conflict'
248
+ ? 'merge-conflict'
249
+ : o.reason === 'build-failed'
250
+ ? 'build-failed'
251
+ : undefined;
252
+ const summary = o.reason === 'merge-conflict'
253
+ ? `BLOCKED — conflict on ${o.branch}; human merge gate required`
254
+ : o.reason === 'build-failed'
255
+ ? `BLOCKED — build gate failed after ${o.branch}`
256
+ : `BLOCKED — merge of ${o.branch} could not proceed`;
257
+ const metrics = { panesComplete: o.merged, paneTotal: o.total };
258
+ if (typeof o.conflictFiles === 'number')
259
+ metrics.filesChanged = o.conflictFiles;
260
+ return makeEvent({
261
+ sessionId: o.sessionId,
262
+ engine: 'choir',
263
+ paneId: null,
264
+ parentEventId: o.parentEventId,
265
+ stage: 'reconcile-blocked',
266
+ summary,
267
+ ts: o.ts,
268
+ refs: blockReason ? { branch: o.branch, blockReason } : { branch: o.branch },
269
+ metrics,
270
+ }, deps);
271
+ }
@@ -0,0 +1,54 @@
1
+ export const NAME_RE = /^[a-z0-9][a-z0-9-]{0,30}$/;
2
+ export const BRANCH_RE = /^[a-z0-9][a-z0-9/-]{0,60}$/;
3
+ export const PANE_ID_RE = /^%\d+$/;
4
+ export const SUMMARY_MAX = 200;
5
+ export const LOCAL_TEXT_MAX = 4096;
6
+ export const RADAR_OFFLINE = Object.freeze({ status: 'radar offline' });
7
+ export const SCHEMA_VERSION = 1;
8
+ export const EGRESS_ALLOWED_TOP_KEYS = Object.freeze([
9
+ 'schemaVersion',
10
+ 'eventId',
11
+ 'sessionId',
12
+ 'engine',
13
+ 'paneId',
14
+ 'parentEventId',
15
+ 'stage',
16
+ 'category',
17
+ 'severity',
18
+ 'terminal',
19
+ 'summary',
20
+ 'ts',
21
+ 'metrics',
22
+ 'refs',
23
+ ]);
24
+ export const EGRESS_FORBIDDEN_KEYS = Object.freeze([
25
+ 'paths',
26
+ 'path',
27
+ 'worktreePath',
28
+ 'diff',
29
+ 'patch',
30
+ 'transcript',
31
+ 'content',
32
+ 'fileContents',
33
+ 'token',
34
+ 'pat',
35
+ 'secret',
36
+ 'apiKey',
37
+ 'nonce',
38
+ 'msg',
39
+ ]);
40
+ export const HEALTH_DEFAULTS = Object.freeze({
41
+ SLOW: 90_000,
42
+ STALL: 8 * 60_000,
43
+ AWAIT_CONFIRM: 20_000,
44
+ LOOP_WINDOW: 5 * 60_000,
45
+ POLL: 5_000,
46
+ CRASH_GRACE: 10_000,
47
+ });
48
+ export const SEVERITY_WEIGHT = Object.freeze({
49
+ info: 0,
50
+ progress: 1,
51
+ 'attention-needed': 2,
52
+ blocked: 3,
53
+ error: 4,
54
+ });
@@ -0,0 +1,128 @@
1
+ import { spawnSync as nodeSpawnSync } from 'node:child_process';
2
+ import nodeFs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { worktreePathFor, branchFor, assertValidBranch } from './names.js';
5
+ const defaultFs = {
6
+ existsSync: nodeFs.existsSync,
7
+ readFileSync: nodeFs.readFileSync,
8
+ writeFileSync: nodeFs.writeFileSync,
9
+ };
10
+ function runGit(spawnImpl, cwd, args) {
11
+ return spawnImpl('git', ['-C', cwd, ...args], {
12
+ encoding: 'utf-8',
13
+ timeout: 15_000,
14
+ windowsHide: true,
15
+ });
16
+ }
17
+ function assertOk(r, op) {
18
+ if (r.status !== 0) {
19
+ throw new Error(`git ${op} failed`);
20
+ }
21
+ }
22
+ export function addWorktree(args) {
23
+ const { repoRoot, sessionName, paneName, baseBranch, spawnImpl = nodeSpawnSync } = args;
24
+ const worktreePath = worktreePathFor(repoRoot, sessionName, paneName);
25
+ const branch = branchFor(sessionName, paneName);
26
+ assertValidBranch(baseBranch, 'base branch');
27
+ const r = runGit(spawnImpl, repoRoot, [
28
+ 'worktree',
29
+ 'add',
30
+ worktreePath,
31
+ '-b',
32
+ branch,
33
+ baseBranch,
34
+ ]);
35
+ assertOk(r, 'worktree add');
36
+ return { worktreePath, branch };
37
+ }
38
+ export function listWorktrees(repoRoot, spawnImpl = nodeSpawnSync) {
39
+ const r = runGit(spawnImpl, repoRoot, ['worktree', 'list', '--porcelain']);
40
+ assertOk(r, 'worktree list');
41
+ const out = typeof r.stdout === 'string' ? r.stdout : '';
42
+ const entries = [];
43
+ let cur = null;
44
+ const flush = () => {
45
+ if (cur)
46
+ entries.push(cur);
47
+ cur = null;
48
+ };
49
+ for (const raw of out.split('\n')) {
50
+ const line = raw.replace(/\r$/, '');
51
+ if (line === '') {
52
+ flush();
53
+ continue;
54
+ }
55
+ const sp = line.indexOf(' ');
56
+ const key = sp === -1 ? line : line.slice(0, sp);
57
+ const val = sp === -1 ? '' : line.slice(sp + 1);
58
+ switch (key) {
59
+ case 'worktree':
60
+ flush();
61
+ cur = { worktree: val, detached: false, bare: false };
62
+ break;
63
+ case 'HEAD':
64
+ if (cur)
65
+ cur.head = val;
66
+ break;
67
+ case 'branch':
68
+ if (cur)
69
+ cur.branch = val.replace(/^refs\/heads\//, '');
70
+ break;
71
+ case 'detached':
72
+ if (cur)
73
+ cur.detached = true;
74
+ break;
75
+ case 'bare':
76
+ if (cur)
77
+ cur.bare = true;
78
+ break;
79
+ default:
80
+ break;
81
+ }
82
+ }
83
+ flush();
84
+ return entries;
85
+ }
86
+ export function removeWorktree(args) {
87
+ const { repoRoot, worktreePath, spawnImpl = nodeSpawnSync } = args;
88
+ const r = runGit(spawnImpl, repoRoot, ['worktree', 'remove', worktreePath]);
89
+ assertOk(r, 'worktree remove');
90
+ }
91
+ export function ensureChoirGitignored(repoRoot, fsImpl = defaultFs) {
92
+ const giPath = path.join(repoRoot, '.gitignore');
93
+ const ENTRY = '.choir/';
94
+ let existing = '';
95
+ if (fsImpl.existsSync(giPath)) {
96
+ existing = fsImpl.readFileSync(giPath, 'utf-8');
97
+ }
98
+ const lines = existing.split('\n').map((l) => l.replace(/\r$/, ''));
99
+ if (lines.includes(ENTRY))
100
+ return;
101
+ const needsNewline = existing.length > 0 && !existing.endsWith('\n');
102
+ const next = existing + (needsNewline ? '\n' : '') + ENTRY + '\n';
103
+ fsImpl.writeFileSync(giPath, next);
104
+ }
105
+ export function pollGitStatus(worktreePath, spawnImpl = nodeSpawnSync, base = 'HEAD') {
106
+ assertValidBranch(base, 'base ref');
107
+ const st = runGit(spawnImpl, worktreePath, ['status', '--porcelain']);
108
+ assertOk(st, 'status');
109
+ const stdout = typeof st.stdout === 'string' ? st.stdout : '';
110
+ const dirtyPaths = [];
111
+ for (const raw of stdout.split('\n')) {
112
+ const line = raw.replace(/\r$/, '');
113
+ if (line.trim() === '')
114
+ continue;
115
+ let p = line.slice(3);
116
+ const arrow = p.indexOf(' -> ');
117
+ if (arrow !== -1)
118
+ p = p.slice(arrow + 4);
119
+ p = p.replace(/^"(.*)"$/, '$1');
120
+ if (p.length > 0)
121
+ dirtyPaths.push(p);
122
+ }
123
+ const rev = runGit(spawnImpl, worktreePath, ['rev-list', '--count', `${base}..HEAD`]);
124
+ assertOk(rev, 'rev-list');
125
+ const revOut = typeof rev.stdout === 'string' ? rev.stdout.trim() : '';
126
+ const commitsAhead = Number.parseInt(revOut, 10) || 0;
127
+ return { dirtyPaths, dirtyFiles: dirtyPaths.length, commitsAhead };
128
+ }
@@ -60,7 +60,8 @@ export async function checkCompletions(opts) {
60
60
  const ok = await postCompletion(baseUrl, pat, id, opts.fetchImpl);
61
61
  if (ok) {
62
62
  reported.push(id);
63
- opts.log?.(`✓ ${id} · run completed (pane closed)\n`);
63
+ const card = opts.renderComplete?.(id);
64
+ opts.log?.(card && card.length > 0 ? card : `✓ ${id} · run completed (pane closed)\n`);
64
65
  }
65
66
  }
66
67
  return reported;