@senomas/pi-git-hat 0.2.3 → 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,839 +29,44 @@
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,
45
54
  Text,
46
55
  } from "@earendil-works/pi-tui";
47
56
  import { SearchableSelectList } from "./lib/searchable-select-list.js";
48
-
49
- // -- Types ---------------------------------------------------------
50
-
51
- interface RoleDef {
52
- pattern: string;
53
- description?: string;
54
- ancestorRoles?: string[];
55
- }
56
-
57
- interface RolesConfig {
58
- version?: number;
59
- roles?: Record<string, RoleDef>;
60
- defaultRole?: string;
61
- fileDir?: string;
62
- caseInsensitive?: boolean;
63
-
64
- postSwitchLog?: boolean;
65
- }
66
-
67
- interface MergedConfig {
68
- roles: Record<string, RoleDef>;
69
- fileDir: string;
70
- caseInsensitive: boolean;
71
-
72
- postSwitchLog: boolean;
73
- configFile: string;
74
- }
75
-
76
- // -- Branch history persistence -------------------------------------
77
-
78
- const AGENT_DIR = resolve(os.homedir(), ".pi", "agent");
79
- const BRANCH_HISTORY_FILE = resolve(AGENT_DIR, "role_branch.json");
80
-
81
- /** Directory containing this extension file. Used to resolve bundled roles/ directory. */
82
- const EXTENSION_DIR = dirname(fileURLToPath(import.meta.url));
83
-
84
- interface SwitchLogEntry {
85
- ts: string;
86
- role: string;
87
- branch: string;
88
- }
89
-
90
- interface BranchHistory {
91
- [role: string]: string | undefined;
92
- }
93
-
94
- /** Load branch history from disk. Handles backward compat: old flat format {role: branch}
95
- * is converted to new format with a history array.
96
- * Returns the role→branch mapping and the switch history entries separately. */
97
- function loadBranchHistory(): { mapping: BranchHistory; history: SwitchLogEntry[] } {
98
- try {
99
- const raw = JSON.parse(readFileSync(BRANCH_HISTORY_FILE, "utf-8"));
100
- // Detect new format (has "history" array) vs old format (flat {role: branch})
101
- if (raw && typeof raw === "object" && !Array.isArray(raw) && "history" in raw) {
102
- const { history, ...mapping } = raw;
103
- return {
104
- mapping: mapping as BranchHistory,
105
- history: Array.isArray(history) ? (history as SwitchLogEntry[]) : [],
106
- };
107
- }
108
- // Old format: flat object — wrap into new shape with empty history
109
- return { mapping: raw as BranchHistory, history: [] };
110
- } catch {
111
- return { mapping: {}, history: [] };
112
- }
113
- }
114
-
115
- /** Persist mapping + history to disk. */
116
- async function saveBranchHistory(mapping: BranchHistory, history: SwitchLogEntry[]): Promise<void> {
117
- await mkdir(AGENT_DIR, { recursive: true });
118
- await writeFile(
119
- BRANCH_HISTORY_FILE,
120
- JSON.stringify({ ...mapping, history }, null, 2),
121
- "utf-8",
122
- );
123
- }
124
-
125
- async function recordBranchUsage(role: string, branch: string): Promise<void> {
126
- if (!role || !branch) return;
127
- const { mapping, history } = loadBranchHistory();
128
- mapping[role] = branch;
129
- history.push({ ts: new Date().toISOString(), role, branch });
130
- // Cap at 50 entries (trim oldest)
131
- if (history.length > 50) {
132
- history.splice(0, history.length - 50);
133
- }
134
- await saveBranchHistory(mapping, history);
135
- }
136
-
137
- interface BranchEntry {
138
- branch: string;
139
- role: string;
140
- isCurrent: boolean;
141
- isLastUsed: boolean;
142
- }
143
-
144
- /** List all git branches and match them to roles. */
145
- async function listBranchesByRole(config: MergedConfig, cwd: string): Promise<{
146
- entries: BranchEntry[];
147
- grouped: Record<string, BranchEntry[]>;
148
- roleOrder: string[];
149
- unmatched: string[];
150
- }> {
151
- const { mapping: history } = loadBranchHistory();
152
- const entries: BranchEntry[] = [];
153
- const grouped: Record<string, BranchEntry[]> = {};
154
- const unmatched: string[] = [];
155
-
156
- // Get current branch
157
- let currentBranch: string | null = null;
158
- try {
159
- const stdout = execFileSync("git", ["branch", "--show-current"], {
160
- cwd,
161
- encoding: "utf-8",
162
- }) as string;
163
- currentBranch = stdout.trim() || null;
164
- } catch {
165
- /* not a git repo */
166
- }
167
-
168
- // Get all branches
169
- let branches: string[] = [];
170
- try {
171
- const stdout = execFileSync("git", ["branch", "--format", "%(refname:short)"], {
172
- cwd,
173
- encoding: "utf-8",
174
- }) as string;
175
- branches = stdout.trim().split("\n").filter(Boolean);
176
- } catch {
177
- return { entries, grouped, roleOrder: [], unmatched };
178
- }
179
-
180
- // Match each branch to a role
181
- for (const branch of branches) {
182
- const role = detectRole(branch, config);
183
- if (!role) {
184
- unmatched.push(branch);
185
- continue;
186
- }
187
-
188
- const isCurrent = branch === currentBranch;
189
- const isLastUsed = history[role] === branch;
190
- const entry: BranchEntry = { branch, role, isCurrent, isLastUsed };
191
- entries.push(entry);
192
- if (!grouped[role]) grouped[role] = [];
193
- grouped[role].push(entry);
194
- }
195
-
196
- // Sort within each role: current -> last-used -> rest (alpha)
197
- for (const role of Object.keys(grouped)) {
198
- grouped[role].sort((a, b) => {
199
- if (a.isCurrent) return -1;
200
- if (b.isCurrent) return 1;
201
- if (a.isLastUsed) return -1;
202
- if (b.isLastUsed) return 1;
203
- return a.branch.localeCompare(b.branch);
204
- });
205
- }
206
-
207
- // Role order: roles with current branch first, then last-used, then alpha
208
- const roleOrder = [...new Set(entries.map((e) => e.role))].sort((a, b) => {
209
- const aHasCurrent = grouped[a]?.some((e) => e.isCurrent);
210
- const bHasCurrent = grouped[b]?.some((e) => e.isCurrent);
211
- if (aHasCurrent && !bHasCurrent) return -1;
212
- if (!aHasCurrent && bHasCurrent) return 1;
213
- const aHasLast = grouped[a]?.some((e) => e.isLastUsed);
214
- const bHasLast = grouped[b]?.some((e) => e.isLastUsed);
215
- if (aHasLast && !bHasLast) return -1;
216
- if (!aHasLast && bHasLast) return 1;
217
- return a.localeCompare(b);
218
- });
219
-
220
- return { entries, grouped, roleOrder, unmatched };
221
- }
222
-
223
- // -- Config loading -------------------------------------------------
224
-
225
- const HOME_PI_DIR = resolve(os.homedir(), ".pi");
226
-
227
- function loadConfig(): MergedConfig {
228
- const merged: MergedConfig = {
229
- roles: {},
230
- fileDir: ".pi",
231
- caseInsensitive: true,
232
-
233
- postSwitchLog: true,
234
- };
235
-
236
- // Search paths for roles.json (first found wins):
237
- // 1. {PROJECT_ROOT}/.pi/roles.json (project-specific, inside .pi/)
238
- // 2. ~/.pi/agent/roles.json (global fallback, inside git repo)
239
- const roleFileCandidates = [
240
- resolve(process.cwd(), ".pi", "roles.json"),
241
- resolve(HOME_PI_DIR, "agent", "roles.json"),
242
- ];
243
-
244
- let loadedRoles = false;
245
- for (const candidatePath of roleFileCandidates) {
246
- if (existsSync(candidatePath)) {
247
- try {
248
- const raw = JSON.parse(readFileSync(candidatePath, "utf-8")) as RolesConfig;
249
- if (raw.roles) {
250
- Object.assign(merged.roles, raw.roles);
251
- loadedRoles = true;
252
- }
253
- if (raw.fileDir) merged.fileDir = raw.fileDir;
254
- if (raw.caseInsensitive !== undefined) merged.caseInsensitive = raw.caseInsensitive;
255
-
256
- if (raw.postSwitchLog !== undefined) merged.postSwitchLog = raw.postSwitchLog;
257
- } catch {
258
- // ignore parse errors
259
- }
260
- merged.configFile = candidatePath;
261
- break; // first matching path wins
262
- }
263
- }
264
-
265
- if (!loadedRoles) {
266
- const searched = roleFileCandidates.join(", ");
267
- throw new Error(
268
- `roles.json not found. Searched:\n ${searched}\n\n` +
269
- `Create one at the project root (~/.pi/agent/roles.json) or inside `.pi/` ({project}/.pi/roles.json).`
270
- );
271
- }
272
-
273
- // Walk up from CWD to find .pi-project.json for project overrides
274
- // Project-level roles fully override matching base roles
275
- let dir = process.cwd();
276
- while (true) {
277
- const candidate = resolve(dir, ".pi-project.json");
278
- if (existsSync(candidate)) {
279
- try {
280
- const projectRaw = JSON.parse(readFileSync(candidate, "utf-8")) as RolesConfig;
281
- if (projectRaw.roles) {
282
- for (const [name, def] of Object.entries(projectRaw.roles)) {
283
- merged.roles[name] = def;
284
- }
285
- }
286
- } catch {
287
- // ignore parse errors
288
- }
289
- break;
290
- }
291
- const parent = dirname(dir);
292
- if (parent === dir) break;
293
- dir = parent;
294
- }
295
-
296
- return merged;
297
- }
298
-
299
- /** Detect the role for a given branch name by iterating merged roles. */
300
- function detectRole(branch: string, config: MergedConfig): string | null {
301
- for (const [roleName, roleDef] of Object.entries(config.roles)) {
302
- try {
303
- if (new RegExp(roleDef.pattern).test(branch)) return roleName;
304
- } catch {
305
- // skip invalid regex
306
- }
307
- }
308
- return null; // no match -> "unknown"
309
- }
310
-
311
- // -- File helpers ---------------------------------------------------
312
-
313
- /** Extract NN sequence number from a todo/ or report/ filename. */
314
- function extractNN(filename: string): number | null {
315
- const match = filename.match(/^(\d+)-/);
316
- return match ? parseInt(match[1], 10) : null;
317
- }
318
-
319
- /** Scan todo/ and report/ dirs for the highest NN in use. */
320
- async function findMaxNN(cwd: string): Promise<number> {
321
- let max = 0;
322
- for (const dirName of ["todo", "report"]) {
323
- try {
324
- const entries = await readdir(resolve(cwd, dirName));
325
- for (const entry of entries) {
326
- const nn = extractNN(entry);
327
- if (nn !== null && nn > max) max = nn;
328
- }
329
- } catch {
330
- // dir doesn't exist yet
331
- }
332
- }
333
- return max;
334
- }
335
-
336
- /** Normalise a file path argument: strip leading ./, absolute -> relative. */
337
- function normalisePath(raw: string, cwd: string, cwdAbsolute: string): string {
338
- let path = raw;
339
- if (path.startsWith(cwdAbsolute + "/")) path = path.slice(cwdAbsolute.length + 1);
340
- if (path.startsWith("./")) path = path.slice(2);
341
- return path;
342
- }
343
-
344
- /** Check if a path is inside one of the given directories. */
345
- function isInside(path: string, dirs: string[]): boolean {
346
- return dirs.some((d) => path === d || path.startsWith(d + "/"));
347
- }
348
-
349
- /**
350
- * Load a role's .md file from the project's fileDir directory.
351
- * Case-insensitive lookup if config.caseInsensitive is true.
352
- */
353
- async function loadRoleFile(
354
- cwd: string,
355
- role: string,
356
- config: MergedConfig,
357
- ): Promise<string | null> {
358
- // 1. Project-local: {cwd}/{fileDir}/{role}.md (highest priority)
359
- const roleDir = resolve(cwd, config.fileDir);
360
- const exactPath = resolve(roleDir, `${role}.md`);
361
- try {
362
- return await readFile(exactPath, "utf8");
363
- } catch {
364
- // not found with exact case
365
- }
366
-
367
- if (config.caseInsensitive) {
368
- try {
369
- const entries = await readdir(roleDir);
370
- for (const entry of entries) {
371
- if (entry.toLowerCase() === `${role}.md`) {
372
- return await readFile(resolve(roleDir, entry), "utf8");
373
- }
374
- }
375
- } catch {
376
- // roleDir doesn't exist
377
- }
378
- }
379
-
380
- // 2. Extension-bundled: {extensionDir}/roles/{role}.md (fallback)
381
- const bundledRoleDir = resolve(EXTENSION_DIR, "roles");
382
- const bundledExact = resolve(bundledRoleDir, `${role}.md`);
383
- try {
384
- return await readFile(bundledExact, "utf8");
385
- } catch {
386
- // not found with exact case
387
- }
388
-
389
- if (config.caseInsensitive) {
390
- try {
391
- const entries = await readdir(bundledRoleDir);
392
- for (const entry of entries) {
393
- if (entry.toLowerCase() === `${role}.md`) {
394
- return await readFile(resolve(bundledRoleDir, entry), "utf8");
395
- }
396
- }
397
- } catch {
398
- // bundledRoleDir doesn't exist
399
- }
400
- }
401
-
402
- return null;
403
- }
404
-
405
- // -- Built-in role instructions (fallback when no .md file exists) ---
406
- // Canonical source: roles/{role}.md in the extension directory.
407
- // Run `python3 scripts/validate-role-prompts.py` to verify alignment.
408
-
409
- const BUILTIN_INSTRUCTIONS: Record<string, string> = {
410
- planner: `
411
-
412
- ## Your Role: Planner
413
-
414
- You are **PLANNER**. Your sole responsibility is to research (just collecting data for others to solved the problem) and create
415
- \`todo/NN-name.md\` files. That is it. Switching branch does not switch your role.
416
-
417
- ## What you do
418
- 1. Research with \`read\` / \`grep\` / \`find\` / \`ls\`
419
- 2. Create \`todo/NN-name.md\` with sequenced, actionable items
420
- 3. Present the plan to the user
421
-
422
- ## What you do NOT do
423
- - \`edit\`/\`write\` any file outside \`todo/\`
424
- - Switch branches (the user handles that)
425
- - Implement anything (that's the implementor)
426
- - Write reports (that's the implementor)
427
-
428
- ## File format: \`todo/NN-name.md\`
429
- - \`- [ ]\` pending, \`- [x]\` done
430
- - First line after checkbox = **header**
431
- - Indented lines = **body**
432
- - Blank lines separate items
433
-
434
- ## NN sequence rule
435
- Every \`todo/NN-name.md\` must use NN **higher** than the highest NN in both
436
- \`todo/\` **and** \`report/\`.
437
- `,
438
-
439
- implementor: `
440
-
441
- ## Your Role: Implementor
442
-
443
- 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.
444
-
445
- ## Report format: \`report/NN-name.md\`
446
- - Same NN and same headers as the todo
447
- - Add implementation notes, decisions, and \`\`\`bash results
448
-
449
- ## How to implement
450
- 1. Read \`todo/NN-name.md\` (lowest NN first)
451
- 2. If \`todo/NN-name.detail.md\` exists, read it too
452
- 3. Implement each \`- [ ]\` item using \`edit\`/\`write\`
453
- 4. After each item (or batch), update \`report/NN-name.md\`
454
- 5. When done, move to the next NN
455
-
456
- ## What you do NOT do
457
- - Switch branches (the user handles that)
458
- - Write to \`todo/\`, \`plan/\`, or \`.pi/\` — create reports only; let others verify and mark items done
459
- - Create todo files (that's the planner's job)
460
- - Modify or mark items in todo files (that's the reviewer's job after verification)
461
- `,
462
-
463
- reviewer: `
464
-
465
- ## Your Role: Reviewer
466
-
467
- 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.
468
-
469
- ## What you do
470
- 1. Read \`todo/NN-name.md\` and \`report/NN-name.md\`
471
- 2. Match each pending \`- [ ]\` todo item against a section in the report
472
- 3. **Covered** → mark \`- [x]\` in the todo file
473
- 4. **Missing** → leave \`- [ ]\`, tell the user
474
- 5. Also check for orphan reports and stale todos
475
-
476
- ## What you do NOT do
477
- - \`edit\`/\`write\` any file outside \`todo/\`
478
- - Write reports (that's the implementor)
479
- - Create \`todo/\` or \`plan/\` files (that's the planner)
480
- - Switch branches (the user handles that)
481
- - Write to \`.pi/\`
482
-
483
- ## Key rules
484
- - **Covered** items get \`- [x]\` in the todo file
485
- - **Missing** items stay \`- [ ]\` — explain why to the user
486
- - Read source files and \`review/\` and \`todo/\` as needed to verify
487
- `,
488
-
489
- admin: `
490
-
491
- ## Your Role: Admin
492
-
493
- You are **ADMIN**. Switching branch does not switch your role.
494
-
495
- ## What you do
496
- - Edit \`.pi/\` files — role prompts, roles.json
497
- - Edit \`README.md\` and \`AGENTS.md\`
498
- - Explore the codebase (read-only)
499
-
500
- ## What you do NOT do
501
- - Edit source code
502
- - Create or modify \`todo/\`, \`plan/\`, \`report/\` files
503
- - Switch branches (the user handles that)
504
- `,
505
-
506
- researcher: `
507
-
508
- ## Your Role: Researcher
509
-
510
- You are **RESEARCHER**. Switching branch does not switch your role.
511
-
512
- ## What you do
513
- - Research with \`find\` / \`grep\` / \`ls\` / \`web_search\` / \`read\`
514
- - Write findings to \`docs/*.md\` and root \`*.md\` files
515
-
516
- ## What you do NOT do
517
- - Edit source code
518
- - Create or modify \`todo/\`, \`plan/\`, \`report/\` files
519
- - Switch branches (the user handles that)
520
- `,
521
- };
522
-
523
- // -- Shared: scan todos and cross-reference with reports -----------
524
-
525
- interface PendingItem {
526
- lineno: number;
527
- header: string;
528
- covered: boolean;
529
- }
530
-
531
- interface TodoFile {
532
- file: string;
533
- pending: PendingItem[];
534
- reportExists: boolean;
535
- }
536
-
537
- async function scanTodos(cwd: string): Promise<{
538
- files: TodoFile[];
539
- totalPending: number;
540
- totalCovered: number;
541
- summary: string;
542
- }> {
543
- const files: TodoFile[] = [];
544
- let totalPending = 0;
545
- let totalCovered = 0;
546
-
547
- try {
548
- const entries = await readdir(resolve(cwd, "todo"));
549
- for (const entry of entries.sort()) {
550
- if (!entry.endsWith(".md") || entry.endsWith(".detail.md")) continue;
551
- const content = await readFile(resolve(cwd, "todo", entry), "utf8");
552
- const pending: PendingItem[] = [];
553
- for (const [i, line] of content.split("\n").entries()) {
554
- const m = line.match(/^- \[ \] (.+)$/);
555
- if (m) pending.push({ lineno: i + 1, header: m[1].trim(), covered: false });
556
- }
557
- if (pending.length === 0) continue;
558
-
559
- let reportHeaders: string[] = [];
560
- try {
561
- const reportContent = await readFile(resolve(cwd, "report", entry), "utf8");
562
- for (const line of reportContent.split("\n")) {
563
- const m = line.match(/^- (.+)$/);
564
- if (m && !line.includes("[ ]") && !line.includes("[x]")) {
565
- reportHeaders.push(m[1].trim().toLowerCase());
566
- }
567
- }
568
- } catch {
569
- // no matching report
570
- }
571
-
572
- const reportExists = reportHeaders.length > 0;
573
- for (const p of pending) {
574
- p.covered = reportHeaders.some(
575
- (h) => p.header.toLowerCase().includes(h) || h.includes(p.header.toLowerCase()),
576
- );
577
- if (p.covered) totalCovered++;
578
- }
579
- totalPending += pending.length;
580
- files.push({ file: entry, pending, reportExists });
581
- }
582
- } catch {
583
- // todo/ doesn"t exist
584
- }
585
-
586
- const displayLines: string[] = [];
587
- for (const f of files) {
588
- const tag = f.reportExists ? "" : " (no report)";
589
- displayLines.push(`todo/${f.file}${tag}`);
590
- for (const p of f.pending) {
591
- displayLines.push(` ${p.covered ? "\u2705" : "\u274C"} line ${p.lineno}: ${p.header}`);
592
- }
593
- }
594
- displayLines.push(
595
- `\u2500\u2500 ${totalPending - totalCovered} missing \u00B7 ${totalCovered} covered \u00B7 ${totalPending} total \u2500\u2500`,
596
- );
597
-
598
- return { files, totalPending, totalCovered, summary: displayLines.join("\n") };
599
- }
600
-
601
- /** Handle /hat todo display: scan todos and send follow-up for pending items. */
602
- /** Ancestry check: verify current branch descends from ancestor role branches. */
603
- async function verifyAncestry(config: MergedConfig, cwd: string): Promise<{
604
- ok: boolean;
605
- violations: { role: string; branch: string; currentBranch: string }[];
606
- }> {
607
- const violations: { role: string; branch: string; currentBranch: string }[] = [];
608
-
609
- let currentBranch: string;
610
- try {
611
- currentBranch = execFileSync("git", ["branch", "--show-current"], {
612
- cwd,
613
- encoding: "utf-8",
614
- }).trim();
615
- if (!currentBranch) return { ok: true, violations }; // not on a branch (detached)
616
- } catch {
617
- return { ok: true, violations }; // not a git repo
618
- }
619
-
620
- const role = detectRole(currentBranch, config);
621
- if (!role) return { ok: true, violations }; // unknown role
622
-
623
- const ancestorRoles = config.roles[role]?.ancestorRoles;
624
- if (!ancestorRoles || ancestorRoles.length === 0) return { ok: true, violations }; // no ancestors required
625
-
626
- // Get all local branches
627
- let allBranches: string[];
628
- try {
629
- const stdout = execFileSync("git", ["branch", "--format", "%(refname:short)"], {
630
- cwd,
631
- encoding: "utf-8",
632
- });
633
- allBranches = stdout.trim().split("\n").filter(Boolean);
634
- } catch {
635
- return { ok: true, violations };
636
- }
637
-
638
- for (const ancestorRole of ancestorRoles) {
639
- const pattern = config.roles[ancestorRole]?.pattern;
640
- if (!pattern) continue;
641
-
642
- const regex = new RegExp(pattern);
643
- const candidates = allBranches.filter((b) => regex.test(b));
644
-
645
- for (const candidate of candidates) {
646
- if (candidate === currentBranch) continue; // same branch, trivially ancestor
647
-
648
- try {
649
- execFileSync("git", ["merge-base", "--is-ancestor", candidate, currentBranch], {
650
- cwd,
651
- stdio: "pipe",
652
- });
653
- // exit code 0 = is ancestor, no violation
654
- } catch {
655
- // exit code 1 or error = not ancestor, or deleted branch — record violation
656
- violations.push({ role: ancestorRole, branch: candidate, currentBranch });
657
- }
658
- }
659
- }
660
-
661
- return { ok: violations.length === 0, violations };
662
- }
663
-
664
- /** Check if master/main is an ancestor of the current branch. */
665
- async function isMasterOrMainAncestor(cwd: string): Promise<{ ok: boolean; branch: string }> {
666
- let currentBranch: string;
667
- try {
668
- currentBranch = execFileSync("git", ["branch", "--show-current"], {
669
- cwd,
670
- encoding: "utf-8",
671
- }).trim();
672
- if (!currentBranch) return { ok: true, branch: "master" }; // detached
673
- } catch {
674
- return { ok: true, branch: "master" }; // not a git repo
675
- }
676
-
677
- // try master first, then main
678
- for (const candidate of ["master", "main"]) {
679
- try {
680
- execFileSync("git", ["rev-parse", "--verify", `refs/heads/${candidate}`], {
681
- cwd,
682
- stdio: "pipe",
683
- });
684
- // branch exists
685
- if (currentBranch === candidate) return { ok: true, branch: candidate }; // already on it
686
- try {
687
- execFileSync("git", ["merge-base", "--is-ancestor", candidate, currentBranch], {
688
- cwd,
689
- stdio: "pipe",
690
- });
691
- return { ok: true, branch: candidate }; // is ancestor
692
- } catch {
693
- return { ok: false, branch: candidate }; // not ancestor
694
- }
695
- } catch {
696
- continue; // branch doesn't exist locally, try next
697
- }
698
- }
699
-
700
- // neither master nor main exists locally — skip check
701
- return { ok: true, branch: "master" };
702
- }
703
-
704
- async function handleTodo(
705
- pi: ExtensionAPI,
706
- ctx: { cwd: string; ui: { notify: (msg: string, level: string) => void } },
707
- ): Promise<void> {
708
- // plan/*.md files are excluded from /hat todo — they store future ideas,
709
- // not actionable todos. scanTodos() already reads only from the todo/ directory.
710
- const result = await scanTodos(ctx.cwd);
711
- if (result.files.length === 0) {
712
- ctx.ui.notify("\uD83D\uDCED No pending todos found", "info");
713
- return;
714
- }
715
- ctx.ui.notify(result.summary, "info");
716
-
717
- if (result.totalPending - result.totalCovered > 0) {
718
- const detail = result.files
719
- .map((f) => {
720
- const items = f.pending
721
- .map((p) => ` ${p.covered ? "\u2705" : "\u274C"} ${p.header}`)
722
- .join("\n");
723
- return `todo/${f.file}${f.reportExists ? "" : " (no report)"}\n${items}`;
724
- })
725
- .join("\n");
726
- pi.sendUserMessage(
727
- `Analyze these pending todos against their reports:\n\n${detail}\n\n` +
728
- `For each \u274C item, check if it"s truly not implemented yet or if the report ` +
729
- `just uses different wording. If it"s genuinely missing, suggest next steps.`,
730
- { deliverAs: "followUp" },
731
- );
732
- }
733
- }
734
-
735
- /** Role icon for status bar display. */
736
- function roleIcon(role: string): string {
737
- const lower = role.toLowerCase();
738
- if (lower === "planner") return "\uD83D\uDCCB";
739
- if (lower === "implementor") return "\uD83D\uDEE0";
740
- if (lower === "reviewer") return "\uD83D\uDD0D";
741
- if (lower === "admin") return "\u2699";
742
- if (lower === "researcher") return "\uD83D\uDD2C";
743
- return "\uD83E\uDDE2";
744
- }
745
-
746
- // -- ANSI color palette for git ref log decoration ----------------
747
-
748
- const REF_COLORS = [
749
- "38;5;51", // cyan
750
- "38;5;118", // green
751
- "38;5;226", // yellow
752
- "38;5;207", // magenta
753
- "38;5;196", // red
754
- "38;5;75", // blue
755
- "38;5;214", // orange
756
- "38;5;201", // purple
757
- ];
758
-
759
- const HEAD_ANSI = "1;37"; // bold bright white
760
-
761
- /**
762
- * Colorize ref names in `git log --oneline --decorate` output.
763
- * Parses `(ref-list)` patterns and wraps each ref in distinct ANSI
764
- * 256-color codes. HEAD gets a fixed bold white. All other refs
765
- * (branches, tags) rotate through an 8-color palette consistently.
766
- */
767
- function colorizeLog(log: string): string {
768
- // First pass: collect all unique ref names for consistent color assignment
769
- const refNames = new Set<string>();
770
- const refListRe = /\(([^)]+)\)/g;
771
- let m: RegExpExecArray | null;
772
- while ((m = refListRe.exec(log)) !== null) {
773
- for (const part of m[1].split(/,\s*/)) {
774
- const trimmed = part
775
- .replace(/^(HEAD -> |HEAD\b|tag: |tags: )/g, "")
776
- .trim();
777
- if (trimmed) refNames.add(trimmed);
778
- }
779
- }
780
-
781
- // Assign a rotating color to each unique ref name
782
- const colorMap = new Map<string, string>();
783
- let idx = 0;
784
- for (const name of refNames) {
785
- colorMap.set(name, REF_COLORS[idx % REF_COLORS.length]);
786
- idx++;
787
- }
788
-
789
- // Second pass: wrap each ref in the `(...)` list with ANSI codes
790
- return log
791
- .split("\n")
792
- .map((line) => {
793
- return line.replace(
794
- /\(([^)]+)\)/g,
795
- (_, refList: string) => {
796
- const colored = refList
797
- .split(/,\s*/)
798
- .map((ref: string) => {
799
- // "HEAD -> branchname"
800
- const headArrow = ref.match(/^(HEAD)\s*->\s*(.+)$/);
801
- if (headArrow) {
802
- const head = `\x1b[${HEAD_ANSI}mHEAD\x1b[0m`;
803
- const branchColor = colorMap.get(headArrow[2]) || REF_COLORS[0];
804
- const branch = `\x1b[${branchColor}m${headArrow[2]}\x1b[0m`;
805
- return `${head} -> ${branch}`;
806
- }
807
- // bare HEAD (detached)
808
- if (ref === "HEAD") {
809
- return `\x1b[${HEAD_ANSI}mHEAD\x1b[0m`;
810
- }
811
- // "tag: tagname"
812
- const tagMatch = ref.match(/^tag:\s*(.+)$/);
813
- if (tagMatch) {
814
- const color = colorMap.get(tagMatch[1]) || REF_COLORS[0];
815
- return `\x1b[${color}m${ref}\x1b[0m`;
816
- }
817
- // plain ref (branch, remote)
818
- const color = colorMap.get(ref) || REF_COLORS[0];
819
- return `\x1b[${color}m${ref}\x1b[0m`;
820
- })
821
- .join(", ");
822
- return `(${colored})`;
823
- },
824
- );
825
- })
826
- .join("\n");
827
- }
828
-
829
- // -- Shared git log helper -----------------------------------------
830
-
831
- /**
832
- * Run a colored git log and display it in the TUI.
833
- * Shared between /hatl and the post-switch log in /hat.
834
- */
835
- async function showGitLog(
836
- ctx: { ui: { notify: (msg: string, level: string) => void } },
837
- count: number = 10,
838
- ): Promise<void> {
839
- const validCount = Number.isFinite(count) && count > 0 ? count : 10;
840
-
841
- try {
842
- const result = await import("child_process").then((cp) =>
843
- cp.execFileSync("git", [
844
- "log",
845
- "--graph",
846
- "--oneline",
847
- "--decorate",
848
- "--all",
849
- `-${validCount}`,
850
- ], { encoding: "utf-8" }),
851
- ) as string;
852
-
853
- const raw = result.trim();
854
- if (!raw) {
855
- return; // silently ignore empty log
856
- }
857
-
858
- const colored = colorizeLog(raw);
859
- const footer = `\n\x1b[90m\u2022 /hatl N for more lines\x1b[0m`;
860
- ctx.ui.notify(colored + footer, "info");
861
- } catch {
862
- // non-critical — silently ignore
863
- }
864
- }
57
+ import {
58
+ testRegex,
59
+ evaluateToolRules,
60
+ } from "./lib/tool-enforcement.js";
61
+
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";
865
70
 
866
71
  // -- Extension -----------------------------------------------------
867
72
 
@@ -871,6 +76,9 @@ export default function (pi: ExtensionAPI) {
871
76
  let config: MergedConfig = loadConfig();
872
77
  let cwdAbsolute = "";
873
78
 
79
+ // -- Wrapper around pi.exec for lib functions that accept ExecFunction --
80
+ const execFn: ExecFunction = (cmd, args) => pi.exec(cmd, args);
81
+
874
82
  // -- Helpers -------------------------------------------------
875
83
 
876
84
  async function detectBranch(): Promise<string | null> {
@@ -943,31 +151,6 @@ export default function (pi: ExtensionAPI) {
943
151
  return;
944
152
  }
945
153
 
946
- // /hat log: show last 5 switch history entries (most recent first)
947
- if (sub === "log") {
948
- const { history } = loadBranchHistory();
949
- if (history.length === 0) {
950
- ctx.ui.notify("No switches recorded yet", "info");
951
- return;
952
- }
953
- const entries = history.slice(-5).reverse();
954
- const lines = entries.map((e) => {
955
- const ts = new Date(e.ts);
956
- const now = new Date();
957
- const diffMs = now.getTime() - ts.getTime();
958
- const diffSec = Math.floor(diffMs / 1000);
959
- let relative: string;
960
- if (diffSec < 60) relative = `${diffSec}s ago`;
961
- else if (diffSec < 3600) relative = `${Math.floor(diffSec / 60)}m ago`;
962
- else if (diffSec < 86400) relative = `${Math.floor(diffSec / 3600)}h ago`;
963
- else relative = `${Math.floor(diffSec / 86400)}d ago`;
964
- const iso = ts.toISOString().replace("T", " ").slice(0, 19);
965
- return `[${e.role}] ${e.branch} @ ${iso} (${relative})`;
966
- });
967
- ctx.ui.notify(lines.join("\n"), "info");
968
- return;
969
- }
970
-
971
154
  // /hat info: show role status
972
155
  if (sub === "info") {
973
156
  const desc = currentRole ? config.roles[currentRole]?.description ?? "" : "";
@@ -978,10 +161,16 @@ export default function (pi: ExtensionAPI) {
978
161
  ? `${roleIcon(currentRole)} ${currentRole}${usingFile}`
979
162
  : "\u2753 No matching role (read-only mode)";
980
163
 
164
+ const defaultInfo = [];
165
+ if (config.preTool) defaultInfo.push(`pre-tool: ${config.preTool.length} rule(s)`);
166
+ if (config.postTool) defaultInfo.push(`post-tool: ${config.postTool.length} rule(s)`);
167
+ const defaultLine = defaultInfo.length > 0 ? `default: ${defaultInfo.join(", ")}\n` : "";
168
+
981
169
  ctx.ui.notify(
982
170
  `${roleDisplay}\n` +
983
171
  `branch: ${currentBranch ?? "not a git repo"}\n` +
984
172
  `${desc ? `desc: ${desc}\n` : ""}` +
173
+ `${defaultLine}` +
985
174
  `roles: ${config.configFile}`,
986
175
  "info",
987
176
  );
@@ -1046,19 +235,15 @@ export default function (pi: ExtensionAPI) {
1046
235
  noMatch: (t) => theme.fg("warning", t),
1047
236
  });
1048
237
 
1049
- selectList.onSelect = (item) => {
1050
- if (item.value.startsWith("__header_")) return; // ignore headers
1051
- done(item.value);
1052
- };
1053
- selectList.onCancel = () => done(null);
1054
-
1055
238
  container.addChild(selectList);
1056
239
 
1057
240
  container.addChild(
1058
241
  new Text(
1059
- theme.fg("dim", "\u2191\u2193 navigate \u2022 enter switch \u2022 esc cancel \u2022 type to search"),
1060
- 1,
1061
- 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,
1062
247
  ),
1063
248
  );
1064
249
 
@@ -1066,6 +251,11 @@ export default function (pi: ExtensionAPI) {
1066
251
  new DynamicBorder((s: string) => theme.fg("accent", s)),
1067
252
  );
1068
253
 
254
+ selectList.onSelect = (item) => {
255
+ done(item.value);
256
+ };
257
+ selectList.onCancel = () => done(null);
258
+
1069
259
  return {
1070
260
  render: (w) => container.render(w),
1071
261
  invalidate: () => container.invalidate(),
@@ -1078,13 +268,20 @@ export default function (pi: ExtensionAPI) {
1078
268
  { overlay: true },
1079
269
  );
1080
270
 
271
+ if (!result) {
272
+ ctx.ui.notify("Branch switch cancelled.", "info");
273
+ return;
274
+ }
275
+
276
+ // Switch to selected branch
1081
277
  if (result) {
1082
- // Require confirmation before switching branches
1083
- if (ctx.hasUI) {
1084
- const confirmed = await ctx.ui.confirm(`Switch to branch "${result}"?`);
1085
- if (!confirmed) {
1086
- ctx.ui.notify("Switch cancelled", "info");
1087
- 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
1088
285
  }
1089
286
  }
1090
287
  try {
@@ -1094,7 +291,7 @@ export default function (pi: ExtensionAPI) {
1094
291
  await updateRole(ctx); // updateRole handles history logging via change detection
1095
292
  // Show colored git log after switch (opt-out via roles.json postSwitchLog: false)
1096
293
  if (config.postSwitchLog) {
1097
- await showGitLog(ctx, 10);
294
+ await showGitLog(ctx, 10, execFn);
1098
295
  }
1099
296
  } else {
1100
297
  ctx.ui.notify(`\x1b[31mFailed to switch: ${switchResult.stderr}\x1b[0m`, "error");
@@ -1109,42 +306,80 @@ export default function (pi: ExtensionAPI) {
1109
306
  // -- /hatt command (alias for /hat todo) ----------------------
1110
307
 
1111
308
  pi.registerCommand("hatt", {
1112
- description: "Ancestry check + list pending todo items",
309
+ description: "Check ancestry + list todos",
1113
310
  handler: async (_args, ctx) => {
1114
- // Ancestry check first (blocking check)
311
+ // Check ancestry first
1115
312
  const ancestry = await verifyAncestry(config, ctx.cwd);
1116
-
1117
313
  if (!ancestry.ok) {
1118
314
  const lines = ancestry.violations.map(
1119
- (v) => ` \u274C ${v.branch} (${v.role}) run: git rebase ${v.branch}`,
315
+ (v) => ` \u274C ${v.branch}: not ancestor of ${v.currentBranch}`,
1120
316
  );
1121
317
  ctx.ui.notify(
1122
- `\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.`,
1123
320
  "warning",
1124
321
  );
1125
- return; // block further processing until ancestry is fixed
322
+ return;
1126
323
  }
1127
324
 
1128
325
  await handleTodo(pi, ctx);
1129
-
1130
- ctx.ui.notify("\u2705 Ancestry check passed", "info");
1131
326
  },
1132
327
  });
1133
328
 
1134
- // -- /hatl command: colored git log ---------------------------
329
+ // -- /hatl command: colored git log + separator + git status --
1135
330
 
1136
331
  pi.registerCommand("hatl", {
1137
- description: "Show colored git log with branch graph",
332
+ description: "Show colored git log + git status. /hatl N for N lines (default 10).",
1138
333
  handler: async (args, ctx) => {
1139
- const count = args?.trim() ? parseInt(args.trim(), 10) : 10;
1140
- 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
+ }
1141
376
  },
1142
377
  });
1143
378
 
1144
- // -- /hatr command: rebase onto most recent role-matched branch --
379
+ // -- /hatr command: TUI branch selector for rebase ------------------
1145
380
 
1146
381
  pi.registerCommand("hatr", {
1147
- description: "Rebase current branch onto the most recent role-matched branch",
382
+ description: "TUI branch selector for rebase",
1148
383
  handler: async (_args, ctx) => {
1149
384
  // Edge case: detached HEAD or non-git directory
1150
385
  const branch = await detectBranch();
@@ -1209,13 +444,90 @@ export default function (pi: ExtensionAPI) {
1209
444
  return;
1210
445
  }
1211
446
 
1212
- // Pick the most recent
447
+ // Sort by commit timestamp descending (most recent first)
1213
448
  infos.sort((a, b) => b.timestamp - a.timestamp);
1214
- const target = infos[0];
1215
449
 
1216
- // Show and confirm
450
+ // Require TUI mode
451
+ if (ctx.mode !== "tui") {
452
+ ctx.ui.notify("/hatr requires TUI mode", "warning");
453
+ return;
454
+ }
455
+
456
+ // Build select items with: "branchname <abbrev> subject"
457
+ const selectItems: SelectItem[] = infos.map((c) => {
458
+ const maxSubjectLen = 50;
459
+ const subject =
460
+ c.subject.length > maxSubjectLen
461
+ ? c.subject.slice(0, maxSubjectLen) + "…"
462
+ : c.subject;
463
+ return {
464
+ value: c.branch,
465
+ label: `${c.branch} \x1b[90m${c.abbrev} ${subject}\x1b[0m`,
466
+ };
467
+ });
468
+
469
+ const result = await ctx.ui.custom<string | null>(
470
+ (tui, theme, _kb, done) => {
471
+ const container = new Container();
472
+
473
+ container.addChild(
474
+ new DynamicBorder((s: string) => theme.fg("accent", s)),
475
+ );
476
+
477
+ container.addChild(
478
+ new Text(theme.fg("accent", theme.bold("Rebase Branch")), 1, 0),
479
+ );
480
+
481
+ const selectList = new SearchableSelectList(selectItems, Math.min(selectItems.length, 20), {
482
+ selectedPrefix: (t) => theme.fg("accent", t),
483
+ selectedText: (t) => theme.fg("accent", t),
484
+ description: (t) => theme.fg("muted", t),
485
+ scrollInfo: (t) => theme.fg("dim", t),
486
+ noMatch: (t) => theme.fg("warning", t),
487
+ });
488
+
489
+ selectList.onSelect = (item) => {
490
+ if (item.value.startsWith("__header_")) return;
491
+ done(item.value);
492
+ };
493
+ selectList.onCancel = () => done(null);
494
+
495
+ container.addChild(selectList);
496
+
497
+ container.addChild(
498
+ new Text(
499
+ theme.fg("dim", "\u2191\u2193 navigate \u2022 enter select \u2022 esc cancel \u2022 type to search"),
500
+ 1,
501
+ 0,
502
+ ),
503
+ );
504
+
505
+ container.addChild(
506
+ new DynamicBorder((s: string) => theme.fg("accent", s)),
507
+ );
508
+
509
+ return {
510
+ render: (w) => container.render(w),
511
+ invalidate: () => container.invalidate(),
512
+ handleInput: (data) => {
513
+ selectList.handleInput(data);
514
+ tui.requestRender();
515
+ },
516
+ };
517
+ },
518
+ { overlay: true },
519
+ );
520
+
521
+ if (!result) {
522
+ ctx.ui.notify("Rebase cancelled.", "info");
523
+ return;
524
+ }
525
+
526
+ const selectedInfo = infos.find((c) => c.branch === result)!;
527
+
528
+ // Confirm before rebasing
1217
529
  const confirmed = await ctx.ui.confirm(
1218
- `Rebase ${currentBranch} onto ${target.branch}? (latest commit: ${target.abbrev} ${target.subject})`,
530
+ `Rebase ${currentBranch} onto ${result}? (${selectedInfo.abbrev} ${selectedInfo.subject})`,
1219
531
  );
1220
532
  if (!confirmed) {
1221
533
  ctx.ui.notify("Rebase cancelled.", "info");
@@ -1224,22 +536,23 @@ export default function (pi: ExtensionAPI) {
1224
536
 
1225
537
  // Run the rebase
1226
538
  try {
1227
- const result = await pi.exec("git", ["rebase", target.branch]);
1228
- if (result.code === 0) {
539
+ const rebaseResult = await pi.exec("git", ["rebase", result]);
540
+ if (rebaseResult.code === 0) {
1229
541
  ctx.ui.notify(
1230
- `Rebase onto ${target.branch} succeeded. Updated commit tree:`,
542
+ `Rebase onto ${result} succeeded. Updated commit tree:`,
1231
543
  "info",
1232
544
  );
1233
- await showGitLog(ctx, 10);
545
+ await showGitLog(ctx, 10, execFn);
546
+ await showGitStatus(ctx, currentBranch, execFn);
1234
547
  } else {
1235
548
  ctx.ui.notify(
1236
- `Rebase onto ${target.branch} failed:\n${result.stderr}\n\nResolve conflicts manually, then run \`git rebase --continue\`.`,
549
+ `Rebase onto ${result} failed:\n${rebaseResult.stderr}\n\nResolve conflicts manually, then run \`git rebase --continue\`.`,
1237
550
  "error",
1238
551
  );
1239
552
  }
1240
553
  } catch (e) {
1241
554
  ctx.ui.notify(
1242
- `Rebase onto ${target.branch} failed: ${(e as Error).message}\n\nResolve conflicts manually, then run \`git rebase --continue\`.`,
555
+ `Rebase onto ${result} failed: ${(e as Error).message}\n\nResolve conflicts manually, then run \`git rebase --continue\`.`,
1243
556
  "error",
1244
557
  );
1245
558
  }
@@ -1248,7 +561,7 @@ export default function (pi: ExtensionAPI) {
1248
561
 
1249
562
  // -- Session lifecycle ----------------------------------------
1250
563
 
1251
- pi.on("session_start", async (event, ctx) => {"}]}
564
+ pi.on("session_start", async (event, ctx) => {
1252
565
  cwdAbsolute = ctx.cwd;
1253
566
 
1254
567
  // Seed bundled roles/ files to project .pi/ before loading config
@@ -1312,10 +625,16 @@ export default function (pi: ExtensionAPI) {
1312
625
  ? `${roleIcon(currentRole)} ${currentRole}${usingFile}`
1313
626
  : "\u2753 No matching role (read-only mode)";
1314
627
 
628
+ const defaultInfo = [];
629
+ if (config.preTool) defaultInfo.push(`pre-tool: ${config.preTool.length} rule(s)`);
630
+ if (config.postTool) defaultInfo.push(`post-tool: ${config.postTool.length} rule(s)`);
631
+ const defaultLine = defaultInfo.length > 0 ? `default: ${defaultInfo.join(", ")}\n` : "";
632
+
1315
633
  ctx.ui.notify(
1316
634
  `${roleDisplay}\n` +
1317
635
  `branch: ${currentBranch ?? "not a git repo"}\n` +
1318
636
  `${desc ? `desc: ${desc}\n` : ""}` +
637
+ `${defaultLine}` +
1319
638
  `roles: ${config.configFile}`,
1320
639
  "info",
1321
640
  );
@@ -1343,7 +662,6 @@ export default function (pi: ExtensionAPI) {
1343
662
  if (!currentRole || !currentBranch) {
1344
663
  return {
1345
664
  systemPrompt:
1346
- event.systemPrompt +
1347
665
  `
1348
666
 
1349
667
  ## Restricted Mode: Read-Only
@@ -1364,28 +682,27 @@ If the user asks you to make changes, tell them to switch to an appropriate bran
1364
682
  };
1365
683
  }
1366
684
 
1367
- // 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);
1368
687
  const roleFile = await loadRoleFile(ctx.cwd, currentRole, config);
688
+ if (defaultFile && roleFile) {
689
+ return { systemPrompt: roleFile + "\n\n" + defaultFile };
690
+ }
1369
691
  if (roleFile) {
1370
- return {
1371
- systemPrompt:
1372
- event.systemPrompt +
1373
- `\n\n## Your Role: ${currentRole} (from ${config.fileDir}/${currentRole}.md)\n\n${roleFile}`,
1374
- };
692
+ return { systemPrompt: roleFile };
1375
693
  }
1376
694
 
1377
695
  // Fall back to built-in instructions if available
1378
696
  const lower = currentRole.toLowerCase();
1379
697
  const builtin = BUILTIN_INSTRUCTIONS[lower];
1380
698
  if (builtin) {
1381
- return { systemPrompt: event.systemPrompt + builtin };
699
+ return { systemPrompt: builtin };
1382
700
  }
1383
701
 
1384
702
  // No custom file and no builtin -> just use the role name as context
1385
703
  const desc = config.roles[currentRole]?.description;
1386
704
  return {
1387
705
  systemPrompt:
1388
- event.systemPrompt +
1389
706
  `\n\n## Your Role: ${currentRole}\n\nYou are in **${currentRole.toUpperCase()}** mode on branch \`${currentBranch}\`.${desc ? `\n${desc}` : ""}`,
1390
707
  };
1391
708
  });
@@ -1475,6 +792,164 @@ If the user asks you to make changes, tell them to switch to an appropriate bran
1475
792
  }
1476
793
  }
1477
794
 
795
+ // Config-driven tool rules: evaluate bash commands
796
+ if (isBash) {
797
+ const lowerRole = currentRole.toLowerCase();
798
+ const roleDef = config.roles[lowerRole] ?? config.roles[currentRole];
799
+ const rawCmd = ((event.input as { command?: string }).command ?? "").trim();
800
+ const cmd = stripCdPrefix(rawCmd, ctx.cwd, cwdAbsolute);
801
+
802
+ // -- Hardcoded safety guards (not overridable by roles.json) --
803
+
804
+ // Block pipe to any shell interpreter (arbitrary code execution vector)
805
+ if (/\|\s*(sh|bash|zsh|fish|dash|ksh|tcsh|csh)\b/.test(cmd)) {
806
+ return {
807
+ block: true,
808
+ reason:
809
+ "Piping to a shell interpreter is blocked for security. " +
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.",
821
+ };
822
+ }
823
+
824
+ // Check output redirect to files via > or >> (write operation via bash)
825
+ const redirectMatch = cmd.match(/[\s>](>|>>)\s*([\w\/~.$].*)$/);
826
+ if (redirectMatch) {
827
+ const target = redirectMatch[2];
828
+ // Allow redirects to /tmp/ with confirmation (scratch space)
829
+ if (/^\/tmp\//.test(target)) {
830
+ const confirmed = await ctx.ui.confirm(
831
+ `Write to \`${target}\` via redirect? Confirm?`,
832
+ );
833
+ if (!confirmed) {
834
+ return { block: true, reason: "Redirect to /tmp cancelled." };
835
+ }
836
+ } else {
837
+ // All other redirect targets are blocked unconditionally
838
+ return {
839
+ block: true,
840
+ reason:
841
+ `Output redirect to \`${target}\` is blocked for security. ` +
842
+ `Use the \`write\` or \`edit\` tool instead. ` +
843
+ `Redirects to \`/tmp/...\` are allowed with confirmation.`,
844
+ };
845
+ }
846
+ }
847
+
848
+ // 1. Default pre-tool rules (evaluated before role-specific rules)
849
+ let matchedAny = false;
850
+ if (config.preTool) {
851
+ const result = evaluateToolRules(config.preTool, cmd);
852
+ if (result.matched) {
853
+ matchedAny = true;
854
+ if (!result.allowed) {
855
+ return {
856
+ block: true,
857
+ reason: result.reason ?? `Command \`${cmd}\` blocked by default pre-tool rule.`,
858
+ };
859
+ }
860
+ if (result.confirm) {
861
+ const confirmed = await ctx.ui.confirm(
862
+ result.reason ?? `Run bash command \`${cmd}\` (default pre-tool check)?`,
863
+ );
864
+ if (!confirmed) {
865
+ return { block: true, reason: "Command cancelled by user." };
866
+ }
867
+ }
868
+ }
869
+ }
870
+
871
+ // 2. Role-specific tool rules
872
+ if (roleDef?.tool !== undefined) {
873
+ const result = evaluateToolRules(roleDef.tool, cmd);
874
+ if (result.matched) {
875
+ matchedAny = true;
876
+ if (!result.allowed) {
877
+ return {
878
+ block: true,
879
+ reason: result.reason ?? `Command \`${cmd}\` blocked by tool rule for ${currentRole}.`,
880
+ };
881
+ }
882
+ if (result.confirm) {
883
+ const confirmed = await ctx.ui.confirm(
884
+ result.reason ?? `Run bash command \`${cmd}\` as ${currentRole}?`,
885
+ );
886
+ if (!confirmed) {
887
+ return { block: true, reason: "Command cancelled by user." };
888
+ }
889
+ }
890
+ }
891
+ }
892
+
893
+ // 3. Default post-tool rules (evaluated after role-specific rules pass)
894
+ if (config.postTool) {
895
+ for (const rule of config.postTool) {
896
+ if (!testRegex(rule.regex, cmd)) continue;
897
+ matchedAny = true;
898
+ if (rule.type === "block") {
899
+ return {
900
+ block: true,
901
+ reason: rule.reason ?? `Command \`${cmd}\` blocked by default post-tool rule.`,
902
+ };
903
+ }
904
+ if (rule.type === "confirm") {
905
+ const confirmed = await ctx.ui.confirm(
906
+ rule.reason ?? `Run bash command \`${cmd}\` (default post-tool check)?`,
907
+ );
908
+ if (!confirmed) {
909
+ return { block: true, reason: "Command cancelled by user." };
910
+ }
911
+ }
912
+ // "allow" → no-op, continue past post-tool
913
+ }
914
+ }
915
+
916
+ // 4. Role-specific post-tool rules (evaluated after default post-tool)
917
+ if (roleDef?.postTool) {
918
+ for (const rule of roleDef.postTool) {
919
+ if (!testRegex(rule.regex, cmd)) continue;
920
+ matchedAny = true;
921
+ if (rule.type === "block") {
922
+ return {
923
+ block: true,
924
+ reason: rule.reason ?? `Command \`${cmd}\` blocked by role post-tool rule for ${currentRole}.`,
925
+ };
926
+ }
927
+ if (rule.type === "confirm") {
928
+ const confirmed = await ctx.ui.confirm(
929
+ rule.reason ?? `Run bash command \`${cmd}\` (${currentRole} post-tool check)?`,
930
+ );
931
+ if (!confirmed) {
932
+ return { block: true, reason: "Command cancelled by user." };
933
+ }
934
+ }
935
+ // "allow" → no-op, continue
936
+ }
937
+ }
938
+
939
+ // 5. Safe default: if no rule matched in any stage, require confirmation
940
+ if (!matchedAny) {
941
+ const confirmed = await ctx.ui.confirm(
942
+ `Command \`${cmd}\` not covered by any tool rule. Confirm execution?`,
943
+ );
944
+ if (!confirmed) {
945
+ return { block: true, reason: "Command cancelled by user." };
946
+ }
947
+ }
948
+
949
+ // Tool rules passed (or no tool rules defined): allow bash
950
+ return;
951
+ }
952
+
1478
953
  // Known roles: only intercept edit/write
1479
954
  if (!isWrite) return;
1480
955
 
@@ -1482,21 +957,44 @@ If the user asks you to make changes, tell them to switch to an appropriate bran
1482
957
  const path = normalisePath(rawPath, ctx.cwd, cwdAbsolute);
1483
958
 
1484
959
  const lowerRole = currentRole.toLowerCase();
960
+ const roleDef = config.roles[lowerRole] ?? config.roles[currentRole];
961
+
962
+ // Note: edit/write skip tool rules entirely — they use writablePaths instead.
963
+
964
+ // -- Config-driven writable path enforcement ---------------------
965
+
966
+ // Planner NN sequence validation (architectural guard — always applies)
967
+ if (lowerRole === "planner" && path.startsWith("todo/")) {
968
+ const nn = extractNN(path.replace("todo/", ""));
969
+ if (nn !== null && nn <= (await findMaxNN(ctx.cwd))) {
970
+ return {
971
+ block: true,
972
+ reason: `\uD83D\uDCCB Planner: NN must be > max in todo/ and report/. You used ${String(nn).padStart(2, "0")}. Use ${String((await findMaxNN(ctx.cwd)) + 1).padStart(2, "0")} or higher.`,
973
+ };
974
+ }
975
+ }
976
+
977
+ // Config-driven writablePaths: if defined and non-empty, use it
978
+ if (roleDef?.writablePaths !== undefined && roleDef.writablePaths.length > 0) {
979
+ if (isWritablePath(path, roleDef.writablePaths)) return;
980
+ const allowed = roleDef.writablePaths
981
+ .map((e) =>
982
+ e.path === ""
983
+ ? `*.${e.extension}`
984
+ : `${e.path}/${e.extension ? `*.${e.extension}` : "*"}`,
985
+ )
986
+ .join(", ");
987
+ return {
988
+ block: true,
989
+ reason: `\uD83D\uDD12 ${currentRole}: can only write to ${allowed}. Blocked: ${rawPath}`,
990
+ };
991
+ }
992
+
993
+ // Fallback: hardcoded per-role path restrictions
1485
994
 
1486
995
  // Planner: only todo/, plan/, docs/*.md
1487
996
  if (lowerRole === "planner") {
1488
- if (isInside(path, ["todo", "plan", "docs"])) {
1489
- if (path.startsWith("todo/")) {
1490
- const nn = extractNN(path.replace("todo/", ""));
1491
- if (nn !== null && nn <= (await findMaxNN(ctx.cwd))) {
1492
- return {
1493
- block: true,
1494
- reason: `\uD83D\uDCCB Planner: NN must be > max in todo/ and report/. You used ${String(nn).padStart(2, "0")}. Use ${String((await findMaxNN(ctx.cwd)) + 1).padStart(2, "0")} or higher.`,
1495
- };
1496
- }
1497
- }
1498
- return;
1499
- }
997
+ if (isInside(path, ["todo", "plan", "docs"])) return;
1500
998
  return {
1501
999
  block: true,
1502
1000
  reason: `\uD83D\uDCCB Planner: can only write to todo/, plan/, and docs/*.md. Blocked: ${rawPath}`,