@tianhai/pi-workflow-kit 0.8.0 → 0.8.2

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,172 @@
1
+ # Workflow Guard: Safe Commands Expansion
2
+
3
+ **Date:** 2026-04-21
4
+ **Status:** Draft
5
+
6
+ ## Problem
7
+
8
+ The workflow guard blocks several read-only bash commands that are genuinely needed during brainstorm and plan phases. Two specific user reports:
9
+
10
+ 1. `cd /path && git remote -v 2>/dev/null; echo "---"; ls` — blocked due to `cd` not being allowlisted and `2>/dev/null` caught by the stdout-redirect pattern.
11
+ 2. `gh pr view 1564 --json ... 2>/dev/null || echo "gh failed"` — blocked because `gh` is not allowlisted at all.
12
+
13
+ Additionally, `git status --short` is blocked because the safe regex only allows `git status` without flags.
14
+
15
+ ## Design
16
+
17
+ ### 1. Harmless redirect stripping
18
+
19
+ Add a `stripHarmlessRedirects(cmd)` helper that removes `2>/dev/null` and `2>&1` before pattern matching. These are purely cosmetic (suppress stderr noise) and have no side effects.
20
+
21
+ ```ts
22
+ function stripHarmlessRedirects(cmd: string): string {
23
+ return cmd.replace(/\s*2\s*>\s*(\/dev\/null|&1)\b/g, "");
24
+ }
25
+ ```
26
+
27
+ Apply it inside `isSafeCommand` on each sub-command before checking DESTRUCTIVE and SAFE patterns. This fixes `2>/dev/null` without loosening the redirect catch (which still blocks real writes).
28
+
29
+ ### 2. New SAFE_PATTERNS entries
30
+
31
+ | Pattern | Rationale |
32
+ |---------|-----------|
33
+ | `/^\s*cd\b/` | Directory navigation — zero side effects |
34
+ | `/^\s*gh\s+pr\s+(view\|list\|diff\|checks\|status)\b/i` | Read-only PR inspection |
35
+ | `/^\s*gh\s+issue\s+(view\|list)\b/i` | Read-only issue inspection |
36
+ | `/^\s*gh\s+repo\s+(view\|fork\|list)\b/i` | Read-only repo metadata |
37
+ | `/^\s*gh\s+release\s+(view\|list\|download)\b/i` | Read-only release inspection |
38
+ | `/^\s*gh\s+run\s+(view\|list)\b/i` | Read-only CI run inspection |
39
+ | `/^\s*git\s+blame\b/` | Read-only file annotation |
40
+ | `/^\s*git\s+shortlog\b/` | Read-only commit summary |
41
+ | `/^\s*git\s+stash\s+list\b/i` | Read-only stash listing |
42
+ | `/^\s*git\s+tag\s+(-l\|--list)\b/i` | Read-only tag listing |
43
+ | `/^\s*git\s+describe\b/` | Read-only version info |
44
+
45
+ ### 3. Fix: `git status` flag handling
46
+
47
+ Current regex `/^\s*git\s+(status|log|...)/i` doesn't allow common flags like `--short`, `--oneline`, `--format=...`. Refine all git safe patterns to optionally accept trailing flags and args:
48
+
49
+ ```ts
50
+ /^\s*git\s+status\b/i,
51
+ /^\s*git\s+log\b/i,
52
+ /^\s*git\s+diff\b/i,
53
+ /^\s*git\s+show\b/i,
54
+ /^\s*git\s+blame\b/i,
55
+ // etc.
56
+ ```
57
+
58
+ The existing patterns already anchor to `^\s*git\s+<subcommand>` — the issue was that `git status --short` didn't match because some patterns had more restrictive anchoring. Reviewing the code: the patterns use `\b` word boundaries which should allow flags. The actual issue with `git status --short && git log --oneline -5` is that the `git log --oneline -5` part is safe, but `git status --short` — let me verify: `/^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get)/i` — `git status` has a `\b` after the group? No, there's no trailing `\b`. So `git status --short` **should** match since the pattern doesn't require end-of-string. The real blocker for that compound command was the `&&` splitting — `git log --oneline -5` — `-5` shouldn't be an issue either.
59
+
60
+ **Conclusion on item 3:** The `git status --short` case was a false alarm caused by compound command parsing combined with the `2>/dev/null` redirect in the user's actual commands, not a pattern bug. No change needed here beyond the redirect fix.
61
+
62
+ ### 4. What we're NOT adding (YAGNI)
63
+
64
+ - `sed -i` (in-place editing) — correctly destructive
65
+ - `gh pr create/merge/close` — write operations
66
+ - `curl -o file` (output to file) — the redirect catch blocks this
67
+ - `cut`, `tr`, `column`, `base64` — rarely needed; can add later on demand
68
+ - `gh api` — too broad; can be used for mutations. Require specific subcommands.
69
+
70
+ ## Data flow
71
+
72
+ No new data flow. The changes are purely additive to the pattern-matching logic in `isSafeCommand`.
73
+
74
+ ## Error handling
75
+
76
+ No new error paths. The existing block-and-warn behavior remains unchanged.
77
+
78
+ ## Testing
79
+
80
+ Manual verification with the exact commands that were blocked:
81
+
82
+ 1. `cd /some/path && git remote -v 2>/dev/null; echo "---"; ls` → allowed
83
+ 2. `gh pr view 1564 --repo owner/repo --json title,body,files 2>/dev/null || echo "gh failed"` → allowed
84
+ 3. `git stash list` → allowed
85
+ 4. `git tag -l` → allowed
86
+ 5. `rm -rf /` → still blocked ✓
87
+ 6. `git push origin main` → still blocked ✓
88
+
89
+ ## Tests
90
+
91
+ Add the following test cases to the existing `tests/workflow-guard.test.ts` `isSafeCommand` describe block:
92
+
93
+ ### New: `cd` navigation
94
+ ```ts
95
+ it("allows cd", () => {
96
+ expect(isSafeCommand("cd /some/path")).toBe(true);
97
+ expect(isSafeCommand("cd src && ls")).toBe(true);
98
+ });
99
+ ```
100
+
101
+ ### New: GitHub CLI read-only subcommands
102
+ ```ts
103
+ it("allows gh read-only subcommands", () => {
104
+ expect(isSafeCommand("gh pr view 1564 --json title,body")).toBe(true);
105
+ expect(isSafeCommand("gh pr list --repo owner/repo")).toBe(true);
106
+ expect(isSafeCommand("gh pr diff 1564")).toBe(true);
107
+ expect(isSafeCommand("gh issue view 42")).toBe(true);
108
+ expect(isSafeCommand("gh issue list --label bug")).toBe(true);
109
+ expect(isSafeCommand("gh repo view owner/repo")).toBe(true);
110
+ expect(isSafeCommand("gh run view 12345")).toBe(true);
111
+ });
112
+
113
+ it("blocks gh write subcommands", () => {
114
+ expect(isSafeCommand("gh pr create --title 'fix'")).toBe(false);
115
+ expect(isSafeCommand("gh pr merge 1564")).toBe(false);
116
+ expect(isSafeCommand("gh issue close 42")).toBe(false);
117
+ expect(isSafeCommand("gh release create v1.0")).toBe(false);
118
+ });
119
+ ```
120
+
121
+ ### New: Git read-only subcommands
122
+ ```ts
123
+ it("allows git read-only subcommands (new additions)", () => {
124
+ expect(isSafeCommand("git blame src/index.ts")).toBe(true);
125
+ expect(isSafeCommand("git shortlog -sn")).toBe(true);
126
+ expect(isSafeCommand("git stash list")).toBe(true);
127
+ expect(isSafeCommand("git tag -l")).toBe(true);
128
+ expect(isSafeCommand("git tag --list 'v*'")).toBe(true);
129
+ expect(isSafeCommand("git describe --tags")).toBe(true);
130
+ });
131
+
132
+ it("still blocks git stash mutations", () => {
133
+ expect(isSafeCommand("git stash push -m 'wip'")).toBe(false);
134
+ expect(isSafeCommand("git stash pop")).toBe(false);
135
+ });
136
+ ```
137
+
138
+ ### New: Harmless stderr redirect stripping
139
+ ```ts
140
+ it("allows 2>/dev/null on safe commands", () => {
141
+ expect(isSafeCommand("git remote -v 2>/dev/null")).toBe(true);
142
+ expect(isSafeCommand("gh pr view 1564 2>/dev/null")).toBe(true);
143
+ expect(isSafeCommand("npm list 2>/dev/null")).toBe(true);
144
+ });
145
+
146
+ it("allows 2>&1 on safe commands", () => {
147
+ expect(isSafeCommand("git log 2>&1")).toBe(true);
148
+ });
149
+
150
+ it("still blocks stdout redirects even with stderr redirect present", () => {
151
+ expect(isSafeCommand("echo 'hello' > file.ts 2>/dev/null")).toBe(false);
152
+ expect(isSafeCommand("cat config > backup.txt 2>/dev/null")).toBe(false);
153
+ });
154
+ ```
155
+
156
+ ### New: Compound commands from real user scenarios
157
+ ```ts
158
+ it("allows the exact user-reported blocked commands", () => {
159
+ // Scenario 1: directory navigation + git remote + ls
160
+ expect(isSafeCommand("cd /Users/u/partying/pt-room && git remote -v 2>/dev/null; echo '---'; ls")).toBe(true);
161
+ // Scenario 2: gh pr view with fallback
162
+ expect(isSafeCommand("gh pr view 1564 --repo olachat/pt-partying --json title,body,files,additions,deletions 2>/dev/null || echo 'gh failed'")).toBe(true);
163
+ });
164
+ ```
165
+
166
+ ## Summary
167
+
168
+ | Change | Location | Size |
169
+ |--------|----------|------|
170
+ | Add `stripHarmlessRedirects()` | Above `isSafeCommand` | ~3 lines |
171
+ | Call it in `isSafeCommand` loop body | Inside `isSafeCommand` | 1 line changed |
172
+ | Add 10 new SAFE_PATTERNS entries | `SAFE_PATTERNS` array | ~10 lines |
@@ -0,0 +1,168 @@
1
+ # Workflow Guard: Safe Commands Expansion — Implementation Plan
2
+
3
+ **Design:** `docs/plans/2026-04-21-workflow-guard-safe-commands-design.md`
4
+ **Date:** 2026-04-21
5
+
6
+ ---
7
+
8
+ ## Task 1: Add `stripHarmlessRedirects` helper and wire it into `isSafeCommand`
9
+
10
+ **Scenario:** Modifying tested code
11
+ **File:** `extensions/workflow-guard.ts`
12
+
13
+ 1. Run existing tests to confirm baseline:
14
+ ```bash
15
+ npx vitest run tests/workflow-guard.test.ts
16
+ ```
17
+ Expected: all pass.
18
+
19
+ 2. Add `stripHarmlessRedirects` function above `isSafeCommand`:
20
+ ```ts
21
+ /** Strip stderr redirects that are purely cosmetic (no side effects). */
22
+ function stripHarmlessRedirects(cmd: string): string {
23
+ return cmd.replace(/\s*2\s*>\s*(\/dev\/null|&1)\b/g, "");
24
+ }
25
+ ```
26
+
27
+ 3. Wire it into `isSafeCommand` — apply `stripHarmlessRedirects` to each part before pattern matching:
28
+ ```ts
29
+ export function isSafeCommand(command: string): boolean {
30
+ const parts = splitCompoundCommand(command);
31
+ return parts.every((part) => {
32
+ const cleaned = stripHarmlessRedirects(part);
33
+ const isDestructive = DESTRUCTIVE_PATTERNS.some((p) => p.test(cleaned));
34
+ const isSafe = SAFE_PATTERNS.some((p) => p.test(cleaned));
35
+ return !isDestructive && isSafe;
36
+ });
37
+ }
38
+ ```
39
+
40
+ 4. Run tests:
41
+ ```bash
42
+ npx vitest run tests/workflow-guard.test.ts
43
+ ```
44
+ Expected: all existing tests still pass (no behavior change yet since no new SAFE_PATTERNS).
45
+
46
+ 5. Commit:
47
+ ```bash
48
+ git add extensions/workflow-guard.ts
49
+ git commit -m "feat(workflow-guard): add stripHarmlessRedirects helper"
50
+ ```
51
+
52
+ ---
53
+
54
+ ## Task 2: Add new SAFE_PATTERNS entries
55
+
56
+ **Scenario:** Modifying tested code
57
+ **File:** `extensions/workflow-guard.ts`
58
+
59
+ 1. Add the following entries to the `SAFE_PATTERNS` array (after the existing `gh`-related area or at end):
60
+
61
+ ```ts
62
+ /^\s*cd\b/,
63
+ /^\s*gh\s+pr\s+(view|list|diff|checks|status)\b/i,
64
+ /^\s*gh\s+issue\s+(view|list)\b/i,
65
+ /^\s*gh\s+repo\s+(view|fork|list)\b/i,
66
+ /^\s*gh\s+release\s+(view|list|download)\b/i,
67
+ /^\s*gh\s+run\s+(view|list)\b/i,
68
+ /^\s*git\s+blame\b/,
69
+ /^\s*git\s+shortlog\b/,
70
+ /^\s*git\s+stash\s+list\b/i,
71
+ /^\s*git\s+tag\s+(-l|--list)\b/i,
72
+ /^\s*git\s+describe\b/,
73
+ ```
74
+
75
+ 2. Run tests:
76
+ ```bash
77
+ npx vitest run tests/workflow-guard.test.ts
78
+ ```
79
+ Expected: all existing tests pass.
80
+
81
+ 3. Commit:
82
+ ```bash
83
+ git add extensions/workflow-guard.ts
84
+ git commit -m "feat(workflow-guard): add safe patterns for cd, gh, and git read-only subcommands"
85
+ ```
86
+
87
+ ---
88
+
89
+ ## Task 3: Add tests for `cd`, `gh`, git new subcommands, and redirect stripping
90
+
91
+ **Scenario:** New feature (test-first)
92
+ **File:** `tests/workflow-guard.test.ts`
93
+
94
+ **checkpoint: test** — pause after writing failing tests, before implementation.
95
+
96
+ > Note: Implementation was already done in Tasks 1–2. These tests should all pass immediately. The checkpoint label is kept for review purposes in case the user wants to verify test design.
97
+
98
+ 1. Add the following test blocks inside the `describe("isSafeCommand", ...)` block, after the existing tests:
99
+
100
+ ```ts
101
+ it("allows cd", () => {
102
+ expect(isSafeCommand("cd /some/path")).toBe(true);
103
+ expect(isSafeCommand("cd src && ls")).toBe(true);
104
+ });
105
+
106
+ it("allows gh read-only subcommands", () => {
107
+ expect(isSafeCommand("gh pr view 1564 --json title,body")).toBe(true);
108
+ expect(isSafeCommand("gh pr list --repo owner/repo")).toBe(true);
109
+ expect(isSafeCommand("gh pr diff 1564")).toBe(true);
110
+ expect(isSafeCommand("gh issue view 42")).toBe(true);
111
+ expect(isSafeCommand("gh issue list --label bug")).toBe(true);
112
+ expect(isSafeCommand("gh repo view owner/repo")).toBe(true);
113
+ expect(isSafeCommand("gh run view 12345")).toBe(true);
114
+ });
115
+
116
+ it("blocks gh write subcommands", () => {
117
+ expect(isSafeCommand("gh pr create --title 'fix'")).toBe(false);
118
+ expect(isSafeCommand("gh pr merge 1564")).toBe(false);
119
+ expect(isSafeCommand("gh issue close 42")).toBe(false);
120
+ expect(isSafeCommand("gh release create v1.0")).toBe(false);
121
+ });
122
+
123
+ it("allows git read-only subcommands (new additions)", () => {
124
+ expect(isSafeCommand("git blame src/index.ts")).toBe(true);
125
+ expect(isSafeCommand("git shortlog -sn")).toBe(true);
126
+ expect(isSafeCommand("git stash list")).toBe(true);
127
+ expect(isSafeCommand("git tag -l")).toBe(true);
128
+ expect(isSafeCommand("git tag --list 'v*'")).toBe(true);
129
+ expect(isSafeCommand("git describe --tags")).toBe(true);
130
+ });
131
+
132
+ it("still blocks git stash mutations", () => {
133
+ expect(isSafeCommand("git stash push -m 'wip'")).toBe(false);
134
+ expect(isSafeCommand("git stash pop")).toBe(false);
135
+ });
136
+
137
+ it("allows 2>/dev/null on safe commands", () => {
138
+ expect(isSafeCommand("git remote -v 2>/dev/null")).toBe(true);
139
+ expect(isSafeCommand("gh pr view 1564 2>/dev/null")).toBe(true);
140
+ expect(isSafeCommand("npm list 2>/dev/null")).toBe(true);
141
+ });
142
+
143
+ it("allows 2>&1 on safe commands", () => {
144
+ expect(isSafeCommand("git log 2>&1")).toBe(true);
145
+ });
146
+
147
+ it("still blocks stdout redirects even with stderr redirect present", () => {
148
+ expect(isSafeCommand("echo 'hello' > file.ts 2>/dev/null")).toBe(false);
149
+ expect(isSafeCommand("cat config > backup.txt 2>/dev/null")).toBe(false);
150
+ });
151
+
152
+ it("allows the exact user-reported blocked commands", () => {
153
+ expect(isSafeCommand("cd /Users/u/partying/pt-room && git remote -v 2>/dev/null; echo '---'; ls")).toBe(true);
154
+ expect(isSafeCommand("gh pr view 1564 --repo olachat/pt-partying --json title,body,files,additions,deletions 2>/dev/null || echo 'gh failed'")).toBe(true);
155
+ });
156
+ ```
157
+
158
+ 2. Run tests:
159
+ ```bash
160
+ npx vitest run tests/workflow-guard.test.ts
161
+ ```
162
+ Expected: all tests pass (including new ones).
163
+
164
+ 3. Commit:
165
+ ```bash
166
+ git add tests/workflow-guard.test.ts
167
+ git commit -m "test(workflow-guard): add tests for cd, gh, git read-only subcommands, and redirect stripping"
168
+ ```
@@ -0,0 +1,172 @@
1
+ # Workflow Guard: Safe Commands Expansion
2
+
3
+ **Date:** 2026-04-21
4
+ **Status:** Draft
5
+
6
+ ## Problem
7
+
8
+ The workflow guard blocks several read-only bash commands that are genuinely needed during brainstorm and plan phases. Two specific user reports:
9
+
10
+ 1. `cd /path && git remote -v 2>/dev/null; echo "---"; ls` — blocked due to `cd` not being allowlisted and `2>/dev/null` caught by the stdout-redirect pattern.
11
+ 2. `gh pr view 1564 --json ... 2>/dev/null || echo "gh failed"` — blocked because `gh` is not allowlisted at all.
12
+
13
+ Additionally, `git status --short` is blocked because the safe regex only allows `git status` without flags.
14
+
15
+ ## Design
16
+
17
+ ### 1. Harmless redirect stripping
18
+
19
+ Add a `stripHarmlessRedirects(cmd)` helper that removes `2>/dev/null` and `2>&1` before pattern matching. These are purely cosmetic (suppress stderr noise) and have no side effects.
20
+
21
+ ```ts
22
+ function stripHarmlessRedirects(cmd: string): string {
23
+ return cmd.replace(/\s*2\s*>\s*(\/dev\/null|&1)\b/g, "");
24
+ }
25
+ ```
26
+
27
+ Apply it inside `isSafeCommand` on each sub-command before checking DESTRUCTIVE and SAFE patterns. This fixes `2>/dev/null` without loosening the redirect catch (which still blocks real writes).
28
+
29
+ ### 2. New SAFE_PATTERNS entries
30
+
31
+ | Pattern | Rationale |
32
+ |---------|-----------|
33
+ | `/^\s*cd\b/` | Directory navigation — zero side effects |
34
+ | `/^\s*gh\s+pr\s+(view\|list\|diff\|checks\|status)\b/i` | Read-only PR inspection |
35
+ | `/^\s*gh\s+issue\s+(view\|list)\b/i` | Read-only issue inspection |
36
+ | `/^\s*gh\s+repo\s+(view\|fork\|list)\b/i` | Read-only repo metadata |
37
+ | `/^\s*gh\s+release\s+(view\|list\|download)\b/i` | Read-only release inspection |
38
+ | `/^\s*gh\s+run\s+(view\|list)\b/i` | Read-only CI run inspection |
39
+ | `/^\s*git\s+blame\b/` | Read-only file annotation |
40
+ | `/^\s*git\s+shortlog\b/` | Read-only commit summary |
41
+ | `/^\s*git\s+stash\s+list\b/i` | Read-only stash listing |
42
+ | `/^\s*git\s+tag\s+(-l\|--list)\b/i` | Read-only tag listing |
43
+ | `/^\s*git\s+describe\b/` | Read-only version info |
44
+
45
+ ### 3. Fix: `git status` flag handling
46
+
47
+ Current regex `/^\s*git\s+(status|log|...)/i` doesn't allow common flags like `--short`, `--oneline`, `--format=...`. Refine all git safe patterns to optionally accept trailing flags and args:
48
+
49
+ ```ts
50
+ /^\s*git\s+status\b/i,
51
+ /^\s*git\s+log\b/i,
52
+ /^\s*git\s+diff\b/i,
53
+ /^\s*git\s+show\b/i,
54
+ /^\s*git\s+blame\b/i,
55
+ // etc.
56
+ ```
57
+
58
+ The existing patterns already anchor to `^\s*git\s+<subcommand>` — the issue was that `git status --short` didn't match because some patterns had more restrictive anchoring. Reviewing the code: the patterns use `\b` word boundaries which should allow flags. The actual issue with `git status --short && git log --oneline -5` is that the `git log --oneline -5` part is safe, but `git status --short` — let me verify: `/^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get)/i` — `git status` has a `\b` after the group? No, there's no trailing `\b`. So `git status --short` **should** match since the pattern doesn't require end-of-string. The real blocker for that compound command was the `&&` splitting — `git log --oneline -5` — `-5` shouldn't be an issue either.
59
+
60
+ **Conclusion on item 3:** The `git status --short` case was a false alarm caused by compound command parsing combined with the `2>/dev/null` redirect in the user's actual commands, not a pattern bug. No change needed here beyond the redirect fix.
61
+
62
+ ### 4. What we're NOT adding (YAGNI)
63
+
64
+ - `sed -i` (in-place editing) — correctly destructive
65
+ - `gh pr create/merge/close` — write operations
66
+ - `curl -o file` (output to file) — the redirect catch blocks this
67
+ - `cut`, `tr`, `column`, `base64` — rarely needed; can add later on demand
68
+ - `gh api` — too broad; can be used for mutations. Require specific subcommands.
69
+
70
+ ## Data flow
71
+
72
+ No new data flow. The changes are purely additive to the pattern-matching logic in `isSafeCommand`.
73
+
74
+ ## Error handling
75
+
76
+ No new error paths. The existing block-and-warn behavior remains unchanged.
77
+
78
+ ## Testing
79
+
80
+ Manual verification with the exact commands that were blocked:
81
+
82
+ 1. `cd /some/path && git remote -v 2>/dev/null; echo "---"; ls` → allowed
83
+ 2. `gh pr view 1564 --repo owner/repo --json title,body,files 2>/dev/null || echo "gh failed"` → allowed
84
+ 3. `git stash list` → allowed
85
+ 4. `git tag -l` → allowed
86
+ 5. `rm -rf /` → still blocked ✓
87
+ 6. `git push origin main` → still blocked ✓
88
+
89
+ ## Tests
90
+
91
+ Add the following test cases to the existing `tests/workflow-guard.test.ts` `isSafeCommand` describe block:
92
+
93
+ ### New: `cd` navigation
94
+ ```ts
95
+ it("allows cd", () => {
96
+ expect(isSafeCommand("cd /some/path")).toBe(true);
97
+ expect(isSafeCommand("cd src && ls")).toBe(true);
98
+ });
99
+ ```
100
+
101
+ ### New: GitHub CLI read-only subcommands
102
+ ```ts
103
+ it("allows gh read-only subcommands", () => {
104
+ expect(isSafeCommand("gh pr view 1564 --json title,body")).toBe(true);
105
+ expect(isSafeCommand("gh pr list --repo owner/repo")).toBe(true);
106
+ expect(isSafeCommand("gh pr diff 1564")).toBe(true);
107
+ expect(isSafeCommand("gh issue view 42")).toBe(true);
108
+ expect(isSafeCommand("gh issue list --label bug")).toBe(true);
109
+ expect(isSafeCommand("gh repo view owner/repo")).toBe(true);
110
+ expect(isSafeCommand("gh run view 12345")).toBe(true);
111
+ });
112
+
113
+ it("blocks gh write subcommands", () => {
114
+ expect(isSafeCommand("gh pr create --title 'fix'")).toBe(false);
115
+ expect(isSafeCommand("gh pr merge 1564")).toBe(false);
116
+ expect(isSafeCommand("gh issue close 42")).toBe(false);
117
+ expect(isSafeCommand("gh release create v1.0")).toBe(false);
118
+ });
119
+ ```
120
+
121
+ ### New: Git read-only subcommands
122
+ ```ts
123
+ it("allows git read-only subcommands (new additions)", () => {
124
+ expect(isSafeCommand("git blame src/index.ts")).toBe(true);
125
+ expect(isSafeCommand("git shortlog -sn")).toBe(true);
126
+ expect(isSafeCommand("git stash list")).toBe(true);
127
+ expect(isSafeCommand("git tag -l")).toBe(true);
128
+ expect(isSafeCommand("git tag --list 'v*'")).toBe(true);
129
+ expect(isSafeCommand("git describe --tags")).toBe(true);
130
+ });
131
+
132
+ it("still blocks git stash mutations", () => {
133
+ expect(isSafeCommand("git stash push -m 'wip'")).toBe(false);
134
+ expect(isSafeCommand("git stash pop")).toBe(false);
135
+ });
136
+ ```
137
+
138
+ ### New: Harmless stderr redirect stripping
139
+ ```ts
140
+ it("allows 2>/dev/null on safe commands", () => {
141
+ expect(isSafeCommand("git remote -v 2>/dev/null")).toBe(true);
142
+ expect(isSafeCommand("gh pr view 1564 2>/dev/null")).toBe(true);
143
+ expect(isSafeCommand("npm list 2>/dev/null")).toBe(true);
144
+ });
145
+
146
+ it("allows 2>&1 on safe commands", () => {
147
+ expect(isSafeCommand("git log 2>&1")).toBe(true);
148
+ });
149
+
150
+ it("still blocks stdout redirects even with stderr redirect present", () => {
151
+ expect(isSafeCommand("echo 'hello' > file.ts 2>/dev/null")).toBe(false);
152
+ expect(isSafeCommand("cat config > backup.txt 2>/dev/null")).toBe(false);
153
+ });
154
+ ```
155
+
156
+ ### New: Compound commands from real user scenarios
157
+ ```ts
158
+ it("allows the exact user-reported blocked commands", () => {
159
+ // Scenario 1: directory navigation + git remote + ls
160
+ expect(isSafeCommand("cd /Users/u/partying/pt-room && git remote -v 2>/dev/null; echo '---'; ls")).toBe(true);
161
+ // Scenario 2: gh pr view with fallback
162
+ expect(isSafeCommand("gh pr view 1564 --repo olachat/pt-partying --json title,body,files,additions,deletions 2>/dev/null || echo 'gh failed'")).toBe(true);
163
+ });
164
+ ```
165
+
166
+ ## Summary
167
+
168
+ | Change | Location | Size |
169
+ |--------|----------|------|
170
+ | Add `stripHarmlessRedirects()` | Above `isSafeCommand` | ~3 lines |
171
+ | Call it in `isSafeCommand` loop body | Inside `isSafeCommand` | 1 line changed |
172
+ | Add 10 new SAFE_PATTERNS entries | `SAFE_PATTERNS` array | ~10 lines |
@@ -0,0 +1,168 @@
1
+ # Workflow Guard: Safe Commands Expansion — Implementation Plan
2
+
3
+ **Design:** `docs/plans/2026-04-21-workflow-guard-safe-commands-design.md`
4
+ **Date:** 2026-04-21
5
+
6
+ ---
7
+
8
+ ## Task 1: Add `stripHarmlessRedirects` helper and wire it into `isSafeCommand`
9
+
10
+ **Scenario:** Modifying tested code
11
+ **File:** `extensions/workflow-guard.ts`
12
+
13
+ 1. Run existing tests to confirm baseline:
14
+ ```bash
15
+ npx vitest run tests/workflow-guard.test.ts
16
+ ```
17
+ Expected: all pass.
18
+
19
+ 2. Add `stripHarmlessRedirects` function above `isSafeCommand`:
20
+ ```ts
21
+ /** Strip stderr redirects that are purely cosmetic (no side effects). */
22
+ function stripHarmlessRedirects(cmd: string): string {
23
+ return cmd.replace(/\s*2\s*>\s*(\/dev\/null|&1)\b/g, "");
24
+ }
25
+ ```
26
+
27
+ 3. Wire it into `isSafeCommand` — apply `stripHarmlessRedirects` to each part before pattern matching:
28
+ ```ts
29
+ export function isSafeCommand(command: string): boolean {
30
+ const parts = splitCompoundCommand(command);
31
+ return parts.every((part) => {
32
+ const cleaned = stripHarmlessRedirects(part);
33
+ const isDestructive = DESTRUCTIVE_PATTERNS.some((p) => p.test(cleaned));
34
+ const isSafe = SAFE_PATTERNS.some((p) => p.test(cleaned));
35
+ return !isDestructive && isSafe;
36
+ });
37
+ }
38
+ ```
39
+
40
+ 4. Run tests:
41
+ ```bash
42
+ npx vitest run tests/workflow-guard.test.ts
43
+ ```
44
+ Expected: all existing tests still pass (no behavior change yet since no new SAFE_PATTERNS).
45
+
46
+ 5. Commit:
47
+ ```bash
48
+ git add extensions/workflow-guard.ts
49
+ git commit -m "feat(workflow-guard): add stripHarmlessRedirects helper"
50
+ ```
51
+
52
+ ---
53
+
54
+ ## Task 2: Add new SAFE_PATTERNS entries
55
+
56
+ **Scenario:** Modifying tested code
57
+ **File:** `extensions/workflow-guard.ts`
58
+
59
+ 1. Add the following entries to the `SAFE_PATTERNS` array (after the existing `gh`-related area or at end):
60
+
61
+ ```ts
62
+ /^\s*cd\b/,
63
+ /^\s*gh\s+pr\s+(view|list|diff|checks|status)\b/i,
64
+ /^\s*gh\s+issue\s+(view|list)\b/i,
65
+ /^\s*gh\s+repo\s+(view|fork|list)\b/i,
66
+ /^\s*gh\s+release\s+(view|list|download)\b/i,
67
+ /^\s*gh\s+run\s+(view|list)\b/i,
68
+ /^\s*git\s+blame\b/,
69
+ /^\s*git\s+shortlog\b/,
70
+ /^\s*git\s+stash\s+list\b/i,
71
+ /^\s*git\s+tag\s+(-l|--list)\b/i,
72
+ /^\s*git\s+describe\b/,
73
+ ```
74
+
75
+ 2. Run tests:
76
+ ```bash
77
+ npx vitest run tests/workflow-guard.test.ts
78
+ ```
79
+ Expected: all existing tests pass.
80
+
81
+ 3. Commit:
82
+ ```bash
83
+ git add extensions/workflow-guard.ts
84
+ git commit -m "feat(workflow-guard): add safe patterns for cd, gh, and git read-only subcommands"
85
+ ```
86
+
87
+ ---
88
+
89
+ ## Task 3: Add tests for `cd`, `gh`, git new subcommands, and redirect stripping
90
+
91
+ **Scenario:** New feature (test-first)
92
+ **File:** `tests/workflow-guard.test.ts`
93
+
94
+ **checkpoint: test** — pause after writing failing tests, before implementation.
95
+
96
+ > Note: Implementation was already done in Tasks 1–2. These tests should all pass immediately. The checkpoint label is kept for review purposes in case the user wants to verify test design.
97
+
98
+ 1. Add the following test blocks inside the `describe("isSafeCommand", ...)` block, after the existing tests:
99
+
100
+ ```ts
101
+ it("allows cd", () => {
102
+ expect(isSafeCommand("cd /some/path")).toBe(true);
103
+ expect(isSafeCommand("cd src && ls")).toBe(true);
104
+ });
105
+
106
+ it("allows gh read-only subcommands", () => {
107
+ expect(isSafeCommand("gh pr view 1564 --json title,body")).toBe(true);
108
+ expect(isSafeCommand("gh pr list --repo owner/repo")).toBe(true);
109
+ expect(isSafeCommand("gh pr diff 1564")).toBe(true);
110
+ expect(isSafeCommand("gh issue view 42")).toBe(true);
111
+ expect(isSafeCommand("gh issue list --label bug")).toBe(true);
112
+ expect(isSafeCommand("gh repo view owner/repo")).toBe(true);
113
+ expect(isSafeCommand("gh run view 12345")).toBe(true);
114
+ });
115
+
116
+ it("blocks gh write subcommands", () => {
117
+ expect(isSafeCommand("gh pr create --title 'fix'")).toBe(false);
118
+ expect(isSafeCommand("gh pr merge 1564")).toBe(false);
119
+ expect(isSafeCommand("gh issue close 42")).toBe(false);
120
+ expect(isSafeCommand("gh release create v1.0")).toBe(false);
121
+ });
122
+
123
+ it("allows git read-only subcommands (new additions)", () => {
124
+ expect(isSafeCommand("git blame src/index.ts")).toBe(true);
125
+ expect(isSafeCommand("git shortlog -sn")).toBe(true);
126
+ expect(isSafeCommand("git stash list")).toBe(true);
127
+ expect(isSafeCommand("git tag -l")).toBe(true);
128
+ expect(isSafeCommand("git tag --list 'v*'")).toBe(true);
129
+ expect(isSafeCommand("git describe --tags")).toBe(true);
130
+ });
131
+
132
+ it("still blocks git stash mutations", () => {
133
+ expect(isSafeCommand("git stash push -m 'wip'")).toBe(false);
134
+ expect(isSafeCommand("git stash pop")).toBe(false);
135
+ });
136
+
137
+ it("allows 2>/dev/null on safe commands", () => {
138
+ expect(isSafeCommand("git remote -v 2>/dev/null")).toBe(true);
139
+ expect(isSafeCommand("gh pr view 1564 2>/dev/null")).toBe(true);
140
+ expect(isSafeCommand("npm list 2>/dev/null")).toBe(true);
141
+ });
142
+
143
+ it("allows 2>&1 on safe commands", () => {
144
+ expect(isSafeCommand("git log 2>&1")).toBe(true);
145
+ });
146
+
147
+ it("still blocks stdout redirects even with stderr redirect present", () => {
148
+ expect(isSafeCommand("echo 'hello' > file.ts 2>/dev/null")).toBe(false);
149
+ expect(isSafeCommand("cat config > backup.txt 2>/dev/null")).toBe(false);
150
+ });
151
+
152
+ it("allows the exact user-reported blocked commands", () => {
153
+ expect(isSafeCommand("cd /Users/u/partying/pt-room && git remote -v 2>/dev/null; echo '---'; ls")).toBe(true);
154
+ expect(isSafeCommand("gh pr view 1564 --repo olachat/pt-partying --json title,body,files,additions,deletions 2>/dev/null || echo 'gh failed'")).toBe(true);
155
+ });
156
+ ```
157
+
158
+ 2. Run tests:
159
+ ```bash
160
+ npx vitest run tests/workflow-guard.test.ts
161
+ ```
162
+ Expected: all tests pass (including new ones).
163
+
164
+ 3. Commit:
165
+ ```bash
166
+ git add tests/workflow-guard.test.ts
167
+ git commit -m "test(workflow-guard): add tests for cd, gh, git read-only subcommands, and redirect stripping"
168
+ ```
@@ -35,7 +35,7 @@ const DESTRUCTIVE_PATTERNS = [
35
35
  /\bpip\s+(install|uninstall)/i,
36
36
  /\bapt(-get)?\s+(install|remove|purge|update|upgrade)/i,
37
37
  /\bbrew\s+(install|uninstall|upgrade)/i,
38
- /\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|branch\s+-[dD]|stash|cherry-pick|revert|tag|init|clone)/i,
38
+ /\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|branch\s+-[dD]|stash(?!\s+list)|cherry-pick|revert|tag(?!\s+(-l|--list))|init|clone)/i,
39
39
  /\bsudo\b/i,
40
40
  /\bsu\b/i,
41
41
  /\bkill\b/i,
@@ -99,12 +99,46 @@ const SAFE_PATTERNS = [
99
99
  /^\s*fd\b/,
100
100
  /^\s*bat\b/,
101
101
  /^\s*eza\b/,
102
+ /^\s*cd\b/,
103
+ /^\s*gh\s+pr\s+(view|list|diff|checks|status)\b/i,
104
+ /^\s*gh\s+issue\s+(view|list)\b/i,
105
+ /^\s*gh\s+repo\s+(view|fork|list)\b/i,
106
+ /^\s*gh\s+release\s+(view|list|download)\b/i,
107
+ /^\s*gh\s+run\s+(view|list)\b/i,
108
+ /^\s*git\s+blame\b/,
109
+ /^\s*git\s+shortlog\b/,
110
+ /^\s*git\s+stash\s+list\b/i,
111
+ /^\s*git\s+tag\s+(-l|--list)\b/i,
112
+ /^\s*git\s+describe\b/,
102
113
  ];
103
114
 
115
+ /** Split a compound command into individual sub-commands.
116
+ * Handles &&, ||, ;, and | (pipe) operators, ignoring leading whitespace.
117
+ */
118
+ function splitCompoundCommand(command: string): string[] {
119
+ // Match sub-commands separated by &&, ||, ; (with optional whitespace)
120
+ // We don't split on | to allow piping (e.g. `git log | head`)
121
+ return command
122
+ .split(/&&|\|\||;/)
123
+ .map((s) => s.trim())
124
+ .filter((s) => s.length > 0);
125
+ }
126
+
127
+ /** Strip stderr redirects that are purely cosmetic (no side effects). */
128
+ function stripHarmlessRedirects(cmd: string): string {
129
+ return cmd.replace(/\s*2\s*>\s*(\/dev\/null|&1)\b/g, "");
130
+ }
131
+
104
132
  export function isSafeCommand(command: string): boolean {
105
- const isDestructive = DESTRUCTIVE_PATTERNS.some((p) => p.test(command));
106
- const isSafe = SAFE_PATTERNS.some((p) => p.test(command));
107
- return !isDestructive && isSafe;
133
+ const parts = splitCompoundCommand(command);
134
+ return parts.every(
135
+ (part) => {
136
+ const cleaned = stripHarmlessRedirects(part);
137
+ const isDestructive = DESTRUCTIVE_PATTERNS.some((p) => p.test(cleaned));
138
+ const isSafe = SAFE_PATTERNS.some((p) => p.test(cleaned));
139
+ return !isDestructive && isSafe;
140
+ },
141
+ );
108
142
  }
109
143
 
110
144
  const SKILL_TO_PHASE: Record<string, Phase> = {
@@ -112,6 +146,18 @@ const SKILL_TO_PHASE: Record<string, Phase> = {
112
146
  "writing-plans": "plan",
113
147
  };
114
148
 
149
+ /** Determine if a write/edit to filePath should be blocked during the given phase.
150
+ * Only writes under docs/plans/ are allowed during brainstorm and plan phases.
151
+ */
152
+ export function shouldBlockFilePath(
153
+ filePath: string,
154
+ cwd: string,
155
+ ): boolean {
156
+ const absolute = resolve(cwd, filePath);
157
+ const plansDir = resolve(cwd, "docs/plans");
158
+ return !absolute.startsWith(plansDir + "/");
159
+ }
160
+
115
161
  export function getCurrentPhase(): Phase {
116
162
  return phase;
117
163
  }
@@ -166,9 +212,7 @@ export default function (pi: ExtensionAPI) {
166
212
  const filePath = (event.input as { path?: string }).path ?? "";
167
213
  if (!filePath) return;
168
214
 
169
- const absolute = resolve(ctx.cwd, filePath);
170
- const plansDir = resolve(ctx.cwd, "docs/plans");
171
- if (absolute.startsWith(plansDir + "/")) return;
215
+ if (!shouldBlockFilePath(filePath, ctx.cwd)) return;
172
216
 
173
217
  if (ctx.hasUI) {
174
218
  ctx.ui.notify(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tianhai/pi-workflow-kit",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "Workflow skills and enforcement extensions for pi",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -13,11 +13,7 @@ Read-only exploration. You may **not** edit or create any files except under `do
13
13
  2. **Understand the idea** — read existing code, docs, and recent commits. Ask questions one at a time to refine the idea. Prefer multiple choice when possible.
14
14
  3. **Explore approaches** — propose 2-3 approaches with trade-offs. Lead with your recommendation.
15
15
  4. **Present the design** — break it into sections of 200-300 words. Check after each section whether it looks right. Cover: architecture, components, data flow, error handling, testing.
16
- 5. **Set up workspace & write the design doc** — create a branch for this work. For larger features, use a git worktree for isolation:
17
- ```
18
- git worktree add ../<repo>-<feature-name> -b <feature-name>
19
- ```
20
- Save the design doc to `docs/plans/YYYY-MM-DD-<topic>-design.md` and commit on the new branch.
16
+ 5. **Write the design doc** — save it to `docs/plans/YYYY-MM-DD-<topic>-design.md`. Ask the user to commit it. Branch creation and worktree setup should be deferred to the execution phase (`/skill:executing-tasks`).
21
17
 
22
18
  ## Principles
23
19
 
@@ -7,6 +7,27 @@ description: "Use this to implement an approved plan task-by-task. Run after wri
7
7
 
8
8
  Implement the plan from `docs/plans/*-implementation.md` task by task.
9
9
 
10
+ ## Before you start
11
+
12
+ 1. **Check git state** — run `git status` and `git log --oneline -5`. Note any uncommitted changes.
13
+ 2. **Suggest workspace isolation** — if the user isn't already on a feature branch or worktree, present the options:
14
+
15
+ - **Branch** (smaller changes):
16
+ ```
17
+ git checkout -b <feature-name>
18
+ ```
19
+ - **Worktree** (larger features, keeps main clean):
20
+ ```
21
+ git worktree add ../<repo>-<feature-name> -b <feature-name>
22
+ ```
23
+
24
+ Derive `<feature-name>` from the plan doc (e.g. `docs/plans/2026-04-16-auth-design.md` → `auth`). Ask the user which they prefer, then wait for confirmation before proceeding.
25
+
26
+ 3. **Commit the plan docs** — if `docs/plans/` has uncommitted files, commit them on the new branch:
27
+ ```
28
+ git add docs/plans/ && git commit -m "docs: add design and implementation plan"
29
+ ```
30
+
10
31
  ## Per-task lifecycle
11
32
 
12
33
  Check each task for a `checkpoint` label and follow the appropriate flow:
@@ -9,7 +9,7 @@ Read-only exploration. You may **not** edit or create any files except under `do
9
9
 
10
10
  ## Process
11
11
 
12
- 1. **Check for a design doc & workspace** — look for `docs/plans/*-design.md`. If one exists, use it as the basis for the plan. Verify you're on the feature branch (or in its worktree) created during brainstorming. If no design doc exists, ask the user to describe what they want to build, read relevant code, create a branch, and create the plan directly.
12
+ 1. **Check for a design doc** — look for `docs/plans/*-design.md`. If one exists, use it as the basis for the plan. If no design doc exists, ask the user to describe what they want to build and read relevant code.
13
13
  2. **Write the implementation plan** — break the design into tasks. Save to `docs/plans/YYYY-MM-DD-<topic>-implementation.md`.
14
14
 
15
15
  ## Task format