@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.
@@ -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)
@@ -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
@@ -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)