@senomas/pi-git-hat 0.2.5 → 0.2.6

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/git-hat.ts CHANGED
@@ -29,16 +29,25 @@
29
29
  * Post-switch log can be disabled by setting `"postSwitchLog": false` in roles.json
30
30
  *
31
31
  * Inspired by "wearing a different hat" -- architect hat vs builder hat.
32
+ *
33
+ * -- Library modules (lib/) --
34
+ *
35
+ * Types: lib/types.ts
36
+ * Config: lib/config.ts (loadConfig, detectRole)
37
+ * Branch hist: lib/branch-history.ts (loadBranchHistory, recordBranchUsage, listBranchesByRole)
38
+ * Path utils: lib/paths.ts (normalisePath, stripCdPrefix, isInside, isWritablePath)
39
+ * Role files: lib/role-file.ts (loadRoleFile, BUILTIN_INSTRUCTIONS, EXTENSION_DIR)
40
+ * Todo utils: lib/todo-utils.ts (extractNN, findMaxNN, verifyAncestry, isMasterOrMainAncestor, handleTodo)
41
+ * Git UI: lib/git-ui.ts (roleIcon, showGitLog)
42
+ * Enforcement: lib/tool-enforcement.ts (testRegex, evaluateToolRules, ToolRule, RegexObj)
43
+ * TUI: lib/searchable-select-list.ts (SearchableSelectList)
32
44
  */
33
45
 
34
46
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
35
47
  import { DynamicBorder } from "@earendil-works/pi-coding-agent";
36
- import { readFile, readdir, writeFile, mkdir, copyFile } from "node:fs/promises";
37
- import { existsSync, readFileSync } from "node:fs";
38
- import { execFileSync } from "node:child_process";
39
- import { resolve, dirname } from "node:path";
40
- import { fileURLToPath } from "node:url";
41
- import os from "node:os";
48
+ import { readFile, readdir, mkdir, copyFile } from "node:fs/promises";
49
+ import { existsSync } from "node:fs";
50
+ import { resolve } from "node:path";
42
51
  import {
43
52
  Container,
44
53
  type SelectItem,
@@ -46,918 +55,18 @@ import {
46
55
  } from "@earendil-works/pi-tui";
47
56
  import { SearchableSelectList } from "./lib/searchable-select-list.js";
48
57
  import {
49
- type RegexObj,
50
- type ToolRule,
51
58
  testRegex,
52
59
  evaluateToolRules,
53
60
  } from "./lib/tool-enforcement.js";
54
61
 
55
- // -- Types ---------------------------------------------------------
56
-
57
- interface WritablePathEntry {
58
- /** Directory or file path (relative to project root). Empty string = project root. */
59
- path: string;
60
- /** Optional file extension filter, e.g. "md" — if set, only files with this extension are writable. */
61
- extension?: string;
62
- }
63
-
64
- interface RoleDef {
65
- pattern: string;
66
- description?: string;
67
- ancestorRoles?: string[];
68
- tool?: ToolRule[];
69
- /** Tool rules evaluated after default post-tool rules (snake_case key "post-tool" in JSON). */
70
- postTool?: ToolRule[];
71
- writablePaths?: WritablePathEntry[];
72
- }
73
-
74
- interface RolesConfig {
75
- version?: number;
76
- roles?: Record<string, RoleDef>;
77
- defaultRole?: string;
78
- fileDir?: string;
79
- caseInsensitive?: boolean;
80
-
81
- default?: {
82
- /** Tool rules evaluated before role-specific rules (snake_case key "pre-tool" in JSON). */
83
- preTool?: ToolRule[];
84
- /** Tool rules evaluated after role-specific rules (snake_case key "post-tool" in JSON). */
85
- postTool?: ToolRule[];
86
- };
87
-
88
- postSwitchLog?: boolean;
89
- }
90
-
91
- interface MergedConfig {
92
- roles: Record<string, RoleDef>;
93
- fileDir: string;
94
- caseInsensitive: boolean;
95
-
96
- /** Default pre-tool rules loaded from roles.json default.pre-tool */
97
- preTool?: ToolRule[];
98
- /** Default post-tool rules loaded from roles.json default.post-tool */
99
- postTool?: ToolRule[];
100
-
101
- postSwitchLog: boolean;
102
- configFile: string;
103
- }
104
-
105
- // -- Branch history persistence -------------------------------------
106
-
107
- const AGENT_DIR = resolve(os.homedir(), ".pi", "agent");
108
- const BRANCH_HISTORY_FILE = resolve(AGENT_DIR, "role_branch.json");
109
-
110
- /** Directory containing this extension file. Used to resolve bundled roles/ directory. */
111
- const EXTENSION_DIR = dirname(fileURLToPath(import.meta.url));
112
-
113
- interface SwitchLogEntry {
114
- ts: string;
115
- role: string;
116
- branch: string;
117
- }
118
-
119
- interface BranchHistory {
120
- [role: string]: string | undefined;
121
- }
122
-
123
- /** Load branch history from disk. Handles backward compat: old flat format {role: branch}
124
- * is converted to new format with a history array.
125
- * Returns the role→branch mapping and the switch history entries separately. */
126
- function loadBranchHistory(): { mapping: BranchHistory; history: SwitchLogEntry[] } {
127
- try {
128
- const raw = JSON.parse(readFileSync(BRANCH_HISTORY_FILE, "utf-8"));
129
- // Detect new format (has "history" array) vs old format (flat {role: branch})
130
- if (raw && typeof raw === "object" && !Array.isArray(raw) && "history" in raw) {
131
- const { history, ...mapping } = raw;
132
- return {
133
- mapping: mapping as BranchHistory,
134
- history: Array.isArray(history) ? (history as SwitchLogEntry[]) : [],
135
- };
136
- }
137
- // Old format: flat object — wrap into new shape with empty history
138
- return { mapping: raw as BranchHistory, history: [] };
139
- } catch {
140
- return { mapping: {}, history: [] };
141
- }
142
- }
143
-
144
- /** Persist mapping + history to disk. */
145
- async function saveBranchHistory(mapping: BranchHistory, history: SwitchLogEntry[]): Promise<void> {
146
- await mkdir(AGENT_DIR, { recursive: true });
147
- await writeFile(
148
- BRANCH_HISTORY_FILE,
149
- JSON.stringify({ ...mapping, history }, null, 2),
150
- "utf-8",
151
- );
152
- }
153
-
154
- async function recordBranchUsage(role: string, branch: string): Promise<void> {
155
- if (!role || !branch) return;
156
- const { mapping, history } = loadBranchHistory();
157
- mapping[role] = branch;
158
- history.push({ ts: new Date().toISOString(), role, branch });
159
- // Cap at 50 entries (trim oldest)
160
- if (history.length > 50) {
161
- history.splice(0, history.length - 50);
162
- }
163
- await saveBranchHistory(mapping, history);
164
- }
165
-
166
- interface BranchEntry {
167
- branch: string;
168
- role: string;
169
- isCurrent: boolean;
170
- isLastUsed: boolean;
171
- }
172
-
173
- /** List all git branches and match them to roles. */
174
- async function listBranchesByRole(config: MergedConfig, cwd: string): Promise<{
175
- entries: BranchEntry[];
176
- grouped: Record<string, BranchEntry[]>;
177
- roleOrder: string[];
178
- unmatched: string[];
179
- }> {
180
- const { mapping: history } = loadBranchHistory();
181
- const entries: BranchEntry[] = [];
182
- const grouped: Record<string, BranchEntry[]> = {};
183
- const unmatched: string[] = [];
184
-
185
- // Get current branch
186
- let currentBranch: string | null = null;
187
- try {
188
- const stdout = execFileSync("git", ["branch", "--show-current"], {
189
- cwd,
190
- encoding: "utf-8",
191
- }) as string;
192
- currentBranch = stdout.trim() || null;
193
- } catch {
194
- /* not a git repo */
195
- }
196
-
197
- // Get all branches
198
- let branches: string[] = [];
199
- try {
200
- const stdout = execFileSync("git", ["branch", "--format", "%(refname:short)"], {
201
- cwd,
202
- encoding: "utf-8",
203
- }) as string;
204
- branches = stdout.trim().split("\n").filter(Boolean);
205
- } catch {
206
- return { entries, grouped, roleOrder: [], unmatched };
207
- }
208
-
209
- // Match each branch to a role
210
- for (const branch of branches) {
211
- const role = detectRole(branch, config);
212
- if (!role) {
213
- unmatched.push(branch);
214
- continue;
215
- }
216
-
217
- const isCurrent = branch === currentBranch;
218
- const isLastUsed = history[role] === branch;
219
- const entry: BranchEntry = { branch, role, isCurrent, isLastUsed };
220
- entries.push(entry);
221
- if (!grouped[role]) grouped[role] = [];
222
- grouped[role].push(entry);
223
- }
224
-
225
- // Sort within each role: current -> last-used -> rest (alpha)
226
- for (const role of Object.keys(grouped)) {
227
- grouped[role].sort((a, b) => {
228
- if (a.isCurrent) return -1;
229
- if (b.isCurrent) return 1;
230
- if (a.isLastUsed) return -1;
231
- if (b.isLastUsed) return 1;
232
- return a.branch.localeCompare(b.branch);
233
- });
234
- }
235
-
236
- // Role order: roles with current branch first, then last-used, then alpha
237
- const roleOrder = [...new Set(entries.map((e) => e.role))].sort((a, b) => {
238
- const aHasCurrent = grouped[a]?.some((e) => e.isCurrent);
239
- const bHasCurrent = grouped[b]?.some((e) => e.isCurrent);
240
- if (aHasCurrent && !bHasCurrent) return -1;
241
- if (!aHasCurrent && bHasCurrent) return 1;
242
- const aHasLast = grouped[a]?.some((e) => e.isLastUsed);
243
- const bHasLast = grouped[b]?.some((e) => e.isLastUsed);
244
- if (aHasLast && !bHasLast) return -1;
245
- if (!aHasLast && bHasLast) return 1;
246
- return a.localeCompare(b);
247
- });
248
-
249
- return { entries, grouped, roleOrder, unmatched };
250
- }
251
-
252
- // -- Config loading -------------------------------------------------
253
-
254
- const HOME_PI_DIR = resolve(os.homedir(), ".pi");
255
-
256
- function loadConfig(): MergedConfig {
257
- const merged: MergedConfig = {
258
- roles: {},
259
- fileDir: ".pi",
260
- caseInsensitive: true,
261
-
262
- postSwitchLog: true,
263
- };
264
-
265
- // Search paths for roles.json (first found wins):
266
- // 1. {PROJECT_ROOT}/.pi/roles.json (project-specific, inside .pi/)
267
- // 2. ~/.pi/agent/roles.json (global fallback, inside git repo)
268
- const roleFileCandidates = [
269
- resolve(process.cwd(), ".pi", "roles.json"),
270
- resolve(HOME_PI_DIR, "agent", "roles.json"),
271
- ];
272
-
273
- let loadedRoles = false;
274
- for (const candidatePath of roleFileCandidates) {
275
- if (existsSync(candidatePath)) {
276
- try {
277
- const raw = JSON.parse(readFileSync(candidatePath, "utf-8")) as RolesConfig;
278
- if (raw.roles) {
279
- Object.assign(merged.roles, raw.roles);
280
- loadedRoles = true;
281
- }
282
- if (raw.fileDir) merged.fileDir = raw.fileDir;
283
- if (raw.caseInsensitive !== undefined) merged.caseInsensitive = raw.caseInsensitive;
284
-
285
- if (raw.postSwitchLog !== undefined) merged.postSwitchLog = raw.postSwitchLog;
286
- if (raw.default) {
287
- if (raw.default["pre-tool"] !== undefined) merged.preTool = raw.default["pre-tool"];
288
- if (raw.default["post-tool"] !== undefined) merged.postTool = raw.default["post-tool"];
289
- }
290
- } catch {
291
- // ignore parse errors
292
- }
293
- merged.configFile = candidatePath;
294
- break; // first matching path wins
295
- }
296
- }
297
-
298
- if (!loadedRoles) {
299
- const searched = roleFileCandidates.join(", ");
300
- throw new Error(
301
- `roles.json not found. Searched:\n ${searched}\n\n` +
302
- `Create one at the project root (~/.pi/agent/roles.json) or inside `.pi/` ({project}/.pi/roles.json).`
303
- );
304
- }
305
-
306
- // Walk up from CWD to find .pi-project.json for project overrides
307
- // Project-level roles fully override matching base roles
308
- let dir = process.cwd();
309
- while (true) {
310
- const candidate = resolve(dir, ".pi-project.json");
311
- if (existsSync(candidate)) {
312
- try {
313
- const projectRaw = JSON.parse(readFileSync(candidate, "utf-8")) as RolesConfig;
314
- if (projectRaw.roles) {
315
- for (const [name, def] of Object.entries(projectRaw.roles)) {
316
- merged.roles[name] = def;
317
- }
318
- }
319
- } catch {
320
- // ignore parse errors
321
- }
322
- break;
323
- }
324
- const parent = dirname(dir);
325
- if (parent === dir) break;
326
- dir = parent;
327
- }
328
-
329
- return merged;
330
- }
331
-
332
- /** Detect the role for a given branch name by iterating merged roles. */
333
- function detectRole(branch: string, config: MergedConfig): string | null {
334
- for (const [roleName, roleDef] of Object.entries(config.roles)) {
335
- try {
336
- if (new RegExp(roleDef.pattern).test(branch)) return roleName;
337
- } catch {
338
- // skip invalid regex
339
- }
340
- }
341
- return null; // no match -> "unknown"
342
- }
343
-
344
- // -- File helpers ---------------------------------------------------
345
-
346
- /** Extract NN sequence number from a todo/ or report/ filename. */
347
- function extractNN(filename: string): number | null {
348
- const match = filename.match(/^(\d+)-/);
349
- return match ? parseInt(match[1], 10) : null;
350
- }
351
-
352
- /** Scan todo/ and report/ dirs for the highest NN in use. */
353
- async function findMaxNN(cwd: string): Promise<number> {
354
- let max = 0;
355
- for (const dirName of ["todo", "report"]) {
356
- try {
357
- const entries = await readdir(resolve(cwd, dirName));
358
- for (const entry of entries) {
359
- const nn = extractNN(entry);
360
- if (nn !== null && nn > max) max = nn;
361
- }
362
- } catch {
363
- // dir doesn't exist yet
364
- }
365
- }
366
- return max;
367
- }
368
-
369
- /** Normalise a file path argument: strip leading ./, absolute -> relative. */
370
- function normalisePath(raw: string, cwd: string, cwdAbsolute: string): string {
371
- let path = raw;
372
- if (path.startsWith(cwdAbsolute + "/")) path = path.slice(cwdAbsolute.length + 1);
373
- if (path.startsWith("./")) path = path.slice(2);
374
- return path;
375
- }
376
-
377
- /**
378
- * Strip a leading `cd <dir> && ` prefix from a bash command if <dir> resolves
379
- * to a path inside the project. Supports relative paths (resolved against cwd).
380
- *
381
- * Examples:
382
- * "cd docs && ls -la" → "ls -la"
383
- * "cd ../outside && ls" → "cd ../outside && ls" (unchanged — outside project)
384
- * "ls -la" → "ls -la" (unchanged — no cd prefix)
385
- */
386
- function stripCdPrefix(cmd: string, cwd: string, cwdAbsolute: string): string {
387
- const match = cmd.match(/^cd\s+(\S+)\s*&&\s*/);
388
- if (!match) return cmd;
389
- const dir = match[1];
390
- const rest = cmd.slice(match[0].length);
391
- // Resolve the directory: absolute path, relative path, or ~
392
- let resolved: string;
393
- if (dir.startsWith("/")) {
394
- resolved = dir;
395
- } else if (dir.startsWith("~")) {
396
- return cmd; // home dir — can't guarantee it's inside project, keep as-is
397
- } else {
398
- resolved = resolve(cwd, dir);
399
- }
400
- // Check if resolved path is inside the project
401
- if (resolved.startsWith(cwdAbsolute)) {
402
- return rest;
403
- }
404
- return cmd;
405
- }
406
-
407
- /** Check if a path is inside one of the given directories. */
408
- function isInside(path: string, dirs: string[]): boolean {
409
- return dirs.some((d) => path === d || path.startsWith(d + "/"));
410
- }
411
-
412
- /**
413
- * Check if a path matches one of the given writable path entries.
414
- *
415
- * - If `entry.path` is empty string, only root-level files match.
416
- * - If `entry.extension` is set, the path must end with that extension.
417
- * - A trailing slash is appended automatically when matching directories.
418
- */
419
- function isWritablePath(path: string, writablePaths: WritablePathEntry[]): boolean {
420
- return writablePaths.some((entry) => {
421
- const dir = entry.path;
422
- const ext = entry.extension;
423
- // Check directory match
424
- const inDir =
425
- dir === ""
426
- ? !path.includes("/") // root level only
427
- : path === dir || path.startsWith(dir + "/");
428
- if (!inDir) return false;
429
- // Check extension filter (if set)
430
- if (ext) {
431
- return path.endsWith("." + ext);
432
- }
433
- return true; // no extension filter = allow any file in this dir
434
- });
435
- }
436
-
437
- /**
438
- * Load a role's .md file from the project's fileDir directory.
439
- * Case-insensitive lookup if config.caseInsensitive is true.
440
- */
441
- async function loadRoleFile(
442
- cwd: string,
443
- role: string,
444
- config: MergedConfig,
445
- ): Promise<string | null> {
446
- // 1. Project-local: {cwd}/{fileDir}/{role}.md (highest priority)
447
- const roleDir = resolve(cwd, config.fileDir);
448
- const exactPath = resolve(roleDir, `${role}.md`);
449
- try {
450
- return await readFile(exactPath, "utf8");
451
- } catch {
452
- // not found with exact case
453
- }
454
-
455
- if (config.caseInsensitive) {
456
- try {
457
- const entries = await readdir(roleDir);
458
- for (const entry of entries) {
459
- if (entry.toLowerCase() === `${role}.md`) {
460
- return await readFile(resolve(roleDir, entry), "utf8");
461
- }
462
- }
463
- } catch {
464
- // roleDir doesn't exist
465
- }
466
- }
467
-
468
- // 2. Extension-bundled: {extensionDir}/roles/{role}.md (fallback)
469
- const bundledRoleDir = resolve(EXTENSION_DIR, "roles");
470
- const bundledExact = resolve(bundledRoleDir, `${role}.md`);
471
- try {
472
- return await readFile(bundledExact, "utf8");
473
- } catch {
474
- // not found with exact case
475
- }
476
-
477
- if (config.caseInsensitive) {
478
- try {
479
- const entries = await readdir(bundledRoleDir);
480
- for (const entry of entries) {
481
- if (entry.toLowerCase() === `${role}.md`) {
482
- return await readFile(resolve(bundledRoleDir, entry), "utf8");
483
- }
484
- }
485
- } catch {
486
- // bundledRoleDir doesn't exist
487
- }
488
- }
489
-
490
- return null;
491
- }
492
-
493
- // -- Built-in role instructions (fallback when no .md file exists) ---
494
- // Canonical source: roles/{role}.md in the extension directory.
495
- // Run `python3 scripts/validate-role-prompts.py` to verify alignment.
496
-
497
- const BUILTIN_INSTRUCTIONS: Record<string, string> = {
498
- planner: `
499
-
500
- ## Your Role: Planner
501
-
502
- You are **PLANNER**. Your sole responsibility is to research (just collecting data for others to solved the problem) and create
503
- \`todo/NN-name.md\` files. That is it. Switching branch does not switch your role.
504
-
505
- ## What you do
506
- 1. Research with \`read\` / \`grep\` / \`find\` / \`ls\`
507
- 2. Create \`todo/NN-name.md\` with sequenced, actionable items
508
- 3. Present the plan to the user
509
-
510
- ## What you do NOT do
511
- - \`edit\`/\`write\` any file outside \`todo/\`
512
- - Switch branches (the user handles that)
513
- - Implement anything (that's the implementor)
514
- - Write reports (that's the implementor)
515
-
516
- ## File format: \`todo/NN-name.md\`
517
- - \`- [ ]\` pending, \`- [x]\` done
518
- - First line after checkbox = **header**
519
- - Indented lines = **body**
520
- - Blank lines separate items
521
-
522
- ## NN sequence rule
523
- Every \`todo/NN-name.md\` must use NN **higher** than the highest NN in both
524
- \`todo/\` **and** \`report/\`.
525
- `,
526
-
527
- implementor: `
528
-
529
- ## Your Role: Implementor
530
-
531
- You are **IMPLEMENTOR**. Your responsibility is to read todo/*.md files, implement and write reports. That is it. Switching branch does not switch your role.
532
-
533
- ## Report format: \`report/NN-name.md\`
534
- - Same NN and same headers as the todo
535
- - Add implementation notes, decisions, and \`\`\`bash results
536
-
537
- ## How to implement
538
- 1. Read \`todo/NN-name.md\` (lowest NN first)
539
- 2. If \`todo/NN-name.detail.md\` exists, read it too
540
- 3. Implement each \`- [ ]\` item using \`edit\`/\`write\`
541
- 4. After each item (or batch), update \`report/NN-name.md\`
542
- 5. When done, move to the next NN
543
-
544
- ## What you do NOT do
545
- - Switch branches (the user handles that)
546
- - Write to \`todo/\`, \`plan/\`, or \`.pi/\` — create reports only; let others verify and mark items done
547
- - Create todo files (that's the planner's job)
548
- - Modify or mark items in todo files (that's the reviewer's job after verification)
549
- `,
550
-
551
- reviewer: `
552
-
553
- ## Your Role: Reviewer
554
-
555
- You are **REVIEWER**. Your sole responsibility is to verify that reports fully cover their corresponding todos, and mark items done in \`todo/\`. That is it. Switching branch does not switch your role.
556
-
557
- ## What you do
558
- 1. Read \`todo/NN-name.md\` and \`report/NN-name.md\`
559
- 2. Match each pending \`- [ ]\` todo item against a section in the report
560
- 3. **Covered** → mark \`- [x]\` in the todo file
561
- 4. **Missing** → leave \`- [ ]\`, tell the user
562
- 5. Also check for orphan reports and stale todos
563
-
564
- ## What you do NOT do
565
- - \`edit\`/\`write\` any file outside \`todo/\`
566
- - Write reports (that's the implementor)
567
- - Create \`todo/\` or \`plan/\` files (that's the planner)
568
- - Switch branches (the user handles that)
569
- - Write to \`.pi/\`
570
-
571
- ## Key rules
572
- - **Covered** items get \`- [x]\` in the todo file
573
- - **Missing** items stay \`- [ ]\` — explain why to the user
574
- - Read source files and \`review/\` and \`todo/\` as needed to verify
575
- `,
576
-
577
- admin: `
578
-
579
- ## Your Role: Admin
580
-
581
- You are **ADMIN**. Switching branch does not switch your role.
582
-
583
- ## What you do
584
- - Edit \`.pi/\` files — role prompts, roles.json
585
- - Edit \`README.md\` and \`AGENTS.md\`
586
- - Explore the codebase (read-only)
587
-
588
- ## What you do NOT do
589
- - Edit source code
590
- - Create or modify \`todo/\`, \`plan/\`, \`report/\` files
591
- - Switch branches (the user handles that)
592
- `,
593
-
594
- researcher: `
595
-
596
- ## Your Role: Researcher
597
-
598
- You are **RESEARCHER**. Switching branch does not switch your role.
599
-
600
- ## What you do
601
- - Research with \`find\` / \`grep\` / \`ls\` / \`web_search\` / \`read\`
602
- - Write findings to \`docs/*.md\` and root \`*.md\` files
603
-
604
- ## What you do NOT do
605
- - Edit source code
606
- - Create or modify \`todo/\`, \`plan/\`, \`report/\` files
607
- - Switch branches (the user handles that)
608
- `,
609
- };
610
-
611
- // -- Shared: scan todos and cross-reference with reports -----------
612
-
613
- interface PendingItem {
614
- lineno: number;
615
- header: string;
616
- covered: boolean;
617
- }
618
-
619
- interface TodoFile {
620
- file: string;
621
- pending: PendingItem[];
622
- reportExists: boolean;
623
- }
624
-
625
- async function scanTodos(cwd: string): Promise<{
626
- files: TodoFile[];
627
- totalPending: number;
628
- totalCovered: number;
629
- summary: string;
630
- }> {
631
- const files: TodoFile[] = [];
632
- let totalPending = 0;
633
- let totalCovered = 0;
634
-
635
- try {
636
- const entries = await readdir(resolve(cwd, "todo"));
637
- for (const entry of entries.sort()) {
638
- if (!entry.endsWith(".md") || entry.endsWith(".detail.md")) continue;
639
- const content = await readFile(resolve(cwd, "todo", entry), "utf8");
640
- const pending: PendingItem[] = [];
641
- for (const [i, line] of content.split("\n").entries()) {
642
- const m = line.match(/^- \[ \] (.+)$/);
643
- if (m) pending.push({ lineno: i + 1, header: m[1].trim(), covered: false });
644
- }
645
- if (pending.length === 0) continue;
646
-
647
- let reportHeaders: string[] = [];
648
- try {
649
- const reportContent = await readFile(resolve(cwd, "report", entry), "utf8");
650
- for (const line of reportContent.split("\n")) {
651
- const m = line.match(/^- (.+)$/);
652
- if (m && !line.includes("[ ]") && !line.includes("[x]")) {
653
- reportHeaders.push(m[1].trim().toLowerCase());
654
- }
655
- }
656
- } catch {
657
- // no matching report
658
- }
659
-
660
- const reportExists = reportHeaders.length > 0;
661
- for (const p of pending) {
662
- p.covered = reportHeaders.some(
663
- (h) => p.header.toLowerCase().includes(h) || h.includes(p.header.toLowerCase()),
664
- );
665
- if (p.covered) totalCovered++;
666
- }
667
- totalPending += pending.length;
668
- files.push({ file: entry, pending, reportExists });
669
- }
670
- } catch {
671
- // todo/ doesn"t exist
672
- }
673
-
674
- const displayLines: string[] = [];
675
- for (const f of files) {
676
- const tag = f.reportExists ? "" : " (no report)";
677
- displayLines.push(`todo/${f.file}${tag}`);
678
- for (const p of f.pending) {
679
- displayLines.push(` ${p.covered ? "\u2705" : "\u274C"} line ${p.lineno}: ${p.header}`);
680
- }
681
- }
682
- displayLines.push(
683
- `\u2500\u2500 ${totalPending - totalCovered} missing \u00B7 ${totalCovered} covered \u00B7 ${totalPending} total \u2500\u2500`,
684
- );
685
-
686
- return { files, totalPending, totalCovered, summary: displayLines.join("\n") };
687
- }
688
-
689
- /** Handle /hat todo display: scan todos and send follow-up for pending items. */
690
- /** Ancestry check: verify current branch descends from ancestor role branches. */
691
- async function verifyAncestry(config: MergedConfig, cwd: string): Promise<{
692
- ok: boolean;
693
- violations: { role: string; branch: string; currentBranch: string }[];
694
- }> {
695
- const violations: { role: string; branch: string; currentBranch: string }[] = [];
696
-
697
- let currentBranch: string;
698
- try {
699
- currentBranch = execFileSync("git", ["branch", "--show-current"], {
700
- cwd,
701
- encoding: "utf-8",
702
- }).trim();
703
- if (!currentBranch) return { ok: true, violations }; // not on a branch (detached)
704
- } catch {
705
- return { ok: true, violations }; // not a git repo
706
- }
707
-
708
- const role = detectRole(currentBranch, config);
709
- if (!role) return { ok: true, violations }; // unknown role
710
-
711
- const ancestorRoles = config.roles[role]?.ancestorRoles;
712
- if (!ancestorRoles || ancestorRoles.length === 0) return { ok: true, violations }; // no ancestors required
713
-
714
- // Get all local branches
715
- let allBranches: string[];
716
- try {
717
- const stdout = execFileSync("git", ["branch", "--format", "%(refname:short)"], {
718
- cwd,
719
- encoding: "utf-8",
720
- });
721
- allBranches = stdout.trim().split("\n").filter(Boolean);
722
- } catch {
723
- return { ok: true, violations };
724
- }
725
-
726
- for (const ancestorRole of ancestorRoles) {
727
- const pattern = config.roles[ancestorRole]?.pattern;
728
- if (!pattern) continue;
729
-
730
- const regex = new RegExp(pattern);
731
- const candidates = allBranches.filter((b) => regex.test(b));
732
-
733
- for (const candidate of candidates) {
734
- if (candidate === currentBranch) continue; // same branch, trivially ancestor
735
-
736
- try {
737
- execFileSync("git", ["merge-base", "--is-ancestor", candidate, currentBranch], {
738
- cwd,
739
- stdio: "pipe",
740
- });
741
- // exit code 0 = is ancestor, no violation
742
- } catch {
743
- // exit code 1 or error = not ancestor, or deleted branch — record violation
744
- violations.push({ role: ancestorRole, branch: candidate, currentBranch });
745
- }
746
- }
747
- }
748
-
749
- return { ok: violations.length === 0, violations };
750
- }
751
-
752
- /** Check if master/main is an ancestor of the current branch. */
753
- async function isMasterOrMainAncestor(cwd: string): Promise<{ ok: boolean; branch: string }> {
754
- let currentBranch: string;
755
- try {
756
- currentBranch = execFileSync("git", ["branch", "--show-current"], {
757
- cwd,
758
- encoding: "utf-8",
759
- }).trim();
760
- if (!currentBranch) return { ok: true, branch: "master" }; // detached
761
- } catch {
762
- return { ok: true, branch: "master" }; // not a git repo
763
- }
764
-
765
- // try master first, then main
766
- for (const candidate of ["master", "main"]) {
767
- try {
768
- execFileSync("git", ["rev-parse", "--verify", `refs/heads/${candidate}`], {
769
- cwd,
770
- stdio: "pipe",
771
- });
772
- // branch exists
773
- if (currentBranch === candidate) return { ok: true, branch: candidate }; // already on it
774
- try {
775
- execFileSync("git", ["merge-base", "--is-ancestor", candidate, currentBranch], {
776
- cwd,
777
- stdio: "pipe",
778
- });
779
- return { ok: true, branch: candidate }; // is ancestor
780
- } catch {
781
- return { ok: false, branch: candidate }; // not ancestor
782
- }
783
- } catch {
784
- continue; // branch doesn't exist locally, try next
785
- }
786
- }
787
-
788
- // neither master nor main exists locally — skip check
789
- return { ok: true, branch: "master" };
790
- }
791
-
792
- async function handleTodo(
793
- pi: ExtensionAPI,
794
- ctx: { cwd: string; ui: { notify: (msg: string, level: string) => void } },
795
- ): Promise<void> {
796
- // plan/*.md files are excluded from /hat todo — they store future ideas,
797
- // not actionable todos. scanTodos() already reads only from the todo/ directory.
798
- const result = await scanTodos(ctx.cwd);
799
- if (result.files.length === 0) {
800
- ctx.ui.notify("\uD83D\uDCED No pending todos found", "info");
801
- return;
802
- }
803
-
804
- const remaining = result.totalPending - result.totalCovered;
805
-
806
- if (remaining === 0) {
807
- ctx.ui.notify("\u2714\uFE0F No more task in todo", "info");
808
- return;
809
- }
810
-
811
- ctx.ui.notify(result.summary, "info");
812
-
813
- if (remaining > 0) {
814
- const detail = result.files
815
- .map((f) => {
816
- const items = f.pending
817
- .map((p) => ` ${p.covered ? "\u2705" : "\u274C"} ${p.header}`)
818
- .join("\n");
819
- return `todo/${f.file}${f.reportExists ? "" : " (no report)"}\n${items}`;
820
- })
821
- .join("\n");
822
- pi.sendUserMessage(
823
- `Analyze these pending todos against their reports:\n\n${detail}\n\n` +
824
- `For each \u274C item, check if it"s truly not implemented yet or if the report ` +
825
- `just uses different wording. If it"s genuinely missing, suggest next steps.`,
826
- { deliverAs: "followUp" },
827
- );
828
- }
829
- }
830
-
831
- /** Role icon for status bar display. */
832
- function roleIcon(role: string): string {
833
- const lower = role.toLowerCase();
834
- if (lower === "planner") return "\uD83D\uDCCB";
835
- if (lower === "implementor") return "\uD83D\uDEE0";
836
- if (lower === "reviewer") return "\uD83D\uDD0D";
837
- if (lower === "admin") return "\u2699";
838
- if (lower === "researcher") return "\uD83D\uDD2C";
839
- return "\uD83E\uDDE2";
840
- }
841
-
842
- // -- ANSI color palette for git ref log decoration ----------------
843
-
844
- const REF_COLORS = [
845
- "38;5;51", // cyan
846
- "38;5;118", // green
847
- "38;5;226", // yellow
848
- "38;5;207", // magenta
849
- "38;5;196", // red
850
- "38;5;75", // blue
851
- "38;5;214", // orange
852
- "38;5;201", // purple
853
- ];
854
-
855
- const HEAD_ANSI = "1;37"; // bold bright white
856
-
857
- /**
858
- * Colorize ref names in `git log --oneline --decorate` output.
859
- * Parses `(ref-list)` patterns and wraps each ref in distinct ANSI
860
- * 256-color codes. HEAD gets a fixed bold white. All other refs
861
- * (branches, tags) rotate through an 8-color palette consistently.
862
- */
863
- function colorizeLog(log: string): string {
864
- // First pass: collect all unique ref names for consistent color assignment
865
- const refNames = new Set<string>();
866
- const refListRe = /\(([^)]+)\)/g;
867
- let m: RegExpExecArray | null;
868
- while ((m = refListRe.exec(log)) !== null) {
869
- for (const part of m[1].split(/,\s*/)) {
870
- const trimmed = part
871
- .replace(/^(HEAD -> |HEAD\b|tag: |tags: )/g, "")
872
- .trim();
873
- if (trimmed) refNames.add(trimmed);
874
- }
875
- }
876
-
877
- // Assign a rotating color to each unique ref name
878
- const colorMap = new Map<string, string>();
879
- let idx = 0;
880
- for (const name of refNames) {
881
- colorMap.set(name, REF_COLORS[idx % REF_COLORS.length]);
882
- idx++;
883
- }
884
-
885
- // Second pass: wrap each ref in the `(...)` list with ANSI codes
886
- return log
887
- .split("\n")
888
- .map((line) => {
889
- return line.replace(
890
- /\(([^)]+)\)/g,
891
- (_, refList: string) => {
892
- const colored = refList
893
- .split(/,\s*/)
894
- .map((ref: string) => {
895
- // "HEAD -> branchname"
896
- const headArrow = ref.match(/^(HEAD)\s*->\s*(.+)$/);
897
- if (headArrow) {
898
- const head = `\x1b[${HEAD_ANSI}mHEAD\x1b[0m`;
899
- const branchColor = colorMap.get(headArrow[2]) || REF_COLORS[0];
900
- const branch = `\x1b[${branchColor}m${headArrow[2]}\x1b[0m`;
901
- return `${head} -> ${branch}`;
902
- }
903
- // bare HEAD (detached)
904
- if (ref === "HEAD") {
905
- return `\x1b[${HEAD_ANSI}mHEAD\x1b[0m`;
906
- }
907
- // "tag: tagname"
908
- const tagMatch = ref.match(/^tag:\s*(.+)$/);
909
- if (tagMatch) {
910
- const color = colorMap.get(tagMatch[1]) || REF_COLORS[0];
911
- return `\x1b[${color}m${ref}\x1b[0m`;
912
- }
913
- // plain ref (branch, remote)
914
- const color = colorMap.get(ref) || REF_COLORS[0];
915
- return `\x1b[${color}m${ref}\x1b[0m`;
916
- })
917
- .join(", ");
918
- return `(${colored})`;
919
- },
920
- );
921
- })
922
- .join("\n");
923
- }
924
-
925
- // -- Shared git log helper -----------------------------------------
926
-
927
- /**
928
- * Run a colored git log and display it in the TUI.
929
- * Shared between /hatl and the post-switch log in /hat.
930
- */
931
- async function showGitLog(
932
- ctx: { ui: { notify: (msg: string, level: string) => void } },
933
- count: number = 10,
934
- ): Promise<void> {
935
- const validCount = Number.isFinite(count) && count > 0 ? count : 10;
936
-
937
- try {
938
- const result = await import("child_process").then((cp) =>
939
- cp.execFileSync("git", [
940
- "log",
941
- "--graph",
942
- "--oneline",
943
- "--decorate",
944
- "--all",
945
- `-${validCount}`,
946
- ], { encoding: "utf-8" }),
947
- ) as string;
948
-
949
- const raw = result.trim();
950
- if (!raw) {
951
- return; // silently ignore empty log
952
- }
953
-
954
- const colored = colorizeLog(raw);
955
- const footer = `\n\x1b[90m\u2022 /hatl N for more lines\x1b[0m`;
956
- ctx.ui.notify(colored + footer, "info");
957
- } catch {
958
- // non-critical — silently ignore
959
- }
960
- }
62
+ import type { MergedConfig } from "./lib/types.js";
63
+ import { loadConfig, detectRole } from "./lib/config.js";
64
+ import { recordBranchUsage, listBranchesByRole } from "./lib/branch-history.js";
65
+ import { loadRoleFile, BUILTIN_INSTRUCTIONS, EXTENSION_DIR } from "./lib/role-file.js";
66
+ import { normalisePath, stripCdPrefix, isInside, isWritablePath } from "./lib/paths.js";
67
+ import { extractNN, findMaxNN, verifyAncestry, isMasterOrMainAncestor, handleTodo } from "./lib/todo-utils.js";
68
+ import { roleIcon, showGitLog, showGitStatus, colorizeLog } from "./lib/git-ui.js";
69
+ import type { ExecFunction } from "./lib/git-ui.js";
961
70
 
962
71
  // -- Extension -----------------------------------------------------
963
72
 
@@ -967,6 +76,9 @@ export default function (pi: ExtensionAPI) {
967
76
  let config: MergedConfig = loadConfig();
968
77
  let cwdAbsolute = "";
969
78
 
79
+ // -- Wrapper around pi.exec for lib functions that accept ExecFunction --
80
+ const execFn: ExecFunction = (cmd, args) => pi.exec(cmd, args);
81
+
970
82
  // -- Helpers -------------------------------------------------
971
83
 
972
84
  async function detectBranch(): Promise<string | null> {
@@ -1039,31 +151,6 @@ export default function (pi: ExtensionAPI) {
1039
151
  return;
1040
152
  }
1041
153
 
1042
- // /hat log: show last 5 switch history entries (most recent first)
1043
- if (sub === "log") {
1044
- const { history } = loadBranchHistory();
1045
- if (history.length === 0) {
1046
- ctx.ui.notify("No switches recorded yet", "info");
1047
- return;
1048
- }
1049
- const entries = history.slice(-5).reverse();
1050
- const lines = entries.map((e) => {
1051
- const ts = new Date(e.ts);
1052
- const now = new Date();
1053
- const diffMs = now.getTime() - ts.getTime();
1054
- const diffSec = Math.floor(diffMs / 1000);
1055
- let relative: string;
1056
- if (diffSec < 60) relative = `${diffSec}s ago`;
1057
- else if (diffSec < 3600) relative = `${Math.floor(diffSec / 60)}m ago`;
1058
- else if (diffSec < 86400) relative = `${Math.floor(diffSec / 3600)}h ago`;
1059
- else relative = `${Math.floor(diffSec / 86400)}d ago`;
1060
- const iso = ts.toISOString().replace("T", " ").slice(0, 19);
1061
- return `[${e.role}] ${e.branch} @ ${iso} (${relative})`;
1062
- });
1063
- ctx.ui.notify(lines.join("\n"), "info");
1064
- return;
1065
- }
1066
-
1067
154
  // /hat info: show role status
1068
155
  if (sub === "info") {
1069
156
  const desc = currentRole ? config.roles[currentRole]?.description ?? "" : "";
@@ -1148,19 +235,15 @@ export default function (pi: ExtensionAPI) {
1148
235
  noMatch: (t) => theme.fg("warning", t),
1149
236
  });
1150
237
 
1151
- selectList.onSelect = (item) => {
1152
- if (item.value.startsWith("__header_")) return; // ignore headers
1153
- done(item.value);
1154
- };
1155
- selectList.onCancel = () => done(null);
1156
-
1157
238
  container.addChild(selectList);
1158
239
 
1159
240
  container.addChild(
1160
241
  new Text(
1161
- theme.fg("dim", "\u2191\u2193 navigate \u2022 enter switch \u2022 esc cancel \u2022 type to search"),
1162
- 1,
1163
- 0,
242
+ theme.fg(
243
+ "muted",
244
+ "Type to filter \u00B7 \u2191/\u2193 navigate \u00B7 Enter select \u00B7 Esc/q cancel",
245
+ ),
246
+ 1, 0,
1164
247
  ),
1165
248
  );
1166
249
 
@@ -1168,6 +251,11 @@ export default function (pi: ExtensionAPI) {
1168
251
  new DynamicBorder((s: string) => theme.fg("accent", s)),
1169
252
  );
1170
253
 
254
+ selectList.onSelect = (item) => {
255
+ done(item.value);
256
+ };
257
+ selectList.onCancel = () => done(null);
258
+
1171
259
  return {
1172
260
  render: (w) => container.render(w),
1173
261
  invalidate: () => container.invalidate(),
@@ -1180,13 +268,20 @@ export default function (pi: ExtensionAPI) {
1180
268
  { overlay: true },
1181
269
  );
1182
270
 
271
+ if (!result) {
272
+ ctx.ui.notify("Branch switch cancelled.", "info");
273
+ return;
274
+ }
275
+
276
+ // Switch to selected branch
1183
277
  if (result) {
1184
- // Require confirmation before switching branches
1185
- if (ctx.hasUI) {
1186
- const confirmed = await ctx.ui.confirm(`Switch to branch "${result}"?`);
1187
- if (!confirmed) {
1188
- ctx.ui.notify("Switch cancelled", "info");
1189
- return;
278
+ // Check `/hat switch`-style direct branch switching
279
+ if (sub && !["info", "todo", "log"].includes(sub)) {
280
+ // "/hat somebranch": generate a TUI selector for extra safety
281
+ const candidates = selectItems.filter((s) => s.value === sub);
282
+ if (candidates.length > 0) {
283
+ // Already handled by TUI above — this path is for direct branch switching
284
+ // For direct /hat branch, fall through to switch
1190
285
  }
1191
286
  }
1192
287
  try {
@@ -1196,7 +291,7 @@ export default function (pi: ExtensionAPI) {
1196
291
  await updateRole(ctx); // updateRole handles history logging via change detection
1197
292
  // Show colored git log after switch (opt-out via roles.json postSwitchLog: false)
1198
293
  if (config.postSwitchLog) {
1199
- await showGitLog(ctx, 10);
294
+ await showGitLog(ctx, 10, execFn);
1200
295
  }
1201
296
  } else {
1202
297
  ctx.ui.notify(`\x1b[31mFailed to switch: ${switchResult.stderr}\x1b[0m`, "error");
@@ -1211,42 +306,80 @@ export default function (pi: ExtensionAPI) {
1211
306
  // -- /hatt command (alias for /hat todo) ----------------------
1212
307
 
1213
308
  pi.registerCommand("hatt", {
1214
- description: "Ancestry check + list pending todo items",
309
+ description: "Check ancestry + list todos",
1215
310
  handler: async (_args, ctx) => {
1216
- // Ancestry check first (blocking check)
311
+ // Check ancestry first
1217
312
  const ancestry = await verifyAncestry(config, ctx.cwd);
1218
-
1219
313
  if (!ancestry.ok) {
1220
314
  const lines = ancestry.violations.map(
1221
- (v) => ` \u274C ${v.branch} (${v.role}) run: git rebase ${v.branch}`,
315
+ (v) => ` \u274C ${v.branch}: not ancestor of ${v.currentBranch}`,
1222
316
  );
1223
317
  ctx.ui.notify(
1224
- `\u26A0\uFE0F Ancestry violations — upstream branches not merged in:\n${lines.join("\n")}`,
318
+ `Ancestry violations:\n${lines.join("\n")}\n\n` +
319
+ `Rebase or merge these branches into ${currentBranch} first, then retry.`,
1225
320
  "warning",
1226
321
  );
1227
- return; // block further processing until ancestry is fixed
322
+ return;
1228
323
  }
1229
324
 
1230
325
  await handleTodo(pi, ctx);
1231
-
1232
- ctx.ui.notify("\u2705 Ancestry check passed", "info");
1233
326
  },
1234
327
  });
1235
328
 
1236
- // -- /hatl command: colored git log ---------------------------
329
+ // -- /hatl command: colored git log + separator + git status --
1237
330
 
1238
331
  pi.registerCommand("hatl", {
1239
- description: "Show colored git log with branch graph",
332
+ description: "Show colored git log + git status. /hatl N for N lines (default 10).",
1240
333
  handler: async (args, ctx) => {
1241
- const count = args?.trim() ? parseInt(args.trim(), 10) : 10;
1242
- await showGitLog(ctx, count);
334
+ const count = parseInt(args?.trim() || "10", 10);
335
+ const validCount = Number.isFinite(count) && count > 0 ? count : 10;
336
+ const SEPARATOR = "\n\x1b[90m\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\x1b[0m\n";
337
+
338
+ try {
339
+ // Single shell command: git log then separator then git status
340
+ const result = await pi.exec("sh", ["-c",
341
+ `git log --graph --oneline --decorate --all -${validCount} && echo "__SEP__" && git status --short`,
342
+ ]);
343
+ const output = result.stdout;
344
+
345
+ // Split at the separator marker
346
+ const sepIdx = output.indexOf("__SEP__");
347
+ if (sepIdx === -1) {
348
+ // No separator found — show raw output as fallback
349
+ ctx.ui.notify(output.trim(), "info");
350
+ return;
351
+ }
352
+
353
+ const logRaw = output.slice(0, sepIdx).trim();
354
+ const statusRaw = output.slice(sepIdx + 7).trim(); // 7 = length of "__SEP__"
355
+
356
+ // Build output: colored log + styled separator + status
357
+ let display = "";
358
+
359
+ if (logRaw) {
360
+ display += colorizeLog(logRaw);
361
+ }
362
+
363
+ display += SEPARATOR;
364
+
365
+ if (statusRaw) {
366
+ display += statusRaw;
367
+ } else {
368
+ display += `\x1b[32m\u2713\x1b[0m working tree clean`;
369
+ }
370
+
371
+ const footer = `\n\x1b[90m\u2022 /hatl N for more lines\x1b[0m`;
372
+ ctx.ui.notify(display + footer, "info");
373
+ } catch (e) {
374
+ ctx.ui.notify(`\x1b[31m/hatl error: ${(e as Error).message}\x1b[0m`, "error");
375
+ }
1243
376
  },
1244
377
  });
1245
378
 
1246
379
  // -- /hatr command: TUI branch selector for rebase ------------------
1247
380
 
1248
381
  pi.registerCommand("hatr", {
1249
- description: "TUI branch selector pick a role-matched branch to rebase onto",
382
+ description: "TUI branch selector for rebase",
1250
383
  handler: async (_args, ctx) => {
1251
384
  // Edge case: detached HEAD or non-git directory
1252
385
  const branch = await detectBranch();
@@ -1409,7 +542,8 @@ export default function (pi: ExtensionAPI) {
1409
542
  `Rebase onto ${result} succeeded. Updated commit tree:`,
1410
543
  "info",
1411
544
  );
1412
- await showGitLog(ctx, 10);
545
+ await showGitLog(ctx, 10, execFn);
546
+ await showGitStatus(ctx, currentBranch, execFn);
1413
547
  } else {
1414
548
  ctx.ui.notify(
1415
549
  `Rebase onto ${result} failed:\n${rebaseResult.stderr}\n\nResolve conflicts manually, then run \`git rebase --continue\`.`,
@@ -1528,7 +662,6 @@ export default function (pi: ExtensionAPI) {
1528
662
  if (!currentRole || !currentBranch) {
1529
663
  return {
1530
664
  systemPrompt:
1531
- event.systemPrompt +
1532
665
  `
1533
666
 
1534
667
  ## Restricted Mode: Read-Only
@@ -1549,28 +682,27 @@ If the user asks you to make changes, tell them to switch to an appropriate bran
1549
682
  };
1550
683
  }
1551
684
 
1552
- // Try loading a custom .md file for this role
685
+ // Load _default.md first (base workflow rules), then the role file
686
+ const defaultFile = await loadRoleFile(ctx.cwd, "_default", config);
1553
687
  const roleFile = await loadRoleFile(ctx.cwd, currentRole, config);
688
+ if (defaultFile && roleFile) {
689
+ return { systemPrompt: roleFile + "\n\n" + defaultFile };
690
+ }
1554
691
  if (roleFile) {
1555
- return {
1556
- systemPrompt:
1557
- event.systemPrompt +
1558
- `\n\n## Your Role: ${currentRole} (from ${config.fileDir}/${currentRole}.md)\n\n${roleFile}`,
1559
- };
692
+ return { systemPrompt: roleFile };
1560
693
  }
1561
694
 
1562
695
  // Fall back to built-in instructions if available
1563
696
  const lower = currentRole.toLowerCase();
1564
697
  const builtin = BUILTIN_INSTRUCTIONS[lower];
1565
698
  if (builtin) {
1566
- return { systemPrompt: event.systemPrompt + builtin };
699
+ return { systemPrompt: builtin };
1567
700
  }
1568
701
 
1569
702
  // No custom file and no builtin -> just use the role name as context
1570
703
  const desc = config.roles[currentRole]?.description;
1571
704
  return {
1572
705
  systemPrompt:
1573
- event.systemPrompt +
1574
706
  `\n\n## Your Role: ${currentRole}\n\nYou are in **${currentRole.toUpperCase()}** mode on branch \`${currentBranch}\`.${desc ? `\n${desc}` : ""}`,
1575
707
  };
1576
708
  });
@@ -1675,7 +807,17 @@ If the user asks you to make changes, tell them to switch to an appropriate bran
1675
807
  block: true,
1676
808
  reason:
1677
809
  "Piping to a shell interpreter is blocked for security. " +
1678
- "Use the `write` or `edit` tool to create or modify files.",
810
+ "Use `execFileSync` or direct commands instead.",
811
+ };
812
+ }
813
+
814
+ // Block curl/wget/fetch to unknown hosts (data exfiltration)
815
+ if (/\b(curl|wget)\s+/.test(cmd)) {
816
+ return {
817
+ block: true,
818
+ reason:
819
+ "`curl` and `wget` are blocked by default. " +
820
+ "Contact the project admin to add an exception for specific hosts.",
1679
821
  };
1680
822
  }
1681
823