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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +257 -0
  3. package/package.json +49 -0
  4. package/presets/code-reviewer.md +37 -0
  5. package/presets/data-analyst.md +33 -0
  6. package/presets/debugger.md +36 -0
  7. package/presets/dev-agent.md +30 -0
  8. package/presets/rapid-prototyper.md +25 -0
  9. package/presets/refactorer.md +34 -0
  10. package/presets/security-auditor.md +39 -0
  11. package/presets/tech-writer.md +33 -0
  12. package/presets/test-engineer.md +34 -0
  13. package/src/concurrency.ts +186 -0
  14. package/src/diff.ts +102 -0
  15. package/src/event-bus.ts +86 -0
  16. package/src/git.ts +187 -0
  17. package/src/history.ts +177 -0
  18. package/src/index.ts +3824 -0
  19. package/src/logging.ts +135 -0
  20. package/src/messaging.ts +164 -0
  21. package/src/metrics.ts +221 -0
  22. package/src/model-availability.ts +73 -0
  23. package/src/notify-completion.ts +207 -0
  24. package/src/params.ts +225 -0
  25. package/src/preflight.ts +150 -0
  26. package/src/presets.ts +425 -0
  27. package/src/prompt.ts +169 -0
  28. package/src/reports.ts +325 -0
  29. package/src/router.ts +133 -0
  30. package/src/runner.ts +877 -0
  31. package/src/sanitize.ts +365 -0
  32. package/src/scheduler.ts +187 -0
  33. package/src/session-manager.ts +1065 -0
  34. package/src/state.ts +590 -0
  35. package/src/templates.ts +469 -0
  36. package/src/transcript-tail.ts +327 -0
  37. package/src/transcript.ts +119 -0
  38. package/src/tui-format.ts +51 -0
  39. package/src/tui.ts +3103 -0
  40. package/src/types.ts +803 -0
  41. package/src/unit-run.ts +269 -0
  42. package/src/update.ts +109 -0
  43. package/src/validate.ts +302 -0
  44. package/templates/analyze-data.md +9 -0
  45. package/templates/code-review.md +10 -0
  46. package/templates/debug-issue.md +9 -0
  47. package/templates/implement-feature.md +9 -0
  48. package/templates/prototype.md +9 -0
  49. package/templates/refactor.md +9 -0
  50. package/templates/security-audit.md +10 -0
  51. package/templates/write-docs.md +9 -0
  52. 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
+ }
@@ -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
+ }