brl-subagent 2.3.4
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/LICENSE +21 -0
- package/README.md +257 -0
- package/package.json +49 -0
- package/presets/code-reviewer.md +37 -0
- package/presets/data-analyst.md +33 -0
- package/presets/debugger.md +36 -0
- package/presets/dev-agent.md +30 -0
- package/presets/rapid-prototyper.md +25 -0
- package/presets/refactorer.md +34 -0
- package/presets/security-auditor.md +39 -0
- package/presets/tech-writer.md +33 -0
- package/presets/test-engineer.md +34 -0
- package/src/concurrency.ts +186 -0
- package/src/diff.ts +102 -0
- package/src/event-bus.ts +86 -0
- package/src/git.ts +187 -0
- package/src/history.ts +177 -0
- package/src/index.ts +3824 -0
- package/src/logging.ts +135 -0
- package/src/messaging.ts +164 -0
- package/src/metrics.ts +221 -0
- package/src/model-availability.ts +73 -0
- package/src/notify-completion.ts +207 -0
- package/src/params.ts +225 -0
- package/src/preflight.ts +150 -0
- package/src/presets.ts +425 -0
- package/src/prompt.ts +169 -0
- package/src/reports.ts +325 -0
- package/src/router.ts +133 -0
- package/src/runner.ts +877 -0
- package/src/sanitize.ts +365 -0
- package/src/scheduler.ts +187 -0
- package/src/session-manager.ts +1065 -0
- package/src/state.ts +590 -0
- package/src/templates.ts +469 -0
- package/src/transcript-tail.ts +327 -0
- package/src/transcript.ts +119 -0
- package/src/tui-format.ts +51 -0
- package/src/tui.ts +3103 -0
- package/src/types.ts +803 -0
- package/src/unit-run.ts +269 -0
- package/src/update.ts +109 -0
- package/src/validate.ts +302 -0
- package/templates/analyze-data.md +9 -0
- package/templates/code-review.md +10 -0
- package/templates/debug-issue.md +9 -0
- package/templates/implement-feature.md +9 -0
- package/templates/prototype.md +9 -0
- package/templates/refactor.md +9 -0
- package/templates/security-audit.md +10 -0
- package/templates/write-docs.md +9 -0
- package/templates/write-tests.md +10 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* brl-subagent — Concurrency Controller (F8)
|
|
3
|
+
*
|
|
4
|
+
* Manages the concurrency queue and progress tracking for subagent execution.
|
|
5
|
+
* All state is contained in the SessionState instance — no module-level mutable variables.
|
|
6
|
+
*
|
|
7
|
+
* F8: Race condition fixes — progress counters are updated atomically within
|
|
8
|
+
* acquireSlot/releaseSlot, eliminating races between parallel completions.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import type { Priority } from "./types";
|
|
13
|
+
import { PRIORITY_ORDER } from "./types";
|
|
14
|
+
import type { SessionState } from "./state";
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Slot management
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Acquire a concurrency slot. If maxParallel is reached, the caller is queued.
|
|
22
|
+
* Returns true if a slot was acquired, false if cancelled while waiting.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* Insert an entry into a priority queue. The entry is placed before the first
|
|
26
|
+
* item with lower priority (higher numeric value). Equal-priority items maintain
|
|
27
|
+
* FIFO order (stack behind existing same-priority items).
|
|
28
|
+
*/
|
|
29
|
+
export function priorityInsert<T extends { priority: Priority }>(
|
|
30
|
+
queue: T[],
|
|
31
|
+
entry: T,
|
|
32
|
+
): void {
|
|
33
|
+
const newOrder = PRIORITY_ORDER[entry.priority];
|
|
34
|
+
const idx = queue.findIndex(
|
|
35
|
+
(existing) => PRIORITY_ORDER[existing.priority] > newOrder,
|
|
36
|
+
);
|
|
37
|
+
if (idx >= 0) {
|
|
38
|
+
queue.splice(idx, 0, entry);
|
|
39
|
+
} else {
|
|
40
|
+
queue.push(entry);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function acquireSlot(
|
|
45
|
+
state: SessionState,
|
|
46
|
+
ctx: ExtensionContext,
|
|
47
|
+
signal?: AbortSignal,
|
|
48
|
+
priority?: Priority,
|
|
49
|
+
): Promise<boolean> {
|
|
50
|
+
const maxParallel = state.config.maxParallel;
|
|
51
|
+
|
|
52
|
+
if (maxParallel === 0 || state.activeSubagents < maxParallel) {
|
|
53
|
+
state.activeSubagents++;
|
|
54
|
+
updateProgressStatus(state, ctx);
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return new Promise((resolve) => {
|
|
59
|
+
const entry = {
|
|
60
|
+
run: () => {
|
|
61
|
+
state.activeSubagents++;
|
|
62
|
+
updateProgressStatus(state, ctx);
|
|
63
|
+
resolve(true);
|
|
64
|
+
},
|
|
65
|
+
signal,
|
|
66
|
+
ctx,
|
|
67
|
+
priority: priority ?? "normal",
|
|
68
|
+
};
|
|
69
|
+
priorityInsert(state.pendingQueue, entry);
|
|
70
|
+
if (signal) {
|
|
71
|
+
signal.addEventListener(
|
|
72
|
+
"abort",
|
|
73
|
+
() => {
|
|
74
|
+
const idx = state.pendingQueue.indexOf(entry);
|
|
75
|
+
if (idx >= 0) {
|
|
76
|
+
state.pendingQueue.splice(idx, 1);
|
|
77
|
+
resolve(false);
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
{ once: true },
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Release a concurrency slot and update progress counters.
|
|
88
|
+
* This is the single point where counters are mutated — no races.
|
|
89
|
+
*/
|
|
90
|
+
export function releaseSlot(state: SessionState, success: boolean, ctx: ExtensionContext): void {
|
|
91
|
+
// Atomically update all counters
|
|
92
|
+
state.activeSubagents--;
|
|
93
|
+
|
|
94
|
+
if (state.activeSubagents < 0) {
|
|
95
|
+
state.activeSubagents = 0; // Defensive: should never happen
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (success) {
|
|
99
|
+
state.completedSubagents++;
|
|
100
|
+
state.unseenSubagents++;
|
|
101
|
+
} else {
|
|
102
|
+
state.failedSubagents++;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
updateProgressStatus(state, ctx);
|
|
106
|
+
|
|
107
|
+
// Dequeue next waiting task
|
|
108
|
+
const next = state.pendingQueue.shift();
|
|
109
|
+
if (next && !next.signal?.aborted) {
|
|
110
|
+
next.run();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// Progress status display
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
const STATUS_RESET_DELAY_MS = 3000;
|
|
119
|
+
|
|
120
|
+
export function updateProgressStatus(state: SessionState, ctx: ExtensionContext): void {
|
|
121
|
+
const total = state.activeSubagents + state.completedSubagents + state.failedSubagents;
|
|
122
|
+
|
|
123
|
+
if (total === 0) {
|
|
124
|
+
updateStatus(state, ctx);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const parts: string[] = [];
|
|
129
|
+
// Issue #133: prepend the multi-unit mode breakdown when a graph/chain
|
|
130
|
+
// dispatch is in flight — e.g. "brl: graph wave 1/2 · node 2/3, 2 running".
|
|
131
|
+
// The mode's finally clears modeContext, so the plain shape returns once
|
|
132
|
+
// no mode is active.
|
|
133
|
+
if (state.modeContext) {
|
|
134
|
+
const mc = state.modeContext;
|
|
135
|
+
parts.push(
|
|
136
|
+
mc.kind === "graph"
|
|
137
|
+
? `graph wave ${mc.wave}/${mc.totalWaves} · node ${mc.node}/${mc.totalNodes}`
|
|
138
|
+
: `chain step ${mc.step}/${mc.totalSteps}`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
if (state.activeSubagents > 0) parts.push(`${state.activeSubagents} running`);
|
|
142
|
+
if (state.completedSubagents > 0) {
|
|
143
|
+
if (state.unseenSubagents > 0) {
|
|
144
|
+
parts.push(`${state.completedSubagents} done (${state.unseenSubagents} unseen)`);
|
|
145
|
+
} else {
|
|
146
|
+
parts.push(`${state.completedSubagents} done`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (state.failedSubagents > 0) parts.push(`${state.failedSubagents} failed`);
|
|
150
|
+
|
|
151
|
+
const statusText = `brl: ${parts.join(", ")}`;
|
|
152
|
+
ctx.ui.setStatus("brl-subagent", ctx.ui.theme.fg("accent", statusText));
|
|
153
|
+
|
|
154
|
+
// When all subagents finish, reset counters after a brief delay
|
|
155
|
+
if (state.activeSubagents === 0 && total > 0) {
|
|
156
|
+
const snapshotTotal = total;
|
|
157
|
+
setTimeout(() => {
|
|
158
|
+
if (
|
|
159
|
+
state.activeSubagents === 0 &&
|
|
160
|
+
state.completedSubagents + state.failedSubagents === snapshotTotal
|
|
161
|
+
) {
|
|
162
|
+
state.completedSubagents = 0;
|
|
163
|
+
state.failedSubagents = 0;
|
|
164
|
+
state.unseenSubagents = 0;
|
|
165
|
+
updateStatus(state, ctx);
|
|
166
|
+
}
|
|
167
|
+
}, STATUS_RESET_DELAY_MS);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function updateStatus(state: SessionState, ctx: ExtensionContext): void {
|
|
172
|
+
if (state.config.model) {
|
|
173
|
+
ctx.ui.setStatus(
|
|
174
|
+
"brl-subagent",
|
|
175
|
+
ctx.ui.theme.fg(
|
|
176
|
+
"accent",
|
|
177
|
+
`brl:${state.config.model.id} [max think:${state.config.maxThinkingLevel}]`,
|
|
178
|
+
),
|
|
179
|
+
);
|
|
180
|
+
} else {
|
|
181
|
+
ctx.ui.setStatus(
|
|
182
|
+
"brl-subagent",
|
|
183
|
+
ctx.ui.theme.fg("muted", "brl: (use /brl-subagent to configure)"),
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
}
|
package/src/diff.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* brl-subagent — Git Diff Parser (P5)
|
|
3
|
+
*
|
|
4
|
+
* Parses standard git unified diff output into structured file-level summaries.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { MAX_HUNKS_PER_FILE, type FileDiff } from "./types";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Parse standard git unified diff output.
|
|
11
|
+
*
|
|
12
|
+
* For each file in the diff:
|
|
13
|
+
* 1. Extract file path from the "diff --git" line
|
|
14
|
+
* 2. Count lines starting with - (deletions) and + (additions)
|
|
15
|
+
* 3. Collect hunk strings: each hunk is the full text from @@ to the next @@ or next file
|
|
16
|
+
*
|
|
17
|
+
* Limit: caps hunks per file to MAX_HUNKS_PER_FILE (10). totalHunks stores the real count.
|
|
18
|
+
*
|
|
19
|
+
* Returns an array sorted by path name (alphabetical, stable).
|
|
20
|
+
* Returns empty array for empty or whitespace-only input.
|
|
21
|
+
*/
|
|
22
|
+
export function parseDiff(rawDiff: string): FileDiff[] {
|
|
23
|
+
if (!rawDiff || !rawDiff.trim()) return [];
|
|
24
|
+
|
|
25
|
+
const lines = rawDiff.split("\n");
|
|
26
|
+
const files: FileDiff[] = [];
|
|
27
|
+
|
|
28
|
+
let currentPath = "";
|
|
29
|
+
let currentHunks: string[] = [];
|
|
30
|
+
let currentAdditions = 0;
|
|
31
|
+
let currentDeletions = 0;
|
|
32
|
+
let hunkLines: string[] | null = null; // null = not inside a hunk
|
|
33
|
+
|
|
34
|
+
function saveCurrentFile(): void {
|
|
35
|
+
if (!currentPath) return;
|
|
36
|
+
if (hunkLines && hunkLines.length > 0) {
|
|
37
|
+
currentHunks.push(hunkLines.join("\n"));
|
|
38
|
+
}
|
|
39
|
+
files.push({
|
|
40
|
+
path: currentPath,
|
|
41
|
+
additions: currentAdditions,
|
|
42
|
+
deletions: currentDeletions,
|
|
43
|
+
hunks: currentHunks.slice(0, MAX_HUNKS_PER_FILE),
|
|
44
|
+
totalHunks: currentHunks.length,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function resetFileState(): void {
|
|
49
|
+
currentPath = "";
|
|
50
|
+
currentHunks = [];
|
|
51
|
+
currentAdditions = 0;
|
|
52
|
+
currentDeletions = 0;
|
|
53
|
+
hunkLines = null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
for (const line of lines) {
|
|
57
|
+
// New file starts
|
|
58
|
+
if (line.startsWith("diff --git ")) {
|
|
59
|
+
saveCurrentFile();
|
|
60
|
+
resetFileState();
|
|
61
|
+
const m = line.match(/^diff --git a\/(.+) b\/(.+)$/);
|
|
62
|
+
currentPath = m ? m[2] : "unknown";
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Hunk header
|
|
67
|
+
if (line.startsWith("@@")) {
|
|
68
|
+
if (hunkLines && hunkLines.length > 0) {
|
|
69
|
+
currentHunks.push(hunkLines.join("\n"));
|
|
70
|
+
}
|
|
71
|
+
hunkLines = [line];
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Track additions (skip +++ header)
|
|
76
|
+
if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
77
|
+
currentAdditions++;
|
|
78
|
+
if (hunkLines !== null) hunkLines.push(line);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Track deletions (skip --- header)
|
|
83
|
+
if (line.startsWith("-") && !line.startsWith("---")) {
|
|
84
|
+
currentDeletions++;
|
|
85
|
+
if (hunkLines !== null) hunkLines.push(line);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Context line inside a hunk
|
|
90
|
+
if (hunkLines !== null) {
|
|
91
|
+
hunkLines.push(line);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Last file
|
|
96
|
+
saveCurrentFile();
|
|
97
|
+
|
|
98
|
+
// Sort by path (alphabetical, stable — plain localeCompare suffices for git paths)
|
|
99
|
+
files.sort((a, b) => a.path.localeCompare(b.path));
|
|
100
|
+
|
|
101
|
+
return files;
|
|
102
|
+
}
|
package/src/event-bus.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { SubagentEvent, SubagentEventType, SubagentEventListener } from './types';
|
|
2
|
+
|
|
3
|
+
// Event listeners by type
|
|
4
|
+
const listeners = new Map<SubagentEventType, Set<SubagentEventListener>>();
|
|
5
|
+
|
|
6
|
+
// Global listeners (receive all events)
|
|
7
|
+
const globalListeners = new Set<SubagentEventListener>();
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Subscribe a listener to a specific event type.
|
|
11
|
+
*
|
|
12
|
+
* @param type - The event type to subscribe to
|
|
13
|
+
* @param listener - The listener invoked for each event of this type
|
|
14
|
+
*/
|
|
15
|
+
export function on(type: SubagentEventType, listener: SubagentEventListener): void {
|
|
16
|
+
let typeListeners = listeners.get(type);
|
|
17
|
+
if (!typeListeners) {
|
|
18
|
+
typeListeners = new Set();
|
|
19
|
+
listeners.set(type, typeListeners);
|
|
20
|
+
}
|
|
21
|
+
typeListeners.add(listener);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Unsubscribe a listener from a specific event type.
|
|
26
|
+
*
|
|
27
|
+
* @param type - The event type to unsubscribe from
|
|
28
|
+
* @param listener - The listener to remove
|
|
29
|
+
*/
|
|
30
|
+
export function off(type: SubagentEventType, listener: SubagentEventListener): void {
|
|
31
|
+
const typeListeners = listeners.get(type);
|
|
32
|
+
if (!typeListeners) return;
|
|
33
|
+
typeListeners.delete(listener);
|
|
34
|
+
if (typeListeners.size === 0) listeners.delete(type);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Emit an event
|
|
39
|
+
*
|
|
40
|
+
* @param event - The event to emit
|
|
41
|
+
*/
|
|
42
|
+
export function emit(event: SubagentEvent): void {
|
|
43
|
+
// Notify type-specific listeners
|
|
44
|
+
const typeListeners = listeners.get(event.type);
|
|
45
|
+
if (typeListeners) {
|
|
46
|
+
for (const listener of typeListeners) {
|
|
47
|
+
try {
|
|
48
|
+
listener(event);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
// Don't let listener errors break the event loop
|
|
51
|
+
console.error(`[event-bus] Listener error for ${event.type}:`, err);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Notify global listeners
|
|
57
|
+
for (const listener of globalListeners) {
|
|
58
|
+
try {
|
|
59
|
+
listener(event);
|
|
60
|
+
} catch (err) {
|
|
61
|
+
console.error(`[event-bus] Global listener error:`, err);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Create a SubagentEvent helper
|
|
69
|
+
*
|
|
70
|
+
* @param type - The event type
|
|
71
|
+
* @param agentId - The agent ID
|
|
72
|
+
* @param data - Additional event data
|
|
73
|
+
* @returns SubagentEvent object
|
|
74
|
+
*/
|
|
75
|
+
export function createEvent(
|
|
76
|
+
type: SubagentEventType,
|
|
77
|
+
agentId: string,
|
|
78
|
+
data: Record<string, unknown> = {}
|
|
79
|
+
): SubagentEvent {
|
|
80
|
+
return {
|
|
81
|
+
type,
|
|
82
|
+
agentId,
|
|
83
|
+
timestamp: Date.now(),
|
|
84
|
+
data,
|
|
85
|
+
};
|
|
86
|
+
}
|
package/src/git.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* brl-subagent — Git Integration (P3)
|
|
3
|
+
*
|
|
4
|
+
* Branch-based git workflow for subagent isolation.
|
|
5
|
+
* All git commands use execFileSync with a 10-second timeout.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { execFileSync } from "node:child_process";
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Options for git commands
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
function gitOpts(cwd: string) {
|
|
15
|
+
return {
|
|
16
|
+
cwd,
|
|
17
|
+
encoding: "utf-8" as const,
|
|
18
|
+
timeout: 10_000,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Public API
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Get the current branch name.
|
|
28
|
+
* Throws if the cwd is not a git repository.
|
|
29
|
+
*/
|
|
30
|
+
export function getCurrentBranch(cwd: string): string {
|
|
31
|
+
const output = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], gitOpts(cwd));
|
|
32
|
+
return output.trim();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Check if there are uncommitted changes in the working tree.
|
|
37
|
+
*/
|
|
38
|
+
export function hasUncommittedChanges(cwd: string): boolean {
|
|
39
|
+
const output = execFileSync("git", ["status", "--porcelain"], gitOpts(cwd));
|
|
40
|
+
return output.trim().length > 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Create a work branch from the given base branch.
|
|
45
|
+
* Returns { ok: true, branch } on success, { ok: false, error } on failure.
|
|
46
|
+
*/
|
|
47
|
+
export function createWorkBranch(
|
|
48
|
+
cwd: string,
|
|
49
|
+
baseBranch: string,
|
|
50
|
+
): { ok: true; branch: string } | { ok: false; error: string } {
|
|
51
|
+
try {
|
|
52
|
+
const branch = "brl-subagent-" + crypto.randomUUID().slice(0, 8);
|
|
53
|
+
execFileSync("git", ["checkout", "-b", branch, baseBranch], gitOpts(cwd));
|
|
54
|
+
return { ok: true, branch };
|
|
55
|
+
} catch (err) {
|
|
56
|
+
return { ok: false, error: (err as Error).message };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Capture the unified diff between baseBranch and HEAD.
|
|
62
|
+
* Returns { ok: true, diff } on success, { ok: false, error } on failure.
|
|
63
|
+
*/
|
|
64
|
+
export function captureDiff(
|
|
65
|
+
cwd: string,
|
|
66
|
+
baseBranch: string,
|
|
67
|
+
): { ok: true; diff: string } | { ok: false; error: string } {
|
|
68
|
+
try {
|
|
69
|
+
const output = execFileSync("git", ["diff", `${baseBranch}...HEAD`], gitOpts(cwd));
|
|
70
|
+
return { ok: true, diff: output };
|
|
71
|
+
} catch (err) {
|
|
72
|
+
return { ok: false, error: (err as Error).message };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Switch to an existing branch.
|
|
78
|
+
* Returns { ok: true } on success, { ok: false, error } on failure.
|
|
79
|
+
*/
|
|
80
|
+
export function switchToBranch(
|
|
81
|
+
cwd: string,
|
|
82
|
+
branch: string,
|
|
83
|
+
): { ok: true } | { ok: false; error: string } {
|
|
84
|
+
try {
|
|
85
|
+
execFileSync("git", ["checkout", branch], gitOpts(cwd));
|
|
86
|
+
return { ok: true };
|
|
87
|
+
} catch (err) {
|
|
88
|
+
return { ok: false, error: (err as Error).message };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Merge a work branch into the current branch.
|
|
94
|
+
* Uses --no-edit to prevent opening an editor.
|
|
95
|
+
* Returns { ok: true } on success, { ok: false, error } on failure.
|
|
96
|
+
*/
|
|
97
|
+
export function mergeWorkBranch(
|
|
98
|
+
cwd: string,
|
|
99
|
+
branch: string,
|
|
100
|
+
): { ok: true } | { ok: false; error: string } {
|
|
101
|
+
try {
|
|
102
|
+
execFileSync("git", ["merge", branch, "--no-edit"], gitOpts(cwd));
|
|
103
|
+
return { ok: true };
|
|
104
|
+
} catch (err) {
|
|
105
|
+
return { ok: false, error: (err as Error).message };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Delete a branch.
|
|
111
|
+
* Returns { ok: true } on success, { ok: false, error } on failure.
|
|
112
|
+
*/
|
|
113
|
+
export function deleteBranch(
|
|
114
|
+
cwd: string,
|
|
115
|
+
branch: string,
|
|
116
|
+
): { ok: true } | { ok: false; error: string } {
|
|
117
|
+
try {
|
|
118
|
+
execFileSync("git", ["branch", "-D", branch], gitOpts(cwd));
|
|
119
|
+
return { ok: true };
|
|
120
|
+
} catch (err) {
|
|
121
|
+
return { ok: false, error: (err as Error).message };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Stage and commit ALL changes (tracked + untracked) on the current branch.
|
|
127
|
+
* Used by background gitMode=branch teardown: the agent's work is committed to
|
|
128
|
+
* the work branch BEFORE the diff is captured and the branch is discarded —
|
|
129
|
+
* `git diff base...HEAD` only sees committed changes, and switching back with
|
|
130
|
+
* uncommitted edits would carry them into the base working tree (C1).
|
|
131
|
+
* Returns { ok: true, sha } on success, { ok: false, error } on failure
|
|
132
|
+
* (e.g. no git identity configured).
|
|
133
|
+
*/
|
|
134
|
+
export function commitAll(
|
|
135
|
+
cwd: string,
|
|
136
|
+
message: string,
|
|
137
|
+
): { ok: true; sha: string } | { ok: false; error: string } {
|
|
138
|
+
try {
|
|
139
|
+
execFileSync("git", ["add", "-A"], gitOpts(cwd));
|
|
140
|
+
execFileSync("git", ["commit", "-m", message], gitOpts(cwd));
|
|
141
|
+
const sha = execFileSync("git", ["rev-parse", "HEAD"], gitOpts(cwd)).trim();
|
|
142
|
+
return { ok: true, sha };
|
|
143
|
+
} catch (err) {
|
|
144
|
+
return { ok: false, error: (err as Error).message };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Capture the WORKING-TREE diff (unstaged + staged + untracked, i.e.
|
|
150
|
+
* everything `git diff base...HEAD` misses when the agent never committed).
|
|
151
|
+
* Untracked files are surfaced via intent-to-add (`git add -N`) WITHOUT
|
|
152
|
+
* staging their content, and the index is restored to its original state —
|
|
153
|
+
* the intent-to-add marks are removed with a path-limited reset that leaves
|
|
154
|
+
* genuinely staged entries untouched. Returns the merged patch text, or
|
|
155
|
+
* undefined when the tree is clean.
|
|
156
|
+
*/
|
|
157
|
+
export function captureWorkingDiff(cwd: string): string | undefined {
|
|
158
|
+
let untracked: string[] = [];
|
|
159
|
+
try {
|
|
160
|
+
// Identify untracked files FIRST — only these need intent-to-add marks.
|
|
161
|
+
untracked = execFileSync("git", ["ls-files", "--others", "--exclude-standard"], gitOpts(cwd))
|
|
162
|
+
.toString().split("\n").filter(Boolean);
|
|
163
|
+
} catch {
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
// Capture staged + unstaged (tracked) first — no index mutation yet.
|
|
168
|
+
const unstaged = execFileSync("git", ["diff"], gitOpts(cwd)).toString();
|
|
169
|
+
const staged = execFileSync("git", ["diff", "--cached"], gitOpts(cwd)).toString();
|
|
170
|
+
let untrackedDiff = "";
|
|
171
|
+
if (untracked.length > 0) {
|
|
172
|
+
// Surface untracked files via intent-to-add, WITHOUT staging content.
|
|
173
|
+
execFileSync("git", ["add", "-N", "--", ...untracked], gitOpts(cwd));
|
|
174
|
+
untrackedDiff = execFileSync("git", ["diff"], gitOpts(cwd)).toString();
|
|
175
|
+
// Remove ONLY the intent-to-add marks — real staged entries are preserved.
|
|
176
|
+
execFileSync("git", ["reset", "-q", "--", ...untracked], gitOpts(cwd));
|
|
177
|
+
}
|
|
178
|
+
const merged = `${unstaged}${untrackedDiff}${staged}`.trim();
|
|
179
|
+
return merged.length > 0 ? merged : undefined;
|
|
180
|
+
} catch {
|
|
181
|
+
try {
|
|
182
|
+
// Best-effort index restore even if a diff command failed mid-way.
|
|
183
|
+
if (untracked.length > 0) execFileSync("git", ["reset", "-q", "--", ...untracked], gitOpts(cwd));
|
|
184
|
+
} catch { /* ignore */ }
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
}
|