@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/README.md +1 -0
- package/git-hat.ts +408 -910
- package/lib/branch-history.ts +143 -0
- package/lib/config.ts +103 -0
- package/lib/git-ui.ts +217 -0
- package/lib/paths.ts +79 -0
- package/lib/role-file.ts +194 -0
- package/lib/todo-utils.ts +244 -0
- package/lib/tool-enforcement.ts +91 -0
- package/lib/types.ts +87 -0
- package/package.json +1 -1
- package/roles/_default.md +26 -0
- package/roles/roles.json +97 -5
package/lib/role-file.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Role file loading for git-hat.
|
|
3
|
+
*
|
|
4
|
+
* Loads role-specific .md files from the project .pi/ directory or
|
|
5
|
+
* the extension-bundled roles/ directory. Also contains the built-in
|
|
6
|
+
* instruction constants used as fallback when no .md file exists.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
10
|
+
import { resolve, dirname } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
|
|
13
|
+
import type { MergedConfig } from "./types.js";
|
|
14
|
+
|
|
15
|
+
/** Directory containing this extension file. Used to resolve bundled roles/ directory. */
|
|
16
|
+
export const EXTENSION_DIR = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Load a role's .md file from the project's fileDir directory.
|
|
20
|
+
* Case-insensitive lookup if config.caseInsensitive is true.
|
|
21
|
+
*
|
|
22
|
+
* Search order:
|
|
23
|
+
* 1. {cwd}/{fileDir}/{role}.md (project-local, highest priority)
|
|
24
|
+
* 2. {extensionDir}/roles/{role}.md (extension-bundled fallback)
|
|
25
|
+
*/
|
|
26
|
+
export async function loadRoleFile(
|
|
27
|
+
cwd: string,
|
|
28
|
+
role: string,
|
|
29
|
+
config: MergedConfig,
|
|
30
|
+
): Promise<string | null> {
|
|
31
|
+
// 1. Project-local: {cwd}/{fileDir}/{role}.md (highest priority)
|
|
32
|
+
const roleDir = resolve(cwd, config.fileDir);
|
|
33
|
+
const exactPath = resolve(roleDir, `${role}.md`);
|
|
34
|
+
try {
|
|
35
|
+
return await readFile(exactPath, "utf8");
|
|
36
|
+
} catch {
|
|
37
|
+
// not found with exact case
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (config.caseInsensitive) {
|
|
41
|
+
try {
|
|
42
|
+
const entries = await readdir(roleDir);
|
|
43
|
+
for (const entry of entries) {
|
|
44
|
+
if (entry.toLowerCase() === `${role}.md`) {
|
|
45
|
+
return await readFile(resolve(roleDir, entry), "utf8");
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
// roleDir doesn't exist
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 2. Extension-bundled: {extensionDir}/roles/{role}.md (fallback)
|
|
54
|
+
const bundledRoleDir = resolve(EXTENSION_DIR, "roles");
|
|
55
|
+
const bundledExact = resolve(bundledRoleDir, `${role}.md`);
|
|
56
|
+
try {
|
|
57
|
+
return await readFile(bundledExact, "utf8");
|
|
58
|
+
} catch {
|
|
59
|
+
// not found with exact case
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (config.caseInsensitive) {
|
|
63
|
+
try {
|
|
64
|
+
const entries = await readdir(bundledRoleDir);
|
|
65
|
+
for (const entry of entries) {
|
|
66
|
+
if (entry.toLowerCase() === `${role}.md`) {
|
|
67
|
+
return await readFile(resolve(bundledRoleDir, entry), "utf8");
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
// bundledRoleDir doesn't exist
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// -- Built-in role instructions (fallback when no .md file exists) ---
|
|
79
|
+
// Canonical source: roles/{role}.md in the extension directory.
|
|
80
|
+
// Run `python3 scripts/validate-role-prompts.py` to verify alignment.
|
|
81
|
+
|
|
82
|
+
export const BUILTIN_INSTRUCTIONS: Record<string, string> = {
|
|
83
|
+
planner: `
|
|
84
|
+
|
|
85
|
+
## Your Role: Planner
|
|
86
|
+
|
|
87
|
+
You are **PLANNER**. Your sole responsibility is to research (just collecting data for others to solved the problem) and create
|
|
88
|
+
\`todo/NN-name.md\` files. That is it. Switching branch does not switch your role.
|
|
89
|
+
|
|
90
|
+
## What you do
|
|
91
|
+
1. Research with \`read\` / \`grep\` / \`find\` / \`ls\`
|
|
92
|
+
2. Create \`todo/NN-name.md\` with sequenced, actionable items
|
|
93
|
+
3. Present the plan to the user
|
|
94
|
+
|
|
95
|
+
## What you do NOT do
|
|
96
|
+
- \`edit\`/\`write\` any file outside \`todo/\`
|
|
97
|
+
- Switch branches (the user handles that)
|
|
98
|
+
- Implement anything (that's the implementor)
|
|
99
|
+
- Write reports (that's the implementor)
|
|
100
|
+
|
|
101
|
+
## File format: \`todo/NN-name.md\`
|
|
102
|
+
- \`- [ ]\` pending, \`- [x]\` done
|
|
103
|
+
- First line after checkbox = **header**
|
|
104
|
+
- Indented lines = **body**
|
|
105
|
+
- Blank lines separate items
|
|
106
|
+
|
|
107
|
+
## NN sequence rule
|
|
108
|
+
Every \`todo/NN-name.md\` must use NN **higher** than the highest NN in both
|
|
109
|
+
\`todo/\` **and** \`report/\`.
|
|
110
|
+
`,
|
|
111
|
+
|
|
112
|
+
implementor: `
|
|
113
|
+
|
|
114
|
+
## Your Role: Implementor
|
|
115
|
+
|
|
116
|
+
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.
|
|
117
|
+
|
|
118
|
+
## Report format: \`report/NN-name.md\`
|
|
119
|
+
- Same NN and same headers as the todo
|
|
120
|
+
- Add implementation notes, decisions, and \`\`\`bash results
|
|
121
|
+
|
|
122
|
+
## How to implement
|
|
123
|
+
1. Read \`todo/NN-name.md\` (lowest NN first)
|
|
124
|
+
2. If \`todo/NN-name.detail.md\` exists, read it too
|
|
125
|
+
3. Implement each \`- [ ]\` item using \`edit\`/\`write\`
|
|
126
|
+
4. After each item (or batch), update \`report/NN-name.md\`
|
|
127
|
+
5. When done, move to the next NN
|
|
128
|
+
|
|
129
|
+
## What you do NOT do
|
|
130
|
+
- Switch branches (the user handles that)
|
|
131
|
+
- Write to \`todo/\`, \`plan/\`, or \`.pi/\` — create reports only; let others verify and mark items done
|
|
132
|
+
- Create todo files (that's the planner's job)
|
|
133
|
+
- Modify or mark items in todo files (that's the reviewer's job after verification)
|
|
134
|
+
`,
|
|
135
|
+
|
|
136
|
+
reviewer: `
|
|
137
|
+
|
|
138
|
+
## Your Role: Reviewer
|
|
139
|
+
|
|
140
|
+
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.
|
|
141
|
+
|
|
142
|
+
## What you do
|
|
143
|
+
1. Read \`todo/NN-name.md\` and \`report/NN-name.md\`
|
|
144
|
+
2. Match each pending \`- [ ]\` todo item against a section in the report
|
|
145
|
+
3. **Covered** → mark \`- [x]\` in the todo file
|
|
146
|
+
4. **Missing** → leave \`- [ ]\`, tell the user
|
|
147
|
+
5. Also check for orphan reports and stale todos
|
|
148
|
+
|
|
149
|
+
## What you do NOT do
|
|
150
|
+
- \`edit\`/\`write\` any file outside \`todo/\`
|
|
151
|
+
- Write reports (that's the implementor)
|
|
152
|
+
- Create \`todo/\` or \`plan/\` files (that's the planner)
|
|
153
|
+
- Switch branches (the user handles that)
|
|
154
|
+
- Write to \`.pi/\`
|
|
155
|
+
|
|
156
|
+
## Key rules
|
|
157
|
+
- **Covered** items get \`- [x]\` in the todo file
|
|
158
|
+
- **Missing** items stay \`- [ ]\` — explain why to the user
|
|
159
|
+
- Read source files and \`review/\` and \`todo/\` as needed to verify
|
|
160
|
+
`,
|
|
161
|
+
|
|
162
|
+
admin: `
|
|
163
|
+
|
|
164
|
+
## Your Role: Admin
|
|
165
|
+
|
|
166
|
+
You are **ADMIN**. Switching branch does not switch your role.
|
|
167
|
+
|
|
168
|
+
## What you do
|
|
169
|
+
- Edit \`.pi/\` files — role prompts, roles.json
|
|
170
|
+
- Edit \`README.md\` and \`AGENTS.md\`
|
|
171
|
+
- Explore the codebase (read-only)
|
|
172
|
+
|
|
173
|
+
## What you do NOT do
|
|
174
|
+
- Edit source code
|
|
175
|
+
- Create or modify \`todo/\`, \`plan/\`, \`report/\` files
|
|
176
|
+
- Switch branches (the user handles that)
|
|
177
|
+
`,
|
|
178
|
+
|
|
179
|
+
researcher: `
|
|
180
|
+
|
|
181
|
+
## Your Role: Researcher
|
|
182
|
+
|
|
183
|
+
You are **RESEARCHER**. Switching branch does not switch your role.
|
|
184
|
+
|
|
185
|
+
## What you do
|
|
186
|
+
- Research with \`find\` / \`grep\` / \`ls\` / \`web_search\` / \`read\`
|
|
187
|
+
- Write findings to \`docs/*.md\` and root \`*.md\` files
|
|
188
|
+
|
|
189
|
+
## What you do NOT do
|
|
190
|
+
- Edit source code
|
|
191
|
+
- Create or modify \`todo/\`, \`plan/\`, \`report/\` files
|
|
192
|
+
- Switch branches (the user handles that)
|
|
193
|
+
`,
|
|
194
|
+
};
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Todo utilities for git-hat.
|
|
3
|
+
*
|
|
4
|
+
* Functions for scanning todo/ files, verifying ancestry of branches,
|
|
5
|
+
* checking master/main ancestry, and the /hat todo handler.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
9
|
+
import { execFileSync } from "node:child_process";
|
|
10
|
+
import { resolve } from "node:path";
|
|
11
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
|
|
13
|
+
import type { MergedConfig, PendingItem, TodoFile } from "./types.js";
|
|
14
|
+
import { detectRole } from "./config.js";
|
|
15
|
+
|
|
16
|
+
/** Extract NN sequence number from a todo/ or report/ filename. */
|
|
17
|
+
export function extractNN(filename: string): number | null {
|
|
18
|
+
const match = filename.match(/^(\d+)-/);
|
|
19
|
+
return match ? parseInt(match[1], 10) : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Scan todo/ and report/ dirs for the highest NN in use. */
|
|
23
|
+
export async function findMaxNN(cwd: string): Promise<number> {
|
|
24
|
+
let max = 0;
|
|
25
|
+
for (const dirName of ["todo", "report"]) {
|
|
26
|
+
try {
|
|
27
|
+
const entries = await readdir(resolve(cwd, dirName));
|
|
28
|
+
for (const entry of entries) {
|
|
29
|
+
const nn = extractNN(entry);
|
|
30
|
+
if (nn !== null && nn > max) max = nn;
|
|
31
|
+
}
|
|
32
|
+
} catch {
|
|
33
|
+
// dir doesn't exist yet
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return max;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Scan todos and cross-reference with reports. */
|
|
40
|
+
async function scanTodos(cwd: string): Promise<{
|
|
41
|
+
files: TodoFile[];
|
|
42
|
+
totalPending: number;
|
|
43
|
+
totalCovered: number;
|
|
44
|
+
summary: string;
|
|
45
|
+
}> {
|
|
46
|
+
const files: TodoFile[] = [];
|
|
47
|
+
let totalPending = 0;
|
|
48
|
+
let totalCovered = 0;
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const entries = await readdir(resolve(cwd, "todo"));
|
|
52
|
+
for (const entry of entries.sort()) {
|
|
53
|
+
if (!entry.endsWith(".md") || entry.endsWith(".detail.md")) continue;
|
|
54
|
+
const content = await readFile(resolve(cwd, "todo", entry), "utf8");
|
|
55
|
+
const pending: PendingItem[] = [];
|
|
56
|
+
for (const [i, line] of content.split("\n").entries()) {
|
|
57
|
+
const m = line.match(/^- \[ \] (.+)$/);
|
|
58
|
+
if (m) pending.push({ lineno: i + 1, header: m[1].trim(), covered: false });
|
|
59
|
+
}
|
|
60
|
+
if (pending.length === 0) continue;
|
|
61
|
+
|
|
62
|
+
let reportHeaders: string[] = [];
|
|
63
|
+
try {
|
|
64
|
+
const reportContent = await readFile(resolve(cwd, "report", entry), "utf8");
|
|
65
|
+
for (const line of reportContent.split("\n")) {
|
|
66
|
+
const m = line.match(/^- (.+)$/);
|
|
67
|
+
if (m && !line.includes("[ ]") && !line.includes("[x]")) {
|
|
68
|
+
reportHeaders.push(m[1].trim().toLowerCase());
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
// no matching report
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const reportExists = reportHeaders.length > 0;
|
|
76
|
+
for (const p of pending) {
|
|
77
|
+
p.covered = reportHeaders.some(
|
|
78
|
+
(h) => p.header.toLowerCase().includes(h) || h.includes(p.header.toLowerCase()),
|
|
79
|
+
);
|
|
80
|
+
if (p.covered) totalCovered++;
|
|
81
|
+
}
|
|
82
|
+
totalPending += pending.length;
|
|
83
|
+
files.push({ file: entry, pending, reportExists });
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
// todo/ doesn"t exist
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const displayLines: string[] = [];
|
|
90
|
+
for (const f of files) {
|
|
91
|
+
const tag = f.reportExists ? "" : " (no report)";
|
|
92
|
+
displayLines.push(`todo/${f.file}${tag}`);
|
|
93
|
+
for (const p of f.pending) {
|
|
94
|
+
displayLines.push(` ${p.covered ? "\u2705" : "\u274C"} line ${p.lineno}: ${p.header}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
displayLines.push(
|
|
98
|
+
`\u2500\u2500 ${totalPending - totalCovered} missing \u00B7 ${totalCovered} covered \u00B7 ${totalPending} total \u2500\u2500`,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
return { files, totalPending, totalCovered, summary: displayLines.join("\n") };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Ancestry check: verify current branch descends from ancestor role branches. */
|
|
105
|
+
export async function verifyAncestry(config: MergedConfig, cwd: string): Promise<{
|
|
106
|
+
ok: boolean;
|
|
107
|
+
violations: { role: string; branch: string; currentBranch: string }[];
|
|
108
|
+
}> {
|
|
109
|
+
const violations: { role: string; branch: string; currentBranch: string }[] = [];
|
|
110
|
+
|
|
111
|
+
let currentBranch: string;
|
|
112
|
+
try {
|
|
113
|
+
currentBranch = execFileSync("git", ["branch", "--show-current"], {
|
|
114
|
+
cwd,
|
|
115
|
+
encoding: "utf-8",
|
|
116
|
+
}).trim();
|
|
117
|
+
if (!currentBranch) return { ok: true, violations }; // not on a branch (detached)
|
|
118
|
+
} catch {
|
|
119
|
+
return { ok: true, violations }; // not a git repo
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const role = detectRole(currentBranch, config);
|
|
123
|
+
if (!role) return { ok: true, violations }; // unknown role
|
|
124
|
+
|
|
125
|
+
const ancestorRoles = config.roles[role]?.ancestorRoles;
|
|
126
|
+
if (!ancestorRoles || ancestorRoles.length === 0) return { ok: true, violations }; // no ancestors required
|
|
127
|
+
|
|
128
|
+
// Get all local branches
|
|
129
|
+
let allBranches: string[];
|
|
130
|
+
try {
|
|
131
|
+
const stdout = execFileSync("git", ["branch", "--format", "%(refname:short)"], {
|
|
132
|
+
cwd,
|
|
133
|
+
encoding: "utf-8",
|
|
134
|
+
});
|
|
135
|
+
allBranches = stdout.trim().split("\n").filter(Boolean);
|
|
136
|
+
} catch {
|
|
137
|
+
return { ok: true, violations };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for (const ancestorRole of ancestorRoles) {
|
|
141
|
+
const pattern = config.roles[ancestorRole]?.pattern;
|
|
142
|
+
if (!pattern) continue;
|
|
143
|
+
|
|
144
|
+
const regex = new RegExp(pattern);
|
|
145
|
+
const candidates = allBranches.filter((b) => regex.test(b));
|
|
146
|
+
|
|
147
|
+
for (const candidate of candidates) {
|
|
148
|
+
if (candidate === currentBranch) continue; // same branch, trivially ancestor
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
execFileSync("git", ["merge-base", "--is-ancestor", candidate, currentBranch], {
|
|
152
|
+
cwd,
|
|
153
|
+
stdio: "pipe",
|
|
154
|
+
});
|
|
155
|
+
// exit code 0 = is ancestor, no violation
|
|
156
|
+
} catch {
|
|
157
|
+
// exit code 1 or error = not ancestor, or deleted branch — record violation
|
|
158
|
+
violations.push({ role: ancestorRole, branch: candidate, currentBranch });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return { ok: violations.length === 0, violations };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Check if master/main is an ancestor of the current branch. */
|
|
167
|
+
export async function isMasterOrMainAncestor(cwd: string): Promise<{ ok: boolean; branch: string }> {
|
|
168
|
+
let currentBranch: string;
|
|
169
|
+
try {
|
|
170
|
+
currentBranch = execFileSync("git", ["branch", "--show-current"], {
|
|
171
|
+
cwd,
|
|
172
|
+
encoding: "utf-8",
|
|
173
|
+
}).trim();
|
|
174
|
+
if (!currentBranch) return { ok: true, branch: "master" }; // detached
|
|
175
|
+
} catch {
|
|
176
|
+
return { ok: true, branch: "master" }; // not a git repo
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// try master first, then main
|
|
180
|
+
for (const candidate of ["master", "main"]) {
|
|
181
|
+
try {
|
|
182
|
+
execFileSync("git", ["rev-parse", "--verify", `refs/heads/${candidate}`], {
|
|
183
|
+
cwd,
|
|
184
|
+
stdio: "pipe",
|
|
185
|
+
});
|
|
186
|
+
// branch exists
|
|
187
|
+
if (currentBranch === candidate) return { ok: true, branch: candidate }; // already on it
|
|
188
|
+
try {
|
|
189
|
+
execFileSync("git", ["merge-base", "--is-ancestor", candidate, currentBranch], {
|
|
190
|
+
cwd,
|
|
191
|
+
stdio: "pipe",
|
|
192
|
+
});
|
|
193
|
+
return { ok: true, branch: candidate }; // is ancestor
|
|
194
|
+
} catch {
|
|
195
|
+
return { ok: false, branch: candidate }; // not ancestor
|
|
196
|
+
}
|
|
197
|
+
} catch {
|
|
198
|
+
continue; // branch doesn't exist locally, try next
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// neither master nor main exists locally — skip check
|
|
203
|
+
return { ok: true, branch: "master" };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** /hat todo handler: scan todos, notify user, and send follow-up for pending items. */
|
|
207
|
+
export async function handleTodo(
|
|
208
|
+
pi: ExtensionAPI,
|
|
209
|
+
ctx: { cwd: string; ui: { notify: (msg: string, level: string) => void } },
|
|
210
|
+
): Promise<void> {
|
|
211
|
+
// plan/*.md files are excluded from /hat todo — they store future ideas,
|
|
212
|
+
// not actionable todos. scanTodos() already reads only from the todo/ directory.
|
|
213
|
+
const result = await scanTodos(ctx.cwd);
|
|
214
|
+
if (result.files.length === 0) {
|
|
215
|
+
ctx.ui.notify("\uD83D\uDCED No pending todos found", "info");
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const remaining = result.totalPending - result.totalCovered;
|
|
220
|
+
|
|
221
|
+
if (remaining === 0) {
|
|
222
|
+
ctx.ui.notify("\u2714\uFE0F No more task in todo", "info");
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
ctx.ui.notify(result.summary, "info");
|
|
227
|
+
|
|
228
|
+
if (remaining > 0) {
|
|
229
|
+
const detail = result.files
|
|
230
|
+
.map((f) => {
|
|
231
|
+
const items = f.pending
|
|
232
|
+
.map((p) => ` ${p.covered ? "\u2705" : "\u274C"} ${p.header}`)
|
|
233
|
+
.join("\n");
|
|
234
|
+
return `todo/${f.file}${f.reportExists ? "" : " (no report)"}\n${items}`;
|
|
235
|
+
})
|
|
236
|
+
.join("\n");
|
|
237
|
+
pi.sendUserMessage(
|
|
238
|
+
`Analyze these pending todos against their reports:\n\n${detail}\n\n` +
|
|
239
|
+
`For each \u274C item, check if it"s truly not implemented yet or if the report ` +
|
|
240
|
+
`just uses different wording. If it"s genuinely missing, suggest next steps.`,
|
|
241
|
+
{ deliverAs: "followUp" },
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool enforcement engine — reusable by extensions and packages.
|
|
3
|
+
*
|
|
4
|
+
* Provides types and functions for config-driven tool rule evaluation.
|
|
5
|
+
*
|
|
6
|
+
* Exports:
|
|
7
|
+
* - RegexObj, ToolRule (types)
|
|
8
|
+
* - testRegex() — recursive regex matching (string / any / all)
|
|
9
|
+
* - evaluateToolRules() — first-match-wins rule evaluation
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// -- Types ---------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
export interface RegexObj {
|
|
15
|
+
type: "any" | "all";
|
|
16
|
+
regex: (string | RegexObj)[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ToolRule {
|
|
20
|
+
type: "allow" | "block" | "confirm";
|
|
21
|
+
regex: string | RegexObj;
|
|
22
|
+
reason?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// -- Regex matching ------------------------------------------------
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Recursively test a command against a regex pattern.
|
|
29
|
+
*
|
|
30
|
+
* - **string**: direct `new RegExp(regex).test(command)`, invalid patterns skipped
|
|
31
|
+
* - **`{ type: "any", regex: [...] }`**: true if **any** sub-regex matches (OR)
|
|
32
|
+
* - **`{ type: "all", regex: [...] }`**: true if **all** sub-regexes match (AND)
|
|
33
|
+
*
|
|
34
|
+
* Sub-regexes can themselves be strings or nested `RegexObj` values.
|
|
35
|
+
*/
|
|
36
|
+
export function testRegex(regex: string | RegexObj, command: string): boolean {
|
|
37
|
+
if (typeof regex === "string") {
|
|
38
|
+
try {
|
|
39
|
+
return new RegExp(regex).test(command);
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// RegexObj
|
|
45
|
+
if (regex.type === "any") {
|
|
46
|
+
return regex.regex.some((r) => testRegex(r, command));
|
|
47
|
+
}
|
|
48
|
+
// type === "all"
|
|
49
|
+
return regex.regex.every((r) => testRegex(r, command));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// -- Rule evaluation -----------------------------------------------
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Evaluate a command against an ordered list of tool rules.
|
|
56
|
+
*
|
|
57
|
+
* **First matching rule wins**: if a rule matches and its type is `"allow"`,
|
|
58
|
+
* the command is permitted. If `"block"`, the command is denied with an
|
|
59
|
+
* optional reason. If `"confirm"`, the command requires user confirmation.
|
|
60
|
+
* If no rule matches, the command also requires user confirmation (safe default).
|
|
61
|
+
*
|
|
62
|
+
* @param rules - Array of `ToolRule` objects, or `undefined` (treated as "not configured")
|
|
63
|
+
* @param command - The string to test (bash command, tool name, etc.)
|
|
64
|
+
* @returns
|
|
65
|
+
* - `{ allowed: true }` — command is permitted unconditionally
|
|
66
|
+
* - `{ allowed: false, reason }` — command is denied
|
|
67
|
+
* - `{ allowed: true, confirm: true, reason }` — command requires user confirmation
|
|
68
|
+
*/
|
|
69
|
+
export function evaluateToolRules(
|
|
70
|
+
rules: ToolRule[] | undefined,
|
|
71
|
+
command: string,
|
|
72
|
+
): { allowed: boolean; confirm?: boolean; reason?: string; matched: boolean } {
|
|
73
|
+
if (!rules || rules.length === 0) {
|
|
74
|
+
// No rules defined or empty array = caller should fall back to hardcoded behaviour
|
|
75
|
+
return { allowed: true, matched: false };
|
|
76
|
+
}
|
|
77
|
+
for (const rule of rules) {
|
|
78
|
+
if (testRegex(rule.regex, command)) {
|
|
79
|
+
if (rule.type === "allow") {
|
|
80
|
+
return { allowed: true, matched: true };
|
|
81
|
+
}
|
|
82
|
+
if (rule.type === "confirm") {
|
|
83
|
+
return { allowed: true, confirm: true, reason: rule.reason, matched: true };
|
|
84
|
+
}
|
|
85
|
+
// block
|
|
86
|
+
return { allowed: false, reason: rule.reason, matched: true };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// No rule matched — caller should continue to next stage
|
|
90
|
+
return { allowed: true, matched: false };
|
|
91
|
+
}
|
package/lib/types.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared type definitions for git-hat extension.
|
|
3
|
+
*
|
|
4
|
+
* Re-exported from git-hat.ts into lib/ to reduce file size.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface WritablePathEntry {
|
|
8
|
+
/** Directory or file path (relative to project root). Empty string = project root. */
|
|
9
|
+
path: string;
|
|
10
|
+
/** Optional file extension filter, e.g. "md" — if set, only files with this extension are writable. */
|
|
11
|
+
extension?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface RoleDef {
|
|
15
|
+
pattern: string;
|
|
16
|
+
description?: string;
|
|
17
|
+
ancestorRoles?: string[];
|
|
18
|
+
tool?: ToolRule[];
|
|
19
|
+
/** Tool rules evaluated after default post-tool rules (snake_case key "post-tool" in JSON). */
|
|
20
|
+
postTool?: ToolRule[];
|
|
21
|
+
writablePaths?: WritablePathEntry[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface RolesConfig {
|
|
25
|
+
version?: number;
|
|
26
|
+
roles?: Record<string, RoleDef>;
|
|
27
|
+
defaultRole?: string;
|
|
28
|
+
fileDir?: string;
|
|
29
|
+
caseInsensitive?: boolean;
|
|
30
|
+
|
|
31
|
+
default?: {
|
|
32
|
+
/** Tool rules evaluated before role-specific rules (snake_case key "pre-tool" in JSON). */
|
|
33
|
+
preTool?: ToolRule[];
|
|
34
|
+
/** Tool rules evaluated after role-specific rules (snake_case key "post-tool" in JSON). */
|
|
35
|
+
postTool?: ToolRule[];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
postSwitchLog?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface MergedConfig {
|
|
42
|
+
roles: Record<string, RoleDef>;
|
|
43
|
+
fileDir: string;
|
|
44
|
+
caseInsensitive: boolean;
|
|
45
|
+
|
|
46
|
+
/** Default pre-tool rules loaded from roles.json default.pre-tool */
|
|
47
|
+
preTool?: ToolRule[];
|
|
48
|
+
/** Default post-tool rules loaded from roles.json default.post-tool */
|
|
49
|
+
postTool?: ToolRule[];
|
|
50
|
+
|
|
51
|
+
postSwitchLog: boolean;
|
|
52
|
+
configFile: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
import type { ToolRule } from "./tool-enforcement.js";
|
|
56
|
+
|
|
57
|
+
// -- Re-export for convenience
|
|
58
|
+
export type { ToolRule };
|
|
59
|
+
|
|
60
|
+
export interface SwitchLogEntry {
|
|
61
|
+
ts: string;
|
|
62
|
+
role: string;
|
|
63
|
+
branch: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface BranchHistory {
|
|
67
|
+
[role: string]: string | undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface BranchEntry {
|
|
71
|
+
branch: string;
|
|
72
|
+
role: string;
|
|
73
|
+
isCurrent: boolean;
|
|
74
|
+
isLastUsed: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface PendingItem {
|
|
78
|
+
lineno: number;
|
|
79
|
+
header: string;
|
|
80
|
+
covered: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface TodoFile {
|
|
84
|
+
file: string;
|
|
85
|
+
pending: PendingItem[];
|
|
86
|
+
reportExists: boolean;
|
|
87
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@senomas/pi-git-hat",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
4
4
|
"description": "Pi extension for role-based Git branch workflows — wear different hats by switching branches",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": ["pi-package", "git", "workflow", "branching", "roles"],
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Workflow Rules
|
|
2
|
+
|
|
3
|
+
## Role boundaries
|
|
4
|
+
- Different roles (planner, implementor, reviewer, admin) may be run by different people or on different machines.
|
|
5
|
+
- Do **not** assume another role will act on your suggestions or that the current user manages all roles.
|
|
6
|
+
- Focus only on the work assigned to your current role.
|
|
7
|
+
- If a task requires a different role, report it in the report and stop.
|
|
8
|
+
- **Follow your role strictly to the letter.** Do not deviate from your role's defined write permissions and responsibilities.
|
|
9
|
+
|
|
10
|
+
## Branch discipline
|
|
11
|
+
- Do NOT switch branches unless the user explicitly asks.
|
|
12
|
+
- Do NOT create new branches on your own.
|
|
13
|
+
- Let the user handle all git branch operations.
|
|
14
|
+
- When the user tells you to "switch to X" or "go to branch X", they mean **you should run `git switch X` yourself** — treat it as an explicit instruction to execute the command.
|
|
15
|
+
- When the user says "rebase X", they mean **run `git rebase X`** — rebase the current branch onto X. Do NOT interpret it as "switch to X".
|
|
16
|
+
- After a successful rebase, **show the commit tree** (`git log --oneline --graph --all --decorate` or `/hatl`) so the user can see the new state.
|
|
17
|
+
- When the user says "commit", they mean **run `git add` and `git commit` yourself** — treat it as an explicit instruction to execute.
|
|
18
|
+
|
|
19
|
+
## Enforcement
|
|
20
|
+
- If an enforcement rule blocks you, respect it.
|
|
21
|
+
- Do NOT try to sidestep enforcement with scripts, alternative tools, or workarounds.
|
|
22
|
+
- Explain to the user what is blocked and why, and show the command they can run manually to unblock.
|
|
23
|
+
|
|
24
|
+
## Never suggest branch switching
|
|
25
|
+
- **Never suggest or recommend switching branches.** Not as advice, not as a next step — under any circumstance.
|
|
26
|
+
- Wait for the user to explicitly ask or tell you.
|