@senomas/pi-git-hat 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +50 -0
- package/git-hat.ts +1456 -0
- package/lib/searchable-select-list.ts +112 -0
- package/package.json +24 -0
- package/roles/admin.md +13 -0
- package/roles/implementor.md +20 -0
- package/roles/planner.md +25 -0
- package/roles/reviewer.md +22 -0
- package/roles/roles.json +8 -0
- package/scripts/validate-role-prompts.py +149 -0
package/git-hat.ts
ADDED
|
@@ -0,0 +1,1456 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* git-hat: Wear different hats by switching git branches
|
|
3
|
+
*
|
|
4
|
+
* -- Config-driven (replaces hardcoded patterns) --
|
|
5
|
+
*
|
|
6
|
+
* Search order for roles.json:
|
|
7
|
+
* 1. {PROJECT_ROOT}/.pi/roles.json (project-specific, inside .pi/)
|
|
8
|
+
* 2. ~/.pi/agent/roles.json (global fallback, inside git repo)
|
|
9
|
+
* If none found, built-in fallback patterns are used.
|
|
10
|
+
*
|
|
11
|
+
* Overrides: .pi-project.json roles (project-specific, overrides per-role)
|
|
12
|
+
*
|
|
13
|
+
* Resolution:
|
|
14
|
+
* 1. .pi-project.json roles (project override, highest priority)
|
|
15
|
+
* 2. roles.json (base definitions)
|
|
16
|
+
* 3. no match -> read-only + branch-switching only
|
|
17
|
+
*
|
|
18
|
+
* Role prompts: .pi/{role}.md (case-insensitive, loaded per detected role)
|
|
19
|
+
* Seeded from {extensionDir}/roles/ on startup (bundled with the extension)
|
|
20
|
+
*
|
|
21
|
+
* Commands:
|
|
22
|
+
* /hat - TUI branch selector: pick a branch grouped by role (with history);
|
|
23
|
+
* shows colored git log after a successful branch switch
|
|
24
|
+
* /hat info - show current role and branch info
|
|
25
|
+
* /hat todo - list pending items across todo/ files
|
|
26
|
+
* /hatt - ancestry check + todo listing (blocks if upstream branches missing)
|
|
27
|
+
* /hatl - show colored git log with branch graph
|
|
28
|
+
*
|
|
29
|
+
* Post-switch log can be disabled by setting `"postSwitchLog": false` in roles.json
|
|
30
|
+
*
|
|
31
|
+
* Inspired by "wearing a different hat" -- architect hat vs builder hat.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
35
|
+
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";
|
|
42
|
+
import {
|
|
43
|
+
Container,
|
|
44
|
+
type SelectItem,
|
|
45
|
+
Text,
|
|
46
|
+
} from "@earendil-works/pi-tui";
|
|
47
|
+
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
|
+
redetectOnInput?: boolean;
|
|
64
|
+
postSwitchLog?: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface MergedConfig {
|
|
68
|
+
roles: Record<string, RoleDef>;
|
|
69
|
+
fileDir: string;
|
|
70
|
+
caseInsensitive: boolean;
|
|
71
|
+
redetectOnInput: boolean;
|
|
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
|
+
redetectOnInput: true,
|
|
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
|
+
if (raw.redetectOnInput !== undefined) merged.redetectOnInput = raw.redetectOnInput;
|
|
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
|
+
|
|
507
|
+
// -- Shared: scan todos and cross-reference with reports -----------
|
|
508
|
+
|
|
509
|
+
interface PendingItem {
|
|
510
|
+
lineno: number;
|
|
511
|
+
header: string;
|
|
512
|
+
covered: boolean;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
interface TodoFile {
|
|
516
|
+
file: string;
|
|
517
|
+
pending: PendingItem[];
|
|
518
|
+
reportExists: boolean;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async function scanTodos(cwd: string): Promise<{
|
|
522
|
+
files: TodoFile[];
|
|
523
|
+
totalPending: number;
|
|
524
|
+
totalCovered: number;
|
|
525
|
+
summary: string;
|
|
526
|
+
}> {
|
|
527
|
+
const files: TodoFile[] = [];
|
|
528
|
+
let totalPending = 0;
|
|
529
|
+
let totalCovered = 0;
|
|
530
|
+
|
|
531
|
+
try {
|
|
532
|
+
const entries = await readdir(resolve(cwd, "todo"));
|
|
533
|
+
for (const entry of entries.sort()) {
|
|
534
|
+
if (!entry.endsWith(".md") || entry.endsWith(".detail.md")) continue;
|
|
535
|
+
const content = await readFile(resolve(cwd, "todo", entry), "utf8");
|
|
536
|
+
const pending: PendingItem[] = [];
|
|
537
|
+
for (const [i, line] of content.split("\n").entries()) {
|
|
538
|
+
const m = line.match(/^- \[ \] (.+)$/);
|
|
539
|
+
if (m) pending.push({ lineno: i + 1, header: m[1].trim(), covered: false });
|
|
540
|
+
}
|
|
541
|
+
if (pending.length === 0) continue;
|
|
542
|
+
|
|
543
|
+
let reportHeaders: string[] = [];
|
|
544
|
+
try {
|
|
545
|
+
const reportContent = await readFile(resolve(cwd, "report", entry), "utf8");
|
|
546
|
+
for (const line of reportContent.split("\n")) {
|
|
547
|
+
const m = line.match(/^- (.+)$/);
|
|
548
|
+
if (m && !line.includes("[ ]") && !line.includes("[x]")) {
|
|
549
|
+
reportHeaders.push(m[1].trim().toLowerCase());
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
} catch {
|
|
553
|
+
// no matching report
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const reportExists = reportHeaders.length > 0;
|
|
557
|
+
for (const p of pending) {
|
|
558
|
+
p.covered = reportHeaders.some(
|
|
559
|
+
(h) => p.header.toLowerCase().includes(h) || h.includes(p.header.toLowerCase()),
|
|
560
|
+
);
|
|
561
|
+
if (p.covered) totalCovered++;
|
|
562
|
+
}
|
|
563
|
+
totalPending += pending.length;
|
|
564
|
+
files.push({ file: entry, pending, reportExists });
|
|
565
|
+
}
|
|
566
|
+
} catch {
|
|
567
|
+
// todo/ doesn"t exist
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const displayLines: string[] = [];
|
|
571
|
+
for (const f of files) {
|
|
572
|
+
const tag = f.reportExists ? "" : " (no report)";
|
|
573
|
+
displayLines.push(`todo/${f.file}${tag}`);
|
|
574
|
+
for (const p of f.pending) {
|
|
575
|
+
displayLines.push(` ${p.covered ? "\u2705" : "\u274C"} line ${p.lineno}: ${p.header}`);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
displayLines.push(
|
|
579
|
+
`\u2500\u2500 ${totalPending - totalCovered} missing \u00B7 ${totalCovered} covered \u00B7 ${totalPending} total \u2500\u2500`,
|
|
580
|
+
);
|
|
581
|
+
|
|
582
|
+
return { files, totalPending, totalCovered, summary: displayLines.join("\n") };
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** Handle /hat todo display: scan todos and send follow-up for pending items. */
|
|
586
|
+
/** Ancestry check: verify current branch descends from ancestor role branches. */
|
|
587
|
+
async function verifyAncestry(config: MergedConfig, cwd: string): Promise<{
|
|
588
|
+
ok: boolean;
|
|
589
|
+
violations: { role: string; branch: string; currentBranch: string }[];
|
|
590
|
+
}> {
|
|
591
|
+
const violations: { role: string; branch: string; currentBranch: string }[] = [];
|
|
592
|
+
|
|
593
|
+
let currentBranch: string;
|
|
594
|
+
try {
|
|
595
|
+
currentBranch = execFileSync("git", ["branch", "--show-current"], {
|
|
596
|
+
cwd,
|
|
597
|
+
encoding: "utf-8",
|
|
598
|
+
}).trim();
|
|
599
|
+
if (!currentBranch) return { ok: true, violations }; // not on a branch (detached)
|
|
600
|
+
} catch {
|
|
601
|
+
return { ok: true, violations }; // not a git repo
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const role = detectRole(currentBranch, config);
|
|
605
|
+
if (!role) return { ok: true, violations }; // unknown role
|
|
606
|
+
|
|
607
|
+
const ancestorRoles = config.roles[role]?.ancestorRoles;
|
|
608
|
+
if (!ancestorRoles || ancestorRoles.length === 0) return { ok: true, violations }; // no ancestors required
|
|
609
|
+
|
|
610
|
+
// Get all local branches
|
|
611
|
+
let allBranches: string[];
|
|
612
|
+
try {
|
|
613
|
+
const stdout = execFileSync("git", ["branch", "--format", "%(refname:short)"], {
|
|
614
|
+
cwd,
|
|
615
|
+
encoding: "utf-8",
|
|
616
|
+
});
|
|
617
|
+
allBranches = stdout.trim().split("\n").filter(Boolean);
|
|
618
|
+
} catch {
|
|
619
|
+
return { ok: true, violations };
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
for (const ancestorRole of ancestorRoles) {
|
|
623
|
+
const pattern = config.roles[ancestorRole]?.pattern;
|
|
624
|
+
if (!pattern) continue;
|
|
625
|
+
|
|
626
|
+
const regex = new RegExp(pattern);
|
|
627
|
+
const candidates = allBranches.filter((b) => regex.test(b));
|
|
628
|
+
|
|
629
|
+
for (const candidate of candidates) {
|
|
630
|
+
if (candidate === currentBranch) continue; // same branch, trivially ancestor
|
|
631
|
+
|
|
632
|
+
try {
|
|
633
|
+
execFileSync("git", ["merge-base", "--is-ancestor", candidate, currentBranch], {
|
|
634
|
+
cwd,
|
|
635
|
+
stdio: "pipe",
|
|
636
|
+
});
|
|
637
|
+
// exit code 0 = is ancestor, no violation
|
|
638
|
+
} catch {
|
|
639
|
+
// exit code 1 or error = not ancestor, or deleted branch — record violation
|
|
640
|
+
violations.push({ role: ancestorRole, branch: candidate, currentBranch });
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
return { ok: violations.length === 0, violations };
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** Check if master/main is an ancestor of the current branch. */
|
|
649
|
+
async function isMasterOrMainAncestor(cwd: string): Promise<{ ok: boolean; branch: string }> {
|
|
650
|
+
let currentBranch: string;
|
|
651
|
+
try {
|
|
652
|
+
currentBranch = execFileSync("git", ["branch", "--show-current"], {
|
|
653
|
+
cwd,
|
|
654
|
+
encoding: "utf-8",
|
|
655
|
+
}).trim();
|
|
656
|
+
if (!currentBranch) return { ok: true, branch: "master" }; // detached
|
|
657
|
+
} catch {
|
|
658
|
+
return { ok: true, branch: "master" }; // not a git repo
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// try master first, then main
|
|
662
|
+
for (const candidate of ["master", "main"]) {
|
|
663
|
+
try {
|
|
664
|
+
execFileSync("git", ["rev-parse", "--verify", `refs/heads/${candidate}`], {
|
|
665
|
+
cwd,
|
|
666
|
+
stdio: "pipe",
|
|
667
|
+
});
|
|
668
|
+
// branch exists
|
|
669
|
+
if (currentBranch === candidate) return { ok: true, branch: candidate }; // already on it
|
|
670
|
+
try {
|
|
671
|
+
execFileSync("git", ["merge-base", "--is-ancestor", candidate, currentBranch], {
|
|
672
|
+
cwd,
|
|
673
|
+
stdio: "pipe",
|
|
674
|
+
});
|
|
675
|
+
return { ok: true, branch: candidate }; // is ancestor
|
|
676
|
+
} catch {
|
|
677
|
+
return { ok: false, branch: candidate }; // not ancestor
|
|
678
|
+
}
|
|
679
|
+
} catch {
|
|
680
|
+
continue; // branch doesn't exist locally, try next
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// neither master nor main exists locally — skip check
|
|
685
|
+
return { ok: true, branch: "master" };
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
async function handleTodo(
|
|
689
|
+
pi: ExtensionAPI,
|
|
690
|
+
ctx: { cwd: string; ui: { notify: (msg: string, level: string) => void } },
|
|
691
|
+
): Promise<void> {
|
|
692
|
+
// plan/*.md files are excluded from /hat todo — they store future ideas,
|
|
693
|
+
// not actionable todos. scanTodos() already reads only from the todo/ directory.
|
|
694
|
+
const result = await scanTodos(ctx.cwd);
|
|
695
|
+
if (result.files.length === 0) {
|
|
696
|
+
ctx.ui.notify("\uD83D\uDCED No pending todos found", "info");
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
ctx.ui.notify(result.summary, "info");
|
|
700
|
+
|
|
701
|
+
if (result.totalPending - result.totalCovered > 0) {
|
|
702
|
+
const detail = result.files
|
|
703
|
+
.map((f) => {
|
|
704
|
+
const items = f.pending
|
|
705
|
+
.map((p) => ` ${p.covered ? "\u2705" : "\u274C"} ${p.header}`)
|
|
706
|
+
.join("\n");
|
|
707
|
+
return `todo/${f.file}${f.reportExists ? "" : " (no report)"}\n${items}`;
|
|
708
|
+
})
|
|
709
|
+
.join("\n");
|
|
710
|
+
pi.sendUserMessage(
|
|
711
|
+
`Analyze these pending todos against their reports:\n\n${detail}\n\n` +
|
|
712
|
+
`For each \u274C item, check if it"s truly not implemented yet or if the report ` +
|
|
713
|
+
`just uses different wording. If it"s genuinely missing, suggest next steps.`,
|
|
714
|
+
{ deliverAs: "followUp" },
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/** Role icon for status bar display. */
|
|
720
|
+
function roleIcon(role: string): string {
|
|
721
|
+
const lower = role.toLowerCase();
|
|
722
|
+
if (lower === "planner") return "\uD83D\uDCCB";
|
|
723
|
+
if (lower === "implementor") return "\uD83D\uDEE0";
|
|
724
|
+
if (lower === "reviewer") return "\uD83D\uDD0D";
|
|
725
|
+
if (lower === "admin") return "\u2699";
|
|
726
|
+
return "\uD83E\uDDE2";
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// -- ANSI color palette for git ref log decoration ----------------
|
|
730
|
+
|
|
731
|
+
const REF_COLORS = [
|
|
732
|
+
"38;5;51", // cyan
|
|
733
|
+
"38;5;118", // green
|
|
734
|
+
"38;5;226", // yellow
|
|
735
|
+
"38;5;207", // magenta
|
|
736
|
+
"38;5;196", // red
|
|
737
|
+
"38;5;75", // blue
|
|
738
|
+
"38;5;214", // orange
|
|
739
|
+
"38;5;201", // purple
|
|
740
|
+
];
|
|
741
|
+
|
|
742
|
+
const HEAD_ANSI = "1;37"; // bold bright white
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Colorize ref names in `git log --oneline --decorate` output.
|
|
746
|
+
* Parses `(ref-list)` patterns and wraps each ref in distinct ANSI
|
|
747
|
+
* 256-color codes. HEAD gets a fixed bold white. All other refs
|
|
748
|
+
* (branches, tags) rotate through an 8-color palette consistently.
|
|
749
|
+
*/
|
|
750
|
+
function colorizeLog(log: string): string {
|
|
751
|
+
// First pass: collect all unique ref names for consistent color assignment
|
|
752
|
+
const refNames = new Set<string>();
|
|
753
|
+
const refListRe = /\(([^)]+)\)/g;
|
|
754
|
+
let m: RegExpExecArray | null;
|
|
755
|
+
while ((m = refListRe.exec(log)) !== null) {
|
|
756
|
+
for (const part of m[1].split(/,\s*/)) {
|
|
757
|
+
const trimmed = part
|
|
758
|
+
.replace(/^(HEAD -> |HEAD\b|tag: |tags: )/g, "")
|
|
759
|
+
.trim();
|
|
760
|
+
if (trimmed) refNames.add(trimmed);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// Assign a rotating color to each unique ref name
|
|
765
|
+
const colorMap = new Map<string, string>();
|
|
766
|
+
let idx = 0;
|
|
767
|
+
for (const name of refNames) {
|
|
768
|
+
colorMap.set(name, REF_COLORS[idx % REF_COLORS.length]);
|
|
769
|
+
idx++;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// Second pass: wrap each ref in the `(...)` list with ANSI codes
|
|
773
|
+
return log
|
|
774
|
+
.split("\n")
|
|
775
|
+
.map((line) => {
|
|
776
|
+
return line.replace(
|
|
777
|
+
/\(([^)]+)\)/g,
|
|
778
|
+
(_, refList: string) => {
|
|
779
|
+
const colored = refList
|
|
780
|
+
.split(/,\s*/)
|
|
781
|
+
.map((ref: string) => {
|
|
782
|
+
// "HEAD -> branchname"
|
|
783
|
+
const headArrow = ref.match(/^(HEAD)\s*->\s*(.+)$/);
|
|
784
|
+
if (headArrow) {
|
|
785
|
+
const head = `\x1b[${HEAD_ANSI}mHEAD\x1b[0m`;
|
|
786
|
+
const branchColor = colorMap.get(headArrow[2]) || REF_COLORS[0];
|
|
787
|
+
const branch = `\x1b[${branchColor}m${headArrow[2]}\x1b[0m`;
|
|
788
|
+
return `${head} -> ${branch}`;
|
|
789
|
+
}
|
|
790
|
+
// bare HEAD (detached)
|
|
791
|
+
if (ref === "HEAD") {
|
|
792
|
+
return `\x1b[${HEAD_ANSI}mHEAD\x1b[0m`;
|
|
793
|
+
}
|
|
794
|
+
// "tag: tagname"
|
|
795
|
+
const tagMatch = ref.match(/^tag:\s*(.+)$/);
|
|
796
|
+
if (tagMatch) {
|
|
797
|
+
const color = colorMap.get(tagMatch[1]) || REF_COLORS[0];
|
|
798
|
+
return `\x1b[${color}m${ref}\x1b[0m`;
|
|
799
|
+
}
|
|
800
|
+
// plain ref (branch, remote)
|
|
801
|
+
const color = colorMap.get(ref) || REF_COLORS[0];
|
|
802
|
+
return `\x1b[${color}m${ref}\x1b[0m`;
|
|
803
|
+
})
|
|
804
|
+
.join(", ");
|
|
805
|
+
return `(${colored})`;
|
|
806
|
+
},
|
|
807
|
+
);
|
|
808
|
+
})
|
|
809
|
+
.join("\n");
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// -- Shared git log helper -----------------------------------------
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Run a colored git log and display it in the TUI.
|
|
816
|
+
* Shared between /hatl and the post-switch log in /hat.
|
|
817
|
+
*/
|
|
818
|
+
async function showGitLog(
|
|
819
|
+
ctx: { ui: { notify: (msg: string, level: string) => void } },
|
|
820
|
+
count: number = 10,
|
|
821
|
+
): Promise<void> {
|
|
822
|
+
const validCount = Number.isFinite(count) && count > 0 ? count : 10;
|
|
823
|
+
|
|
824
|
+
try {
|
|
825
|
+
const result = await import("child_process").then((cp) =>
|
|
826
|
+
cp.execFileSync("git", [
|
|
827
|
+
"log",
|
|
828
|
+
"--graph",
|
|
829
|
+
"--oneline",
|
|
830
|
+
"--decorate",
|
|
831
|
+
"--all",
|
|
832
|
+
`-${validCount}`,
|
|
833
|
+
], { encoding: "utf-8" }),
|
|
834
|
+
) as string;
|
|
835
|
+
|
|
836
|
+
const raw = result.trim();
|
|
837
|
+
if (!raw) {
|
|
838
|
+
return; // silently ignore empty log
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
const colored = colorizeLog(raw);
|
|
842
|
+
const footer = `\n\x1b[90m\u2022 /hatl N for more lines\x1b[0m`;
|
|
843
|
+
ctx.ui.notify(colored + footer, "info");
|
|
844
|
+
} catch {
|
|
845
|
+
// non-critical — silently ignore
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// -- Extension -----------------------------------------------------
|
|
850
|
+
|
|
851
|
+
export default function (pi: ExtensionAPI) {
|
|
852
|
+
let currentBranch: string | null = null;
|
|
853
|
+
let currentRole: string | null = null; // null = no match (read-only)
|
|
854
|
+
let config: MergedConfig = loadConfig();
|
|
855
|
+
let cwdAbsolute = "";
|
|
856
|
+
|
|
857
|
+
// -- Helpers -------------------------------------------------
|
|
858
|
+
|
|
859
|
+
async function detectBranch(): Promise<string | null> {
|
|
860
|
+
try {
|
|
861
|
+
const result = await pi.exec("git", ["branch", "--show-current"]);
|
|
862
|
+
const branch = result.stdout.trim();
|
|
863
|
+
if (branch.length > 0) return branch;
|
|
864
|
+
} catch {
|
|
865
|
+
return null;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// Detached HEAD — check for in-progress rebase
|
|
869
|
+
try {
|
|
870
|
+
const gitDirResult = await pi.exec("git", ["rev-parse", "--git-dir"]);
|
|
871
|
+
const gitDir = gitDirResult.stdout.trim();
|
|
872
|
+
const rebaseHead = resolve(gitDir, "rebase-merge", "head-name");
|
|
873
|
+
const content = await readFile(rebaseHead, "utf8");
|
|
874
|
+
const ref = content.trim();
|
|
875
|
+
const branchName = ref.replace("refs/heads/", "");
|
|
876
|
+
return branchName || null;
|
|
877
|
+
} catch {
|
|
878
|
+
return null;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
async function updateRole(ctx?: {
|
|
883
|
+
ui: { setStatus: (key: string, value: string | undefined) => void };
|
|
884
|
+
}): Promise<void> {
|
|
885
|
+
// Save old state for change detection
|
|
886
|
+
const oldBranch = currentBranch;
|
|
887
|
+
const oldRole = currentRole;
|
|
888
|
+
|
|
889
|
+
const branch = await detectBranch();
|
|
890
|
+
currentBranch = branch;
|
|
891
|
+
config = loadConfig();
|
|
892
|
+
currentRole = branch ? detectRole(branch, config) : null;
|
|
893
|
+
|
|
894
|
+
// Only log on actual branch or role change
|
|
895
|
+
if (currentRole && currentBranch &&
|
|
896
|
+
(currentBranch !== oldBranch || currentRole !== oldRole)) {
|
|
897
|
+
await recordBranchUsage(currentRole, currentBranch);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
if (ctx) {
|
|
901
|
+
const label = currentBranch
|
|
902
|
+
? currentRole
|
|
903
|
+
? `${roleIcon(currentRole)} ${currentRole} (${currentBranch})`
|
|
904
|
+
: `\u2753 no-role (${currentBranch})`
|
|
905
|
+
: "no-git";
|
|
906
|
+
ctx.ui.setStatus("git-hat", label);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function getEffectiveRole(): string | null {
|
|
911
|
+
return currentRole;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// -- /hat command ---------------------------------------------
|
|
915
|
+
|
|
916
|
+
pi.registerCommand("hat", {
|
|
917
|
+
description: "TUI branch selector. /hat info | /hat todo | /hat log",
|
|
918
|
+
handler: async (args, ctx) => {
|
|
919
|
+
const sub = args?.trim().toLowerCase();
|
|
920
|
+
|
|
921
|
+
// /hat todo: show pending vs report coverage
|
|
922
|
+
// Note: plan/*.md files are excluded — they store future ideas, not actionable todos.
|
|
923
|
+
// scanTodos() already reads only from the todo/ directory.
|
|
924
|
+
if (sub === "todo") {
|
|
925
|
+
await handleTodo(pi, ctx);
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// /hat log: show last 5 switch history entries (most recent first)
|
|
930
|
+
if (sub === "log") {
|
|
931
|
+
const { history } = loadBranchHistory();
|
|
932
|
+
if (history.length === 0) {
|
|
933
|
+
ctx.ui.notify("No switches recorded yet", "info");
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
const entries = history.slice(-5).reverse();
|
|
937
|
+
const lines = entries.map((e) => {
|
|
938
|
+
const ts = new Date(e.ts);
|
|
939
|
+
const now = new Date();
|
|
940
|
+
const diffMs = now.getTime() - ts.getTime();
|
|
941
|
+
const diffSec = Math.floor(diffMs / 1000);
|
|
942
|
+
let relative: string;
|
|
943
|
+
if (diffSec < 60) relative = `${diffSec}s ago`;
|
|
944
|
+
else if (diffSec < 3600) relative = `${Math.floor(diffSec / 60)}m ago`;
|
|
945
|
+
else if (diffSec < 86400) relative = `${Math.floor(diffSec / 3600)}h ago`;
|
|
946
|
+
else relative = `${Math.floor(diffSec / 86400)}d ago`;
|
|
947
|
+
const iso = ts.toISOString().replace("T", " ").slice(0, 19);
|
|
948
|
+
return `[${e.role}] ${e.branch} @ ${iso} (${relative})`;
|
|
949
|
+
});
|
|
950
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// /hat info: show role status
|
|
955
|
+
if (sub === "info") {
|
|
956
|
+
const desc = currentRole ? config.roles[currentRole]?.description ?? "" : "";
|
|
957
|
+
const roleFile = currentRole ? await loadRoleFile(ctx.cwd, currentRole, config) : null;
|
|
958
|
+
const usingFile = roleFile ? ` | using ${config.fileDir}/${currentRole}.md` : "";
|
|
959
|
+
|
|
960
|
+
const roleDisplay = currentRole
|
|
961
|
+
? `${roleIcon(currentRole)} ${currentRole}${usingFile}`
|
|
962
|
+
: "\u2753 No matching role (read-only mode)";
|
|
963
|
+
|
|
964
|
+
ctx.ui.notify(
|
|
965
|
+
`${roleDisplay}\n` +
|
|
966
|
+
`branch: ${currentBranch ?? "not a git repo"}\n` +
|
|
967
|
+
`${desc ? `desc: ${desc}\n` : ""}` +
|
|
968
|
+
`roles: ${config.configFile}`,
|
|
969
|
+
"info",
|
|
970
|
+
);
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// /hat: TUI role/branch selector (default, no args)
|
|
975
|
+
if (ctx.mode !== "tui") {
|
|
976
|
+
ctx.ui.notify("/hat requires TUI mode", "warning");
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
const { grouped, roleOrder, unmatched } = await listBranchesByRole(config, ctx.cwd);
|
|
981
|
+
|
|
982
|
+
// Build flat items for SelectList, grouped by role (no role headers)
|
|
983
|
+
const selectItems: SelectItem[] = [];
|
|
984
|
+
for (const role of roleOrder) {
|
|
985
|
+
const entries = grouped[role] || [];
|
|
986
|
+
for (const entry of entries) {
|
|
987
|
+
const suffix = entry.isCurrent
|
|
988
|
+
? " \u2190 current"
|
|
989
|
+
: entry.isLastUsed
|
|
990
|
+
? " \u2190 last"
|
|
991
|
+
: "";
|
|
992
|
+
selectItems.push({
|
|
993
|
+
value: entry.branch,
|
|
994
|
+
label: `${entry.branch}${suffix}`,
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// Add unmatched branches (at bottom, no header)
|
|
1000
|
+
for (const branch of unmatched) {
|
|
1001
|
+
selectItems.push({
|
|
1002
|
+
value: branch,
|
|
1003
|
+
label: branch,
|
|
1004
|
+
});
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
if (selectItems.length === 0) {
|
|
1008
|
+
ctx.ui.notify("No branches match any role pattern", "info");
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
const result = await ctx.ui.custom<string | null>(
|
|
1013
|
+
(tui, theme, _kb, done) => {
|
|
1014
|
+
const container = new Container();
|
|
1015
|
+
|
|
1016
|
+
container.addChild(
|
|
1017
|
+
new DynamicBorder((s: string) => theme.fg("accent", s)),
|
|
1018
|
+
);
|
|
1019
|
+
|
|
1020
|
+
container.addChild(
|
|
1021
|
+
new Text(theme.fg("accent", theme.bold("Switch Branch")), 1, 0),
|
|
1022
|
+
);
|
|
1023
|
+
|
|
1024
|
+
const selectList = new SearchableSelectList(selectItems, Math.min(selectItems.length, 20), {
|
|
1025
|
+
selectedPrefix: (t) => theme.fg("accent", t),
|
|
1026
|
+
selectedText: (t) => theme.fg("accent", t),
|
|
1027
|
+
description: (t) => theme.fg("muted", t),
|
|
1028
|
+
scrollInfo: (t) => theme.fg("dim", t),
|
|
1029
|
+
noMatch: (t) => theme.fg("warning", t),
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
selectList.onSelect = (item) => {
|
|
1033
|
+
if (item.value.startsWith("__header_")) return; // ignore headers
|
|
1034
|
+
done(item.value);
|
|
1035
|
+
};
|
|
1036
|
+
selectList.onCancel = () => done(null);
|
|
1037
|
+
|
|
1038
|
+
container.addChild(selectList);
|
|
1039
|
+
|
|
1040
|
+
container.addChild(
|
|
1041
|
+
new Text(
|
|
1042
|
+
theme.fg("dim", "\u2191\u2193 navigate \u2022 enter switch \u2022 esc cancel \u2022 type to search"),
|
|
1043
|
+
1,
|
|
1044
|
+
0,
|
|
1045
|
+
),
|
|
1046
|
+
);
|
|
1047
|
+
|
|
1048
|
+
container.addChild(
|
|
1049
|
+
new DynamicBorder((s: string) => theme.fg("accent", s)),
|
|
1050
|
+
);
|
|
1051
|
+
|
|
1052
|
+
return {
|
|
1053
|
+
render: (w) => container.render(w),
|
|
1054
|
+
invalidate: () => container.invalidate(),
|
|
1055
|
+
handleInput: (data) => {
|
|
1056
|
+
selectList.handleInput(data);
|
|
1057
|
+
tui.requestRender();
|
|
1058
|
+
},
|
|
1059
|
+
};
|
|
1060
|
+
},
|
|
1061
|
+
{ overlay: true },
|
|
1062
|
+
);
|
|
1063
|
+
|
|
1064
|
+
if (result) {
|
|
1065
|
+
// Require confirmation before switching branches
|
|
1066
|
+
if (ctx.hasUI) {
|
|
1067
|
+
const confirmed = await ctx.ui.confirm(`Switch to branch "${result}"?`);
|
|
1068
|
+
if (!confirmed) {
|
|
1069
|
+
ctx.ui.notify("Switch cancelled", "info");
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
try {
|
|
1074
|
+
const switchResult = await pi.exec("git", ["switch", result]);
|
|
1075
|
+
if (switchResult.code === 0) {
|
|
1076
|
+
ctx.ui.notify(`Switched to branch: ${result}`, "info");
|
|
1077
|
+
await updateRole(ctx); // updateRole handles history logging via change detection
|
|
1078
|
+
// Show colored git log after switch (opt-out via roles.json postSwitchLog: false)
|
|
1079
|
+
if (config.postSwitchLog) {
|
|
1080
|
+
await showGitLog(ctx, 10);
|
|
1081
|
+
}
|
|
1082
|
+
} else {
|
|
1083
|
+
ctx.ui.notify(`\x1b[31mFailed to switch: ${switchResult.stderr}\x1b[0m`, "error");
|
|
1084
|
+
}
|
|
1085
|
+
} catch (e) {
|
|
1086
|
+
ctx.ui.notify(`\x1b[31mFailed to switch: ${(e as Error).message}\x1b[0m`, "error");
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
},
|
|
1090
|
+
});
|
|
1091
|
+
|
|
1092
|
+
// -- /hatt command (alias for /hat todo) ----------------------
|
|
1093
|
+
|
|
1094
|
+
pi.registerCommand("hatt", {
|
|
1095
|
+
description: "Ancestry check + list pending todo items",
|
|
1096
|
+
handler: async (_args, ctx) => {
|
|
1097
|
+
// Ancestry check first (blocking check)
|
|
1098
|
+
const ancestry = await verifyAncestry(config, ctx.cwd);
|
|
1099
|
+
|
|
1100
|
+
if (!ancestry.ok) {
|
|
1101
|
+
const lines = ancestry.violations.map(
|
|
1102
|
+
(v) => ` \u274C ${v.branch} (${v.role}) — run: git rebase ${v.branch}`,
|
|
1103
|
+
);
|
|
1104
|
+
ctx.ui.notify(
|
|
1105
|
+
`\u26A0\uFE0F Ancestry violations — upstream branches not merged in:\n${lines.join("\n")}`,
|
|
1106
|
+
"warning",
|
|
1107
|
+
);
|
|
1108
|
+
return; // block further processing until ancestry is fixed
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
await handleTodo(pi, ctx);
|
|
1112
|
+
|
|
1113
|
+
ctx.ui.notify("\u2705 Ancestry check passed", "info");
|
|
1114
|
+
},
|
|
1115
|
+
});
|
|
1116
|
+
|
|
1117
|
+
// -- /hatl command: colored git log ---------------------------
|
|
1118
|
+
|
|
1119
|
+
pi.registerCommand("hatl", {
|
|
1120
|
+
description: "Show colored git log with branch graph",
|
|
1121
|
+
handler: async (args, ctx) => {
|
|
1122
|
+
const count = args?.trim() ? parseInt(args.trim(), 10) : 10;
|
|
1123
|
+
await showGitLog(ctx, count);
|
|
1124
|
+
},
|
|
1125
|
+
});
|
|
1126
|
+
|
|
1127
|
+
// -- Session lifecycle ----------------------------------------
|
|
1128
|
+
|
|
1129
|
+
pi.on("session_start", async (event, ctx) => {
|
|
1130
|
+
cwdAbsolute = ctx.cwd;
|
|
1131
|
+
|
|
1132
|
+
// Seed bundled roles/ files to project .pi/ before loading config
|
|
1133
|
+
// (this ensures roles.json exists on first startup so loadConfig() doesn't throw)
|
|
1134
|
+
if (event.reason === "startup") {
|
|
1135
|
+
const bundledRoleDir = resolve(EXTENSION_DIR, "roles");
|
|
1136
|
+
let bundledEntries: string[] = [];
|
|
1137
|
+
try {
|
|
1138
|
+
bundledEntries = await readdir(bundledRoleDir);
|
|
1139
|
+
} catch {
|
|
1140
|
+
// directory doesn't exist or can't read — skip silently
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
const mdFiles = bundledEntries.filter((e) => e.endsWith(".md"));
|
|
1144
|
+
if (mdFiles.length > 0) {
|
|
1145
|
+
// Ensure .pi/ directory exists before copying into it
|
|
1146
|
+
await mkdir(resolve(ctx.cwd, ".pi"), { recursive: true });
|
|
1147
|
+
}
|
|
1148
|
+
const copied: string[] = [];
|
|
1149
|
+
for (const file of mdFiles) {
|
|
1150
|
+
// Lowercase the filename so exact match in loadRoleFile() works on first try
|
|
1151
|
+
const lowerName = file.toLowerCase();
|
|
1152
|
+
const target = resolve(ctx.cwd, ".pi", lowerName);
|
|
1153
|
+
if (existsSync(target)) continue; // skip existing files
|
|
1154
|
+
try {
|
|
1155
|
+
await copyFile(resolve(bundledRoleDir, file), target);
|
|
1156
|
+
copied.push(lowerName);
|
|
1157
|
+
} catch {
|
|
1158
|
+
// copy failed (permissions, disk full, etc.) — skip silently
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
// Also seed roles.json from bundled roles/ to project .pi/ if not present
|
|
1163
|
+
const bundledRolesJson = resolve(bundledRoleDir, "roles.json");
|
|
1164
|
+
const projectRolesJson = resolve(ctx.cwd, ".pi", "roles.json");
|
|
1165
|
+
if (!existsSync(projectRolesJson)) {
|
|
1166
|
+
try {
|
|
1167
|
+
await copyFile(bundledRolesJson, projectRolesJson);
|
|
1168
|
+
copied.push("roles.json");
|
|
1169
|
+
} catch {
|
|
1170
|
+
// copy failed — skip silently
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
if (copied.length > 0) {
|
|
1175
|
+
ctx.ui.notify(`Seeded: ${copied.join(", ")}`, "info");
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
config = loadConfig();
|
|
1180
|
+
await updateRole(ctx);
|
|
1181
|
+
|
|
1182
|
+
// Print startup info (same as /hat info, plus config file path)
|
|
1183
|
+
const desc = currentRole ? config.roles[currentRole]?.description ?? "" : "";
|
|
1184
|
+
const roleFile = currentRole
|
|
1185
|
+
? await loadRoleFile(ctx.cwd, currentRole, config)
|
|
1186
|
+
: null;
|
|
1187
|
+
const usingFile = roleFile ? ` | using ${config.fileDir}/${currentRole}.md` : "";
|
|
1188
|
+
|
|
1189
|
+
const roleDisplay = currentRole
|
|
1190
|
+
? `${roleIcon(currentRole)} ${currentRole}${usingFile}`
|
|
1191
|
+
: "\u2753 No matching role (read-only mode)";
|
|
1192
|
+
|
|
1193
|
+
ctx.ui.notify(
|
|
1194
|
+
`${roleDisplay}\n` +
|
|
1195
|
+
`branch: ${currentBranch ?? "not a git repo"}\n` +
|
|
1196
|
+
`${desc ? `desc: ${desc}\n` : ""}` +
|
|
1197
|
+
`roles: ${config.configFile}`,
|
|
1198
|
+
"info",
|
|
1199
|
+
);
|
|
1200
|
+
});
|
|
1201
|
+
|
|
1202
|
+
pi.on("input", async (event, ctx) => {
|
|
1203
|
+
if (config.redetectOnInput) {
|
|
1204
|
+
await updateRole(ctx);
|
|
1205
|
+
}
|
|
1206
|
+
return { action: "continue" };
|
|
1207
|
+
});
|
|
1208
|
+
|
|
1209
|
+
// -- Re-detect after every turn (catches git checkout/switch) --
|
|
1210
|
+
|
|
1211
|
+
pi.on("turn_end", async (_event, ctx) => {
|
|
1212
|
+
if (config.redetectOnInput) {
|
|
1213
|
+
await updateRole(ctx);
|
|
1214
|
+
}
|
|
1215
|
+
});
|
|
1216
|
+
|
|
1217
|
+
// -- System prompt injection ----------------------------------
|
|
1218
|
+
|
|
1219
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
1220
|
+
cwdAbsolute = ctx.cwd;
|
|
1221
|
+
config = loadConfig();
|
|
1222
|
+
await updateRole();
|
|
1223
|
+
|
|
1224
|
+
// No match -> strict read-only mode
|
|
1225
|
+
if (!currentRole || !currentBranch) {
|
|
1226
|
+
return {
|
|
1227
|
+
systemPrompt:
|
|
1228
|
+
event.systemPrompt +
|
|
1229
|
+
`
|
|
1230
|
+
|
|
1231
|
+
## Restricted Mode: Read-Only
|
|
1232
|
+
|
|
1233
|
+
No role matched the current branch (\`${currentBranch ?? "no git repo"}\`).
|
|
1234
|
+
You are in **strict read-only mode**. You can:
|
|
1235
|
+
|
|
1236
|
+
- Read any file with \`read\` / \`grep\` / \`find\`
|
|
1237
|
+
- Run read-only bash commands (ls, cd, pwd, git status/log/diff/branch)
|
|
1238
|
+
- Switch to a matching branch via /hat (TUI selector)
|
|
1239
|
+
|
|
1240
|
+
**What you CANNOT do:**
|
|
1241
|
+
- \`write\` or \`edit\` any file
|
|
1242
|
+
- Run destructive bash commands
|
|
1243
|
+
- Make any changes to the repository
|
|
1244
|
+
|
|
1245
|
+
If the user asks you to make changes, tell them to switch to an appropriate branch first.`,
|
|
1246
|
+
};
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
// Try loading a custom .md file for this role
|
|
1250
|
+
const roleFile = await loadRoleFile(ctx.cwd, currentRole, config);
|
|
1251
|
+
if (roleFile) {
|
|
1252
|
+
return {
|
|
1253
|
+
systemPrompt:
|
|
1254
|
+
event.systemPrompt +
|
|
1255
|
+
`\n\n## Your Role: ${currentRole} (from ${config.fileDir}/${currentRole}.md)\n\n${roleFile}`,
|
|
1256
|
+
};
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
// Fall back to built-in instructions if available
|
|
1260
|
+
const lower = currentRole.toLowerCase();
|
|
1261
|
+
const builtin = BUILTIN_INSTRUCTIONS[lower];
|
|
1262
|
+
if (builtin) {
|
|
1263
|
+
return { systemPrompt: event.systemPrompt + builtin };
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// No custom file and no builtin -> just use the role name as context
|
|
1267
|
+
const desc = config.roles[currentRole]?.description;
|
|
1268
|
+
return {
|
|
1269
|
+
systemPrompt:
|
|
1270
|
+
event.systemPrompt +
|
|
1271
|
+
`\n\n## Your Role: ${currentRole}\n\nYou are in **${currentRole.toUpperCase()}** mode on branch \`${currentBranch}\`.${desc ? `\n${desc}` : ""}`,
|
|
1272
|
+
};
|
|
1273
|
+
});
|
|
1274
|
+
|
|
1275
|
+
// -- Guard rails: tool_call interception ----------------------
|
|
1276
|
+
|
|
1277
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
1278
|
+
const isWrite = event.toolName === "edit" || event.toolName === "write";
|
|
1279
|
+
const isBash = event.toolName === "bash";
|
|
1280
|
+
|
|
1281
|
+
// No match (unknown role): read-only + branch switching only
|
|
1282
|
+
if (!currentRole) {
|
|
1283
|
+
if (isWrite) {
|
|
1284
|
+
return {
|
|
1285
|
+
block: true,
|
|
1286
|
+
reason:
|
|
1287
|
+
`\u2753 No role matched branch "${currentBranch ?? "no-git"}". ` +
|
|
1288
|
+
`All edits and writes are blocked. Switch to a matching branch via /hat (TUI selector).`,
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
if (isBash) {
|
|
1292
|
+
const cmd = ((event.input as { command?: string }).command ?? "").trim();
|
|
1293
|
+
|
|
1294
|
+
// Allow: read-only commands
|
|
1295
|
+
if (
|
|
1296
|
+
/^(ls|cd|pwd|echo|date|which|whoami|uname|hostname|env|true|false|yes|time|seq|head|tail|wc|sort|uniq|jq|dirname|basename|cat|printf)\b/.test(
|
|
1297
|
+
cmd,
|
|
1298
|
+
)
|
|
1299
|
+
) {
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
// Allow: git read-only commands
|
|
1304
|
+
if (
|
|
1305
|
+
/^git\s+(status|log|diff|branch|show|grep|blame|describe|rev-parse|rev-list|shortlog|config|check-ignore|whatchanged|cherry|name-rev|ls-tree|ls-files|ls-remote|stash\s+list|tag)\b/.test(
|
|
1306
|
+
cmd,
|
|
1307
|
+
)
|
|
1308
|
+
) {
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
// Allow: git branch switching
|
|
1313
|
+
if (/^git\s+(switch|checkout)\b/.test(cmd)) {
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// Allow: git add and git rebase --continue during an active rebase
|
|
1318
|
+
let rebasing = false;
|
|
1319
|
+
try {
|
|
1320
|
+
const gitDirResult = await pi.exec("git", ["rev-parse", "--git-dir"]);
|
|
1321
|
+
rebasing = existsSync(resolve(gitDirResult.stdout.trim(), "rebase-merge"));
|
|
1322
|
+
} catch { /* not a git repo */ }
|
|
1323
|
+
if (rebasing && /^git\s+(add|rebase\s+--continue)\b/.test(cmd)) {
|
|
1324
|
+
return;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
return {
|
|
1328
|
+
block: true,
|
|
1329
|
+
reason:
|
|
1330
|
+
`\u2753 No role matched branch "${currentBranch ?? "no-git"}". ` +
|
|
1331
|
+
`Only read-only commands and git branch-switching are allowed.`,
|
|
1332
|
+
};
|
|
1333
|
+
}
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
// Planner: branch must be based on master/main
|
|
1338
|
+
if (currentRole?.toLowerCase() === "planner") {
|
|
1339
|
+
const ancestry = await isMasterOrMainAncestor(ctx.cwd);
|
|
1340
|
+
if (!ancestry.ok) {
|
|
1341
|
+
if (isWrite) {
|
|
1342
|
+
return {
|
|
1343
|
+
block: true,
|
|
1344
|
+
reason: `\uD83D\uDCCB Planner: branch not based on ${ancestry.branch}. Run \`git rebase ${ancestry.branch}\` first.`,
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
1347
|
+
if (isBash) {
|
|
1348
|
+
const cmd = ((event.input as { command?: string }).command ?? "").trim();
|
|
1349
|
+
if (!/^git\s+(rebase\s+(master|main)|switch|checkout)\b/.test(cmd)) {
|
|
1350
|
+
return {
|
|
1351
|
+
block: true,
|
|
1352
|
+
reason: `\uD83D\uDCCB Planner: branch not based on ${ancestry.branch}. Run \`git rebase ${ancestry.branch}\` first.`,
|
|
1353
|
+
};
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
// Known roles: only intercept edit/write
|
|
1360
|
+
if (!isWrite) return;
|
|
1361
|
+
|
|
1362
|
+
const rawPath = (event.input as { path?: string }).path ?? "";
|
|
1363
|
+
const path = normalisePath(rawPath, ctx.cwd, cwdAbsolute);
|
|
1364
|
+
|
|
1365
|
+
const lowerRole = currentRole.toLowerCase();
|
|
1366
|
+
|
|
1367
|
+
// Planner: only todo/, plan/, docs/*.md
|
|
1368
|
+
if (lowerRole === "planner") {
|
|
1369
|
+
if (isInside(path, ["todo", "plan", "docs"])) {
|
|
1370
|
+
if (path.startsWith("todo/")) {
|
|
1371
|
+
const nn = extractNN(path.replace("todo/", ""));
|
|
1372
|
+
if (nn !== null && nn <= (await findMaxNN(ctx.cwd))) {
|
|
1373
|
+
return {
|
|
1374
|
+
block: true,
|
|
1375
|
+
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.`,
|
|
1376
|
+
};
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
return {
|
|
1382
|
+
block: true,
|
|
1383
|
+
reason: `\uD83D\uDCCB Planner: can only write to todo/, plan/, and docs/*.md. Blocked: ${rawPath}`,
|
|
1384
|
+
};
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
// Implementor: block todo/, plan/, .pi/
|
|
1388
|
+
if (lowerRole === "implementor") {
|
|
1389
|
+
if (isInside(path, ["todo", "plan", ".pi"])) {
|
|
1390
|
+
const name = path.startsWith(".pi") ? ".pi/" : path.split("/")[0] + "/";
|
|
1391
|
+
return {
|
|
1392
|
+
block: true,
|
|
1393
|
+
reason: `\uD83D\uDEE0 Implementor: cannot write to ${name}. Blocked: ${rawPath}`,
|
|
1394
|
+
};
|
|
1395
|
+
}
|
|
1396
|
+
return;
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
// Reviewer: only todo/
|
|
1400
|
+
if (lowerRole === "reviewer") {
|
|
1401
|
+
if (isInside(path, ["todo"])) return;
|
|
1402
|
+
return {
|
|
1403
|
+
block: true,
|
|
1404
|
+
reason: `\uD83D\uDD0D Reviewer: can only write to todo/ (to mark items done). Blocked: ${rawPath}`,
|
|
1405
|
+
};
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
// Admin: only .pi/ and *.md in the project root
|
|
1409
|
+
if (lowerRole === "admin") {
|
|
1410
|
+
if (isInside(path, [".pi"]) || (path.endsWith(".md") && !path.includes("/"))) return;
|
|
1411
|
+
return {
|
|
1412
|
+
block: true,
|
|
1413
|
+
reason: `\u2699 Admin: can only write to .pi/ and *.md in the project root. Blocked: ${rawPath}`,
|
|
1414
|
+
};
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
// Custom roles: no hard write restrictions (base-enforce handles it)
|
|
1418
|
+
return;
|
|
1419
|
+
});
|
|
1420
|
+
|
|
1421
|
+
// -- Post-write validation (planner only) ---------------------
|
|
1422
|
+
|
|
1423
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
1424
|
+
if (currentRole !== "planner") return;
|
|
1425
|
+
if (event.toolName !== "write" && event.toolName !== "edit") return;
|
|
1426
|
+
|
|
1427
|
+
const rawPath = (event.input as { path?: string }).path ?? "";
|
|
1428
|
+
const path = normalisePath(rawPath, ctx.cwd, cwdAbsolute);
|
|
1429
|
+
if (!path.startsWith("todo/")) return;
|
|
1430
|
+
|
|
1431
|
+
const validatorPath = resolve(ctx.cwd, "scripts", "validate-todo.py");
|
|
1432
|
+
try {
|
|
1433
|
+
await readFile(validatorPath, "utf8");
|
|
1434
|
+
} catch {
|
|
1435
|
+
return;
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
try {
|
|
1439
|
+
const result = await pi.exec("python3", [validatorPath, resolve(ctx.cwd, path)]);
|
|
1440
|
+
if (result.code !== 0) {
|
|
1441
|
+
return {
|
|
1442
|
+
content: [
|
|
1443
|
+
{
|
|
1444
|
+
type: "text" as const,
|
|
1445
|
+
text: `\u26A0\uFE0F Validation failed for ${path}:\n${result.stderr || result.stdout}\nFix and retry.`,
|
|
1446
|
+
},
|
|
1447
|
+
],
|
|
1448
|
+
isError: true,
|
|
1449
|
+
details: {},
|
|
1450
|
+
};
|
|
1451
|
+
}
|
|
1452
|
+
} catch {
|
|
1453
|
+
return;
|
|
1454
|
+
}
|
|
1455
|
+
});
|
|
1456
|
+
}
|