@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
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SearchableSelectList: extends @mariozechner/pi-tui's SelectList
|
|
3
|
+
* with incremental search/filter by typing printable characters.
|
|
4
|
+
*
|
|
5
|
+
* This avoids patching node_modules directly.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { SelectList, getKeybindings, parseKey } from "@earendil-works/pi-tui";
|
|
9
|
+
|
|
10
|
+
export class SearchableSelectList extends SelectList {
|
|
11
|
+
filterString = "";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Override setFilter() to:
|
|
15
|
+
* - Store the filter string
|
|
16
|
+
* - Use `includes` (not `startsWith`)
|
|
17
|
+
* - Search both `value` and `label`
|
|
18
|
+
* - Keep disabled items visible (role headers)
|
|
19
|
+
*/
|
|
20
|
+
setFilter(filter: string): void {
|
|
21
|
+
this.filterString = filter;
|
|
22
|
+
this.filteredItems = this.items.filter((item) => {
|
|
23
|
+
if (item.disabled) return true; // keep role headers
|
|
24
|
+
const q = filter.toLowerCase();
|
|
25
|
+
const val = (item.value || "").toLowerCase();
|
|
26
|
+
const lbl = (item.label || "").toLowerCase();
|
|
27
|
+
return val.includes(q) || lbl.includes(q);
|
|
28
|
+
});
|
|
29
|
+
this.selectedIndex = 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Convenience: clear the filter. */
|
|
33
|
+
clearFilter(): void {
|
|
34
|
+
this.setFilter("");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Override render() to show a filter bar when filterString is non-empty.
|
|
39
|
+
*/
|
|
40
|
+
render(width: number): string[] {
|
|
41
|
+
const lines: string[] = [];
|
|
42
|
+
|
|
43
|
+
// Show filter bar when filtering
|
|
44
|
+
if (this.filterString.length > 0) {
|
|
45
|
+
lines.push(this.theme.noMatch(`\uD83D\uDD0D ${this.filterString}`));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Delegate remaining rendering to the base class
|
|
49
|
+
const baseLines = super.render(width);
|
|
50
|
+
// super.render() prepends "No matching commands" when filteredItems is empty,
|
|
51
|
+
// but we already show a filter bar above. Squelch duplicates.
|
|
52
|
+
if (this.filteredItems.length === 0 && this.filterString.length > 0) {
|
|
53
|
+
// Already showing filter bar; skip the "No matching commands" message
|
|
54
|
+
return lines;
|
|
55
|
+
}
|
|
56
|
+
lines.push(...baseLines);
|
|
57
|
+
return lines;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Override handleInput() to intercept search keystrokes before
|
|
62
|
+
* delegating navigation keys to the base class.
|
|
63
|
+
*/
|
|
64
|
+
handleInput(keyData: string): void {
|
|
65
|
+
const kb = getKeybindings();
|
|
66
|
+
|
|
67
|
+
// Backspace → remove last char of filter
|
|
68
|
+
if (kb.matches(keyData, "tui.editor.deleteCharBackward")) {
|
|
69
|
+
if (this.filterString.length > 0) {
|
|
70
|
+
this.setFilter(this.filterString.slice(0, -1));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
// If filter is already empty, treat as cancel
|
|
74
|
+
if (this.onCancel) {
|
|
75
|
+
this.onCancel();
|
|
76
|
+
}
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Ctrl+U → clear filter entirely
|
|
81
|
+
if (kb.matches(keyData, "tui.editor.deleteToLineStart")) {
|
|
82
|
+
if (this.filterString.length > 0) {
|
|
83
|
+
this.clearFilter();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
// fall through to base if filter already empty
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Escape → if filter active, clear it; otherwise cancel
|
|
90
|
+
if (kb.matches(keyData, "tui.select.cancel")) {
|
|
91
|
+
if (this.filterString.length > 0) {
|
|
92
|
+
this.clearFilter();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
// fall through to base for actual cancel
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Printable character → append to filter.
|
|
99
|
+
// parseKey() returns the single character for printable key input
|
|
100
|
+
// (handles Kitty CSI-u, ModifyOtherKeys, and legacy raw ASCII).
|
|
101
|
+
// It returns multi-character key IDs ("up", "enter", etc.) for
|
|
102
|
+
// non-printable keys, which are filtered out by the length check.
|
|
103
|
+
const parsed = parseKey(keyData);
|
|
104
|
+
if (parsed && parsed.length === 1) {
|
|
105
|
+
this.setFilter(this.filterString + parsed);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Delegate navigation and other keys to the base class
|
|
110
|
+
super.handleInput(keyData);
|
|
111
|
+
}
|
|
112
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@senomas/pi-git-hat",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pi extension for role-based Git branch workflows — wear different hats by switching branches",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": ["pi-package", "git", "workflow", "branching", "roles"],
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"files": [
|
|
9
|
+
"git-hat.ts",
|
|
10
|
+
"lib/",
|
|
11
|
+
"roles/",
|
|
12
|
+
"scripts/validate-role-prompts.py",
|
|
13
|
+
"LICENSE",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"pi": {
|
|
17
|
+
"extensions": ["./git-hat.ts"]
|
|
18
|
+
},
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
21
|
+
"@earendil-works/pi-tui": "*"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {}
|
|
24
|
+
}
|
package/roles/admin.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Admin
|
|
2
|
+
|
|
3
|
+
You are **ADMIN**. Switching branch does not switch your role.
|
|
4
|
+
|
|
5
|
+
## What you do
|
|
6
|
+
- Edit `.pi/` files — role prompts, roles.json
|
|
7
|
+
- Edit `README.md` and `AGENTS.md`
|
|
8
|
+
- Explore the codebase (read-only)
|
|
9
|
+
|
|
10
|
+
## What you do NOT do
|
|
11
|
+
- Edit source code
|
|
12
|
+
- Create or modify `todo/`, `plan/`, `report/` files
|
|
13
|
+
- Switch branches (the user handles that)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Implementor
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
## Report format: `report/NN-name.md`
|
|
6
|
+
- Same NN and same headers as the todo
|
|
7
|
+
- Add implementation notes, decisions, and ```bash results
|
|
8
|
+
|
|
9
|
+
## How to implement
|
|
10
|
+
1. Read `todo/NN-name.md` (lowest NN first)
|
|
11
|
+
2. If `todo/NN-name.detail.md` exists, read it too
|
|
12
|
+
3. Implement each `- [ ]` item using `edit`/`write`
|
|
13
|
+
4. After each item (or batch), update `report/NN-name.md`
|
|
14
|
+
5. When done, move to the next NN
|
|
15
|
+
|
|
16
|
+
## What you do NOT do
|
|
17
|
+
- Switch branches (the user handles that)
|
|
18
|
+
- Write to `todo/`, `plan/`, or `.pi/` — create reports only; let others verify and mark items done
|
|
19
|
+
- Create todo files (that's the planner's job)
|
|
20
|
+
- Modify or mark items in todo files (that's the reviewer's job after verification)
|
package/roles/planner.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Planner
|
|
2
|
+
|
|
3
|
+
You are **PLANNER**. Your sole responsibility is to research (just collecting data for others to solved the problem) and create
|
|
4
|
+
`todo/NN-name.md` files. That is it. Switching branch does not switch your role.
|
|
5
|
+
|
|
6
|
+
## What you do
|
|
7
|
+
1. Research with `read` / `grep` / `find` / `ls`
|
|
8
|
+
2. Create `todo/NN-name.md` with sequenced, actionable items
|
|
9
|
+
3. Present the plan to the user
|
|
10
|
+
|
|
11
|
+
## What you do NOT do
|
|
12
|
+
- `edit`/`write` any file outside `todo/`
|
|
13
|
+
- Switch branches (the user handles that)
|
|
14
|
+
- Implement anything (that's the implementor)
|
|
15
|
+
- Write reports (that's the implementor)
|
|
16
|
+
|
|
17
|
+
## File format: `todo/NN-name.md`
|
|
18
|
+
- `- [ ]` pending, `- [x]` done
|
|
19
|
+
- First line after checkbox = **header**
|
|
20
|
+
- Indented lines = **body**
|
|
21
|
+
- Blank lines separate items
|
|
22
|
+
|
|
23
|
+
## NN sequence rule
|
|
24
|
+
Every `todo/NN-name.md` must use NN **higher** than the highest NN in both
|
|
25
|
+
`todo/` **and** `report/`.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Reviewer
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
## What you do
|
|
6
|
+
1. Read `todo/NN-name.md` and `report/NN-name.md`
|
|
7
|
+
2. Match each pending `- [ ]` todo item against a section in the report
|
|
8
|
+
3. **Covered** → mark `- [x]` in the todo file
|
|
9
|
+
4. **Missing** → leave `- [ ]`, tell the user
|
|
10
|
+
5. Also check for orphan reports and stale todos
|
|
11
|
+
|
|
12
|
+
## What you do NOT do
|
|
13
|
+
- `edit`/`write` any file outside `todo/`
|
|
14
|
+
- Write reports (that's the implementor)
|
|
15
|
+
- Create `todo/` or `plan/` files (that's the planner)
|
|
16
|
+
- Switch branches (the user handles that)
|
|
17
|
+
- Write to `.pi/`
|
|
18
|
+
|
|
19
|
+
## Key rules
|
|
20
|
+
- **Covered** items get `- [x]` in the todo file
|
|
21
|
+
- **Missing** items stay `- [ ]` — explain why to the user
|
|
22
|
+
- Read source files and `review/` and `todo/` as needed to verify
|
package/roles/roles.json
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"roles": {
|
|
3
|
+
"planner": { "pattern": "^plan(-|/|$)", "description": "Plan work by creating todo/plan files" },
|
|
4
|
+
"implementor": { "pattern": "^(implementor$|feature|feat|impl$|work$)(-|/|$)", "description": "Feature/impl/work branches" },
|
|
5
|
+
"reviewer": { "pattern": "^review(-|/|$)", "description": "Review implementations against todos" },
|
|
6
|
+
"admin": { "pattern": "^(main|master)$", "description": "Project configuration and docs" }
|
|
7
|
+
}
|
|
8
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Validate that BUILTIN_INSTRUCTIONS (hardcoded in git-hat.ts) match
|
|
4
|
+
the canonical roles/*.md files in content (body text, not wrappers).
|
|
5
|
+
|
|
6
|
+
Run from the git-hat project root:
|
|
7
|
+
python3 scripts/validate-role-prompts.py
|
|
8
|
+
|
|
9
|
+
Returns exit code 0 if all match, 1 on any mismatch.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
import os
|
|
15
|
+
|
|
16
|
+
# --- Find repo root (script lives in scripts/) ---
|
|
17
|
+
script_dir = os.path.dirname(os.path.abspath(__file__))
|
|
18
|
+
repo_root = os.path.dirname(script_dir)
|
|
19
|
+
|
|
20
|
+
# --- Extract BUILTIN_INSTRUCTIONS from git-hat.ts ---
|
|
21
|
+
source_path = os.path.join(repo_root, "git-hat.ts")
|
|
22
|
+
with open(source_path, "r", encoding="utf-8") as f:
|
|
23
|
+
source = f.read()
|
|
24
|
+
|
|
25
|
+
# Find the BUILTIN_INSTRUCTIONS object
|
|
26
|
+
pattern = r"const BUILTIN_INSTRUCTIONS:\s*Record<string,\s*string>\s*=\s*({[\s\S]*?});"
|
|
27
|
+
m = re.search(pattern, source)
|
|
28
|
+
if not m:
|
|
29
|
+
print("❌ Could not find BUILTIN_INSTRUCTIONS in git-hat.ts", file=sys.stderr)
|
|
30
|
+
sys.exit(1)
|
|
31
|
+
|
|
32
|
+
# Parse the object literal using eval-like approach.
|
|
33
|
+
# We need to convert the TS object to a Python dict.
|
|
34
|
+
obj_str = m.group(1)
|
|
35
|
+
|
|
36
|
+
# Extract string key-value pairs: key: `value`
|
|
37
|
+
builtin = {}
|
|
38
|
+
# Match patterns like: planner: `\n\n...content...\n`,
|
|
39
|
+
key_pattern = re.compile(
|
|
40
|
+
r'^\s+(\w+):\s*`((?:[^`\\]|\\.)*)`\s*,?\s*$',
|
|
41
|
+
re.MULTILINE
|
|
42
|
+
)
|
|
43
|
+
for match in key_pattern.finditer(obj_str):
|
|
44
|
+
key = match.group(1)
|
|
45
|
+
value = match.group(2)
|
|
46
|
+
builtin[key] = value
|
|
47
|
+
|
|
48
|
+
if not builtin:
|
|
49
|
+
print("❌ Failed to parse BUILTIN_INSTRUCTIONS keys from git-hat.ts", file=sys.stderr)
|
|
50
|
+
# Try a more lenient approach
|
|
51
|
+
print(" Raw match snippet:", obj_str[:200], file=sys.stderr)
|
|
52
|
+
sys.exit(1)
|
|
53
|
+
|
|
54
|
+
# --- Load roles/*.md files ---
|
|
55
|
+
roles_dir = os.path.join(repo_root, "roles")
|
|
56
|
+
if not os.path.isdir(roles_dir):
|
|
57
|
+
print("❌ Could not find roles/ directory", file=sys.stderr)
|
|
58
|
+
sys.exit(1)
|
|
59
|
+
|
|
60
|
+
role_files = sorted([f for f in os.listdir(roles_dir) if f.endswith(".md")])
|
|
61
|
+
role_names = set(f.replace(".md", "").lower() for f in role_files)
|
|
62
|
+
# Map lowercase role name to actual filename (handles case-sensitive filesystems)
|
|
63
|
+
role_file_map = {f.replace(".md", "").lower(): f for f in role_files}
|
|
64
|
+
|
|
65
|
+
# --- Comparison helpers ---
|
|
66
|
+
|
|
67
|
+
def strip_builtin_wrapper(text):
|
|
68
|
+
"""Strip the header wrapper that BUILTIN_INSTRUCTIONS adds for system prompt injection.
|
|
69
|
+
BUILTIN strings start with \n\n## Your Role: <Name>\n\n
|
|
70
|
+
We extract everything after the first blank-line-separated header block.
|
|
71
|
+
Also unescape backticks (escaped as backslash-backtick in TS template literals).
|
|
72
|
+
"""
|
|
73
|
+
# Remove leading newlines, then the ## Your Role: ... header and its following blank line
|
|
74
|
+
result = re.sub(r'^\n+', '', text)
|
|
75
|
+
result = re.sub(r'^## Your Role: .+?\n\n', '', result)
|
|
76
|
+
# Unescape backticks that are escaped in TS template literals
|
|
77
|
+
result = result.replace('\\`', '`')
|
|
78
|
+
return result.rstrip() + '\n'
|
|
79
|
+
|
|
80
|
+
def normalize_file_content(text):
|
|
81
|
+
"""Normalize a roles/*.md file content for comparison.
|
|
82
|
+
Strips the # Rolename header (redundant with the ## Your Role: wrapper
|
|
83
|
+
that BUILTIN_INSTRUCTIONS adds at injection time) and trailing whitespace.
|
|
84
|
+
"""
|
|
85
|
+
# Strip the # Rolename level-1 header if present
|
|
86
|
+
result = re.sub(r'^# \w+\n\n', '', text)
|
|
87
|
+
return result.rstrip() + '\n'
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
all_pass = True
|
|
91
|
+
errors = []
|
|
92
|
+
|
|
93
|
+
for role_name in sorted(role_names):
|
|
94
|
+
# Find matching builtin key (case-insensitive)
|
|
95
|
+
builtin_key = None
|
|
96
|
+
for k in builtin:
|
|
97
|
+
if k.lower() == role_name:
|
|
98
|
+
builtin_key = k
|
|
99
|
+
break
|
|
100
|
+
|
|
101
|
+
if builtin_key is None:
|
|
102
|
+
errors.append(f"❌ roles/{role_name}.md has no matching key in BUILTIN_INSTRUCTIONS")
|
|
103
|
+
all_pass = False
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
actual_filename = role_file_map.get(role_name, f"{role_name}.md")
|
|
107
|
+
with open(os.path.join(roles_dir, actual_filename), "r", encoding="utf-8") as f:
|
|
108
|
+
file_content = normalize_file_content(f.read())
|
|
109
|
+
|
|
110
|
+
builtin_content = strip_builtin_wrapper(builtin[builtin_key])
|
|
111
|
+
|
|
112
|
+
if file_content == builtin_content:
|
|
113
|
+
print(f"✅ {role_name}: BUILTIN_INSTRUCTIONS matches roles/{role_name}.md")
|
|
114
|
+
else:
|
|
115
|
+
errors.append(f"❌ {role_name}: BUILTIN_INSTRUCTIONS differs from roles/{role_name}.md")
|
|
116
|
+
# Show first diff chunk
|
|
117
|
+
file_lines = file_content.split('\n')
|
|
118
|
+
builtin_lines = builtin_content.split('\n')
|
|
119
|
+
max_len = max(len(file_lines), len(builtin_lines))
|
|
120
|
+
first_diff = -1
|
|
121
|
+
for i in range(max_len):
|
|
122
|
+
fl = file_lines[i] if i < len(file_lines) else None
|
|
123
|
+
bl = builtin_lines[i] if i < len(builtin_lines) else None
|
|
124
|
+
if fl != bl:
|
|
125
|
+
first_diff = i
|
|
126
|
+
break
|
|
127
|
+
if first_diff >= 0:
|
|
128
|
+
fl = file_lines[first_diff] if first_diff < len(file_lines) else "(end)"
|
|
129
|
+
bl = builtin_lines[first_diff] if first_diff < len(builtin_lines) else "(end)"
|
|
130
|
+
errors.append(f" First difference at line {first_diff + 1}:")
|
|
131
|
+
errors.append(f" file: {repr(fl)}")
|
|
132
|
+
errors.append(f" builtin: {repr(bl)}")
|
|
133
|
+
all_pass = False
|
|
134
|
+
|
|
135
|
+
# Check for builtin keys that have no corresponding roles/ file
|
|
136
|
+
for key in builtin:
|
|
137
|
+
if key.lower() not in role_names:
|
|
138
|
+
errors.append(f"❌ BUILTIN_INSTRUCTIONS[\"{key}\"] has no corresponding roles/{key}.md file")
|
|
139
|
+
all_pass = False
|
|
140
|
+
|
|
141
|
+
print()
|
|
142
|
+
if all_pass:
|
|
143
|
+
print("✅ All role prompts are in sync.")
|
|
144
|
+
else:
|
|
145
|
+
print("❌ Mismatches found:")
|
|
146
|
+
for err in errors:
|
|
147
|
+
print(f" {err}")
|
|
148
|
+
|
|
149
|
+
sys.exit(0 if all_pass else 1)
|