pincer-workflow 0.2.2 → 0.3.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.
Files changed (34) hide show
  1. package/README.md +16 -15
  2. package/bin/pincer.js +52 -12
  3. package/package.json +2 -2
  4. package/template/.agents/skills/pincer-code/SKILL.md +19 -16
  5. package/template/.agents/skills/pincer-evaluate/SKILL.md +28 -9
  6. package/template/.agents/skills/pincer-narrow/SKILL.md +27 -21
  7. package/template/.agents/skills/pincer-plan/SKILL.md +28 -20
  8. package/template/.agents/skills/pincer-release/SKILL.md +14 -9
  9. package/template/.agents/skills/pincer-status/SKILL.md +1 -1
  10. package/template/.claude/commands/pincer-code.md +18 -15
  11. package/template/.claude/commands/pincer-evaluate.md +28 -9
  12. package/template/.claude/commands/pincer-narrow.md +25 -19
  13. package/template/.claude/commands/pincer-plan.md +26 -18
  14. package/template/.claude/commands/pincer-release.md +13 -8
  15. package/template/.claude/commands/pincer-status.md +1 -1
  16. package/template/.claude/hooks/block-dangerous.sh +7 -18
  17. package/template/.claude/hooks/hook-policy.cjs +258 -0
  18. package/template/.claude/hooks/ticket-guard.sh +6 -63
  19. package/template/.claude/references/prd-template.md +3 -3
  20. package/template/.claude/references/ticket-template.md +22 -4
  21. package/template/.codex/README.md +8 -4
  22. package/template/.github/prompts/pincer-code.prompt.md +18 -15
  23. package/template/.github/prompts/pincer-evaluate.prompt.md +28 -9
  24. package/template/.github/prompts/pincer-narrow.prompt.md +25 -19
  25. package/template/.github/prompts/pincer-plan.prompt.md +26 -18
  26. package/template/.github/prompts/pincer-release.prompt.md +13 -8
  27. package/template/.github/prompts/pincer-status.prompt.md +1 -1
  28. package/template/AGENTS.md +2 -3
  29. package/template/docs/dry-run-checklist.md +19 -15
  30. package/template/docs/release-checklist.md +34 -0
  31. package/template/scripts/pincer-status.sh +64 -23
  32. package/template/scripts/pincer-ticket-lib.sh +270 -0
  33. package/template/scripts/pincer-ticket.sh +57 -46
  34. package/template/scripts/sync-prompts.sh +6 -1
@@ -5,8 +5,9 @@ argument-hint: "Ticket numbers (optional — defaults to all open tickets in ord
5
5
 
6
6
  # /pincer-code — Ticket Implementation
7
7
 
8
- You are implementing the tickets in `tickets/` sequentially. Mostly autonomous: after the
9
- user confirms the starting point, run continuously and report progress between tickets.
8
+ You are implementing the tickets in `tickets/` sequentially. The approved PRD, ticket
9
+ breakdown, and existing session authorization define the work; run continuously and report
10
+ progress between tickets unless a material scope or design decision appears.
10
11
 
11
12
  **Initial request:** $ARGUMENTS
12
13
 
@@ -14,21 +15,22 @@ Ticket state lives in the ticket file's frontmatter and is written **only** by
14
15
  `scripts/pincer-ticket.sh` (`start` → `verify` → `done`). `verify` runs the ticket's
15
16
  Verification block and stamps a receipt only on a green exit; `done` refuses without a
16
17
  receipt that matches the current check, or with unticked acceptance criteria. Never edit
17
- `status`, `started`, `verified`, or `finished` by hand — on Claude Code a hook blocks it.
18
+ `status`, `started`, `last_check`, `verified`, or `finished` by hand — on Claude Code a hook blocks it.
18
19
 
19
20
  ## Before the loop
20
21
 
21
22
  Run `scripts/pincer-status.sh`. It lists every ticket's state, what is blocked, elapsed
22
23
  build time from the clock, and the next action. If a ticket is `in_progress`, you are
23
24
  resuming: read it, check `git status` / `git diff` for uncommitted work, and continue
24
- from wherever the receipt says you are. Confirm the starting point with the user, then go.
25
+ from wherever the receipt says you are. Do not ask the user to reconfirm unchanged,
26
+ previously authorized work.
25
27
 
26
28
  ## Loop (per ticket, in dependency order)
27
29
 
28
30
  1. **Start:** `scripts/pincer-ticket.sh start T-{NN}` — refuses while a `depends_on` ticket
29
31
  isn't done, and stamps the start time. Read the ticket and the files it references.
30
32
  Announce: "Starting T-{NN}: {title}."
31
- 2. **Implement.** Follow the conventions in `CLAUDE.md` and the PRD's architecture and
33
+ 2. **Implement.** Follow the conventions in `AGENTS.md` and the PRD's architecture and
32
34
  visual direction. Installing a dependency not named in the PRD's architecture is a
33
35
  stop-and-ask: verify it's the real package on the registry (linked repo, downloads —
34
36
  hallucinated names get typosquatted), say why it earns its place, and wait for a yes.
@@ -41,26 +43,27 @@ from wherever the receipt says you are. Confirm the starting point with the user
41
43
  4. **Self-review the diff** before committing: silent failures (empty catches,
42
44
  un-awaited promises), leftover debug code, drift from the ticket's acceptance criteria.
43
45
  Then a security sweep of the same diff:
44
- - No secret values: run
45
- `git diff | grep -iE '(api[_-]?key|secret|token|password)[[:space:]]*[:=]'`
46
- and treat any hit that isn't a `process.env` reference or a name in
47
- `.env.example` as a blocker.
46
+ - Check for secret-like assignments without printing values. If a scanner reports a
47
+ possible secret, report only its file and line until the value is safely redacted;
48
+ environment references and names in `.env.example` are allowed.
48
49
  - External input touched by this diff is validated server-side, and untrusted
49
50
  content (user input, LLM output) is escaped where rendered — per the
50
- Security defaults in `CLAUDE.md`.
51
+ Security defaults in `AGENTS.md`.
51
52
  - No error path leaks internals (stack traces, key names with values) to the client.
52
53
  If the review changed code, run `verify` again — the receipt must match the code you commit.
53
54
  5. **Close the ticket:** tick every verified acceptance-criteria checkbox (`- [ ]` → `- [x]`;
54
55
  editing the checkboxes is allowed), then `scripts/pincer-ticket.sh done T-{NN}`. A
55
56
  criterion that was cut is a scope change to record in the PRD, not a box to skip.
56
- Commit code and ticket file together: `git add -A && git commit -m "T-{NN}: {title}"`.
57
+ Inspect `git status --short`, preserve pre-existing staged work, and stage only the
58
+ explicit paths changed for this ticket plus its ticket file. Review `git diff --cached`
59
+ before committing as `T-{NN}: {title}`.
57
60
  6. Give a one-line progress update using the elapsed figure from
58
- `scripts/pincer-status.sh` ("T-02 done, 3 remaining, 38m elapsed of 75m") and continue.
61
+ `scripts/pincer-status.sh` ("T-02 done, 3 remaining, 38m elapsed") and continue.
59
62
 
60
- ## Timebox rules
63
+ ## Budget rules
61
64
 
62
- - The budget is ~75 minutes of build time, measured by `scripts/pincer-status.sh` from
63
- the first ticket's start stamp — never estimated. If the remaining tickets won't fit,
65
+ - If the user set `PINCER_BUILD_BUDGET_MIN` or stated another budget, use the elapsed
66
+ figure from `scripts/pincer-status.sh` rather than estimating. If the remaining tickets won't fit,
64
67
  stop and propose a scope cut: which remaining tickets to drop or shrink. Cutting scope
65
68
  deliberately beats an unfinished mess — record the cut in the PRD's Out of Scope.
66
69
  - If a ticket reveals the plan was wrong, stop and say so rather than silently diverging.
@@ -10,10 +10,15 @@ run the pipeline, then present results.
10
10
 
11
11
  ## Steps
12
12
 
13
- 1. Run `scripts/pincer-status.sh`. Every ticket should be `done` with a receipt; if one
13
+ 1. Run `scripts/pincer-status.sh`. Review only tickets associated with the selected
14
+ PRD. Every such ticket should be `done` with a current receipt; if one
14
15
  is still open or in progress, stop and ask whether it was cut (then it goes in the
15
16
  PRD's Out of Scope) or should be finished first via `/pincer-code`. Then get the full
16
- diff of the session: `git log --oneline` and `git diff <first-commit>..HEAD`.
17
+ diff of the change: identify the actual base commit before this change from
18
+ its ticket commits and recorded context. If it cannot be established, resolve
19
+ that uncertainty before claiming a complete review. Record full commit IDs for
20
+ `base` and `candidate` (`git rev-parse HEAD`), then review `git diff <base>..<candidate>`.
21
+ Require a clean candidate before review, excluding only the notes being written.
17
22
  2. Dispatch a `code-quality-reviewer` agent with: the diff, the PRD's Success Criteria and
18
23
  Scope sections, and the list of tickets. If the diff is large, split by area and
19
24
  dispatch two in parallel. (No subagents on this platform? Review the diff yourself
@@ -23,10 +28,11 @@ run the pipeline, then present results.
23
28
  4. If the project has a UI, look at it — don't only read the code. Start it, open it in
24
29
  the browser (screenshot via Chrome DevTools MCP if available), and check it against
25
30
  the PRD's Visual Direction and Success Criteria. Note anything visibly broken or off.
26
- 5. Run a mechanical security audit (cheap, ~2 min — do all of these):
27
- - Whole history, not just the tree:
28
- `git log -p | grep -iE '(api[_-]?key|secret|token|password)[[:space:]]*[:=]'`
29
- a secret committed then deleted is still leaked.
31
+ 5. Run a mechanical security audit:
32
+ - Inspect the relevant history with a secret scanner that redacts values, when one is
33
+ available. Otherwise review likely locations without copying candidate values into
34
+ output. Report file, line, and remediation only; a secret committed then deleted is
35
+ still leaked.
30
36
  - `.gitignore` covers `.env*` (except `.env.example`), and `git ls-files | grep -i env`
31
37
  shows only `.env.example`.
32
38
  - `npm audit --omit=dev` (or the ecosystem's equivalent) — report high/critical only.
@@ -37,9 +43,22 @@ run the pipeline, then present results.
37
43
  7. Present findings as a short list with `file:line` references, ordered by severity.
38
44
  Security findings always rank above style-adjacent ones. For each, say whether you
39
45
  recommend fixing now (within the timebox) or noting as known-issue.
40
- 8. Fix what the user approves (or everything clearly broken, if time allows), verify,
41
- and commit as `review: fixes`.
42
- 9. Close out: write a brief `NOTES.md` at the repo root what was built, what was cut
46
+ 8. Fix findings clearly within the authorized PRD through a new ticket associated with
47
+ that PRD. Use `pincer-ticket.sh` to start, verify, and close it, then make a scoped
48
+ `T-{NN}: {title}` commit. Ask only when a fix changes scope, architecture, or another
49
+ material decision; never make an ad-hoc `review: fixes` commit.
50
+ 9. Close out: write a brief `NOTES.md` at the repo root with frontmatter:
51
+ ```yaml
52
+ ---
53
+ prd: .prd/prd-vN.md
54
+ base: <full reviewed base commit ID>
55
+ candidate: <full reviewed candidate commit ID>
56
+ ---
57
+ ```
58
+ Record the candidate before the separate NOTES commit. Status accepts a later
59
+ commit only when its diff from the candidate changes solely `NOTES.md`; changes
60
+ to source, tickets, or PRD require reevaluation. Legacy notes without these
61
+ references do not establish readiness. Then describe what was built, what was cut
43
62
  and why, known issues, and what you'd do next with more time. Then a **Handover**
44
63
  section, written for the stranger who inherits this repo in six months: how to get
45
64
  oriented (which file to read first), what each dependency is for and why it earned
@@ -5,22 +5,26 @@ argument-hint: "Path to PRD (optional — defaults to the latest in .prd/)"
5
5
 
6
6
  # /pincer-narrow — PRD to Local Tickets
7
7
 
8
- You are decomposing the PRD into small, independently verifiable tickets stored as local
9
- markdown files (no external tracker needed). Target: 4–7 tickets that fit a ~75-minute
10
- build window.
8
+ You are decomposing the PRD into coherent, independently verifiable tickets stored as
9
+ local markdown files (no external tracker needed). Ticket count and size follow the
10
+ change's dependencies and risk, plus any budget the user supplied.
11
11
 
12
12
  **Initial request:** $ARGUMENTS
13
13
 
14
14
  ## Steps
15
15
 
16
- 1. Run `scripts/pincer-status.sh`. If tickets already exist, ask before adding to them
17
- new tickets continue the numbering, existing ones are never renumbered. Then read the
16
+ 1. Run `scripts/pincer-status.sh`. If tickets already exist for another PRD, leave them
17
+ as history. New tickets continue the numbering and existing ones are never renumbered.
18
+ If tickets already exist for this PRD, extend them only when the current request already
19
+ authorizes that work; otherwise present the concrete addition before asking. Then read the
18
20
  PRD (`$ARGUMENTS` or the latest `.prd/prd-v*.md`). If its status isn't `draft`, ask
19
21
  which PRD to use.
20
22
  2. Decompose into tickets. Rules:
21
- - Each ticket is one coherent unit: sized S or M, never L. Split anything larger.
22
- - Ticket 1 is always the walking skeleton: project scaffold + a thin end-to-end slice
23
- that runs. Everything after builds on a working base.
23
+ - Each ticket is one coherent unit. Use S, M, or L as relative scope indicators and
24
+ split work when that improves dependency order, verification, or ownership.
25
+ - For greenfield work, use a walking skeleton when it reduces integration risk. For
26
+ brownfield work, begin with the smallest protected vertical change; add a
27
+ characterization ticket before changing load-bearing code that lacks coverage.
24
28
  - Order by dependency; note blockers explicitly ("depends on T-01").
25
29
  - Every ticket gets a runnable command in its Verification block — a fenced `bash`
26
30
  block that exits 0 only when the ticket is done. `scripts/pincer-ticket.sh verify`
@@ -32,20 +36,22 @@ build window.
32
36
  LLM output) gets an acceptance criterion for the reject path — what invalid
33
37
  input produces (e.g. "empty goal → 400 with a clear message"), not only the
34
38
  happy path.
35
- - The walking skeleton (T-01) includes `.gitignore` covering `.env*` (except
36
- `.env.example`) and an `.env.example` naming any required secrets before
37
- any secret can exist in the repo.
38
- - Brownfield: a ticket that modifies load-bearing code with no test coverage
39
- is preceded by a characterization ticket — a test that pins the current
40
- behavior before any ticket is allowed to change it.
39
+ - A greenfield setup ticket includes `.gitignore` covering `.env*` (except
40
+ `.env.example`) and an `.env.example` naming any required secrets before any
41
+ secret can exist in the repo. In brownfield repositories, preserve and verify
42
+ the existing ignore and environment conventions.
41
43
  3. Write each ticket to `tickets/T-{NN}-{slug}.md` using
42
- `.claude/references/ticket-template.md`, with `status: open`. The other state fields
43
- (`started`, `verified`, `finished`) are added later by `scripts/pincer-ticket.sh`
44
+ `.claude/references/ticket-template.md`, with `status: open` and an explicit
45
+ `prd: .prd/prd-vN.md` naming the selected PRD. Never infer this association from
46
+ numbering or old notes. The other state fields
47
+ (`started`, `last_check`, `verified`, `finished`) are added later by `scripts/pincer-ticket.sh` —
44
48
  never write them yourself.
45
49
  4. Present the ticket list (number, title, size, dependencies) as a table.
46
50
 
47
- **Gate (medium):** Ask for approval of the breakdown and build order. Adjust if pushed back.
51
+ Present the concrete breakdown and build order. Reuse existing authorization for the same
52
+ scope and order; ask only when the breakdown introduces a material decision or scope change.
48
53
 
49
- 5. After approval, update the PRD frontmatter to `status: ticketed`, commit the tickets
50
- (`git add .prd tickets && git commit`), and finish with:
54
+ 5. Once authorized, update the selected PRD frontmatter to `status: ticketed`. Inspect
55
+ existing staged changes, stage that PRD and the explicit new ticket paths, review
56
+ `git diff --cached`, and commit only those paths. Finish with:
51
57
  "Tickets ready in `tickets/`. Run `/pincer-code` to start implementing."
@@ -5,17 +5,19 @@ argument-hint: "Brief description of the task/feature (paste the full brief if y
5
5
 
6
6
  # /pincer-plan — PRD Creation
7
7
 
8
- You are turning a task brief into a compact PRD. This runs inside a short delivery
9
- timebox (~2 hours total), so discovery is brief and the PRD is lean. The PRD feeds
8
+ You are turning a task brief into a reviewable PRD. Scale discovery and detail to
9
+ the change's uncertainty, risk, and any time budget the user supplied. The PRD feeds
10
10
  `/pincer-narrow` next.
11
11
 
12
12
  **Initial request:** $ARGUMENTS
13
13
 
14
- First run `scripts/pincer-status.sh`. If a PRD already exists, say so and ask whether
15
- this is a new version of it (`.prd/prd-v{N+1}.md` old versions are never overwritten)
16
- or a fresh start; if tickets are in progress, stop and point at `/pincer-code` instead.
14
+ First run `scripts/pincer-status.sh`. If a PRD already exists, preserve it and select
15
+ the next unused numeric version for this change. Use the brief and repository state to
16
+ distinguish a revision from a new change; ask only if that distinction changes scope or
17
+ architecture. If tickets for the current PRD are in progress, resume `/pincer-code`
18
+ unless the user explicitly authorized a separate change.
17
19
 
18
- ## Phase 1: Discovery (~5 min)
20
+ ## Phase 1: Discovery
19
21
 
20
22
  1. If `$ARGUMENTS` contains the brief, extract what you can before asking anything.
21
23
  Never ask a question the brief already answers.
@@ -27,9 +29,10 @@ or a fresh start; if tickets are in progress, stop and point at `/pincer-code` i
27
29
  3. If the project has a frontend, ask one design question: "What should this feel like,
28
30
  and what should it NOT look like?" Capture the answer for the Visual Direction section.
29
31
 
30
- Summarize your understanding in 3–5 sentences and confirm before moving on.
32
+ Summarize your understanding in 3–5 sentences. Existing authorization in the request or
33
+ session carries forward; ask only about an unresolved choice that materially changes the result.
31
34
 
32
- ## Phase 2: Codebase scan (conditional, ~5 min)
35
+ ## Phase 2: Codebase scan (conditional)
33
36
 
34
37
  If the repo already contains source code, launch 1–2 `codebase-explorer` agents in parallel
35
38
  (one for architecture/structure, one for patterns relevant to the feature). Read the 2–3 most
@@ -44,7 +47,7 @@ touches are load-bearing, what test coverage protects them (run the suite, don't
44
47
  and the blast radius + rollback story for the change. Record these in the PRD's
45
48
  Architecture section. Greenfield speed assumptions do not transfer to brownfield work.
46
49
 
47
- ## Phase 3: Architecture (~5 min)
50
+ ## Phase 3: Architecture
48
51
 
49
52
  Propose the architecture: components, data flow, integration points, and key decisions.
50
53
  - Recommend one approach; mention an alternative only when the trade-off is real.
@@ -57,23 +60,28 @@ Propose the architecture: components, data flow, integration points, and key dec
57
60
  - Verify the contract of any external API the plan builds on (one live request or the
58
61
  current official docs) before designing around it — endpoint shapes remembered from
59
62
  training data are guesses.
60
- - Bias every decision toward "finishable in the remaining time". Cut before you gold-plate.
63
+ - Respect any explicit delivery budget. Record deliberate cuts in Out of Scope.
61
64
 
62
- **Gate (heavy):** Ask for explicit approval of the architecture before writing the PRD.
65
+ Prepare the full draft before seeking any approval still required. The user should review a
66
+ concrete scope and architecture; do not repeat an approval already given for the same decision.
63
67
 
64
- ## Phase 4: Write the PRD (~5 min)
68
+ ## Phase 4: Write the PRD
65
69
 
66
70
  1. Load `.claude/references/prd-template.md` and write all core sections.
67
- 2. Include optional sections only when they earn their space in the timebox.
68
- 3. Save to `.prd/prd-v1.md` (create `.prd/` if needed) with frontmatter:
71
+ 2. Include optional sections when risk or the product context warrants them.
72
+ 3. Save to the next unused `.prd/prd-v{N}.md` (create `.prd/` if needed), with `N`
73
+ matching the filename and frontmatter:
69
74
  ```yaml
70
75
  ---
71
- version: 1
76
+ version: {N}
72
77
  status: draft
73
78
  date: {today}
74
79
  ---
75
80
  ```
76
- 4. If `.git/` doesn't exist, run `git init` and make an initial commit containing the
77
- PRD and this `.claude/` setup planning should be visible in the history.
81
+ 4. If `.git/` doesn't exist, run `git init`. Commit the PRD and only the intended setup
82
+ paths after inspecting existing staged work; planning should be visible in history
83
+ without absorbing unrelated brownfield changes.
78
84
 
79
- Finish with: "PRD saved to `.prd/prd-v1.md`. Run `/pincer-narrow` to break it into work items."
85
+ Present the saved draft and obtain approval only when the same scope/architecture was not
86
+ already authorized. Finish with: "PRD saved to `.prd/prd-v{N}.md`. Run `/pincer-narrow`
87
+ to break it into work items."
@@ -5,24 +5,29 @@ argument-hint: "Stage to check (optional: plan | narrow | code | evaluate — de
5
5
 
6
6
  # /pincer-release — Workflow Audit
7
7
 
8
- You are auditing the current repo state against `docs/dry-run-checklist.md`. Read-only:
8
+ You are auditing the current repo state against `docs/release-checklist.md`. Read-only:
9
9
  report pass/fail, never fix anything — fixes belong to the stage commands.
10
10
 
11
11
  **Requested stage:** $ARGUMENTS
12
12
 
13
13
  ## Steps
14
14
 
15
- 1. Read `docs/dry-run-checklist.md` — it is the source of truth for what to check.
16
- 2. Run `scripts/pincer-status.sh` to determine which stages have run (it reads `.prd/`,
17
- `tickets/`, `NOTES.md`; add `git log`). If `$ARGUMENTS` names a stage, check only up
15
+ 1. Read `docs/release-checklist.md` — it is the source of truth for product-candidate
16
+ readiness. `docs/dry-run-checklist.md` is a separate manual platform trial and must
17
+ not impose toy-project or Pincer-kit assumptions on this audit.
18
+ 2. Run `scripts/pincer-status.sh` to determine the selected PRD and which stages have run
19
+ (it reads `.prd/`, associated `tickets/`, `NOTES.md`; add `git log`). If `$ARGUMENTS`
20
+ names a stage, check only up
18
21
  to that stage.
19
22
  3. Check every applicable item mechanically where possible:
20
23
  - File existence and frontmatter: read the files.
21
24
  - Commit format and story: `git log --oneline`.
22
- - Receipts: every done ticket carries `verified:` (a status warning means one was
23
- marked done by hand). Re-run at least two checks with
24
- `scripts/pincer-ticket.sh verify T-{NN}` on a done ticket it re-checks without
25
- touching the receipt and report actual output.
25
+ - Receipts: every done ticket carries current `last_check` and `verified` evidence;
26
+ any status warning fails the audit. Do not call `pincer-ticket.sh` from Release:
27
+ it writes receipts and would invalidate the evaluated candidate.
28
+ - Run the repository's candidate-wide release gate directly (`npm test`, or the
29
+ equivalent declared by the project) and report its actual output. Any failure
30
+ blocks PASS. Confirm `git status --short` remains clean afterward.
26
31
  4. For judgment items (tickets genuinely S/M, history reads as a story), give your
27
32
  verdict AND one sentence of evidence — never a bare pass.
28
33
  5. Present a table: checklist item | pass/fail/skipped | evidence. Order by stage.
@@ -12,7 +12,7 @@ start of a session. Read-only: change nothing.
12
12
 
13
13
  1. Run `scripts/pincer-status.sh`. It reads the artifacts on disk (`.prd/`, `tickets/`,
14
14
  `NOTES.md`) and prints the PRD state, every ticket with its state and clock-based
15
- elapsed time, what is blocked, build time against the budget, any warnings (a ticket
15
+ elapsed time, what is blocked, build time against any explicit user budget, any warnings (a ticket
16
16
  marked done without a verification receipt), and the next command to run.
17
17
  2. Report in three lines: where the workflow is, what is in progress or blocked, and the
18
18
  next command. Quote the `Next` line as-is.
@@ -1,20 +1,9 @@
1
1
  #!/bin/bash
2
- # PreToolUse guardrail for Bash commands. Blocks destructive or pipe-to-shell
3
- # patterns regardless of permission mode these are never auto-approved; a
4
- # human runs them in their own terminal if truly intended.
5
- # Exit 2 blocks the tool call; stderr goes back to the agent.
6
-
7
- input=$(cat)
8
-
9
- if printf '%s' "$input" | grep -qE \
10
- -e 'rm -rf?( -[a-z]+)* +(/|~|\$HOME)' \
11
- -e 'git push[^|;&]*(--force|-f)( |$)' \
12
- -e '(curl|wget)[^|;&]*\|[[:space:]]*(sudo )?(ba|z)?sh' \
13
- -e 'chmod( -R)? +777' \
14
- -e '--dangerously-skip-permissions' \
15
- -e 'git reset --hard[^|;&]*origin/'; then
16
- echo "Blocked by PINCER guardrail: destructive or pipe-to-shell command. If this is genuinely intended, the user runs it manually in their own terminal." >&2
2
+ # PreToolUse guardrail for Bash commands. The Node helper parses the hook JSON
3
+ # before inspecting the actual command and recognizes the documented command
4
+ # forms without matching harmless strings elsewhere in the payload.
5
+ command -v node >/dev/null 2>&1 || {
6
+ echo 'Blocked by PINCER guardrail: Node.js is required to parse hook input safely.' >&2
17
7
  exit 2
18
- fi
19
-
20
- exit 0
8
+ }
9
+ exec node "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/hook-policy.cjs" dangerous
@@ -0,0 +1,258 @@
1
+ #!/usr/bin/env node
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+
5
+ const mode = process.argv[2];
6
+ const input = fs.readFileSync(0, 'utf8');
7
+
8
+ function block(message) {
9
+ const label = mode === 'ticket' ? 'ticket guard' : 'guardrail';
10
+ process.stderr.write(`Blocked by PINCER ${label}: ${message}\n`);
11
+ process.exit(2);
12
+ }
13
+
14
+ let payload;
15
+ try {
16
+ payload = JSON.parse(input);
17
+ } catch {
18
+ block('hook input is not valid JSON.');
19
+ }
20
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload) ||
21
+ typeof payload.tool_name !== 'string' || !payload.tool_input ||
22
+ typeof payload.tool_input !== 'object' || Array.isArray(payload.tool_input)) {
23
+ block('hook input must contain tool_name and a tool_input object.');
24
+ }
25
+
26
+ function lexShell(source) {
27
+ const tokens = [];
28
+ let word = '';
29
+ let quote = '';
30
+ const push = () => { if (word) tokens.push({ value: word }); word = ''; };
31
+ for (let i = 0; i < source.length; i++) {
32
+ const ch = source[i];
33
+ if (quote) {
34
+ if (ch === quote) quote = '';
35
+ else if (ch === '\\' && quote === '"' && i + 1 < source.length) word += source[++i];
36
+ else word += ch;
37
+ continue;
38
+ }
39
+ if (ch === "'" || ch === '"') { quote = ch; continue; }
40
+ if (ch === '\\' && i + 1 < source.length) { word += source[++i]; continue; }
41
+ if (/\s/.test(ch)) {
42
+ push();
43
+ if (ch === '\n') tokens.push({ op: ';' });
44
+ continue;
45
+ }
46
+ if ('|;&><()'.includes(ch)) {
47
+ push();
48
+ const pair = source.slice(i, i + 2);
49
+ if (['||', '&&', '>>', '<<'].includes(pair)) { tokens.push({ op: pair }); i++; }
50
+ else tokens.push({ op: ch });
51
+ continue;
52
+ }
53
+ word += ch;
54
+ }
55
+ push();
56
+ return tokens;
57
+ }
58
+
59
+ function shellCommands(source) {
60
+ const commands = [];
61
+ let words = [];
62
+ let operators = [];
63
+ const finish = (separator = '') => {
64
+ if (words.length || operators.length) commands.push({ words, operators, separator });
65
+ words = []; operators = [];
66
+ };
67
+ for (const token of lexShell(source)) {
68
+ if (token.value !== undefined) words.push(token.value);
69
+ else if (['|', ';', '&&', '||', '(', ')'].includes(token.op)) finish(token.op);
70
+ else operators.push(token.op);
71
+ }
72
+ finish();
73
+ return commands;
74
+ }
75
+
76
+ function commandParts(command) {
77
+ const words = [...command.words];
78
+ let i = 0;
79
+ while (i < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i])) i++;
80
+ if (words[i] === 'env') {
81
+ i++;
82
+ while (i < words.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]) || words[i].startsWith('-'))) i++;
83
+ }
84
+ if (words[i] === 'sudo') {
85
+ i++;
86
+ while (i < words.length && words[i].startsWith('-')) i++;
87
+ }
88
+ while (['command', 'builtin', 'exec'].includes(words[i])) i++;
89
+ const executable = words[i] ? path.basename(words[i]) : '';
90
+ return { executable, args: words.slice(i + 1), words };
91
+ }
92
+
93
+ function gitSubcommand(args) {
94
+ let i = 0;
95
+ while (i < args.length) {
96
+ if (['-C', '-c', '--git-dir', '--work-tree'].includes(args[i])) i += 2;
97
+ else if (args[i].startsWith('-')) i++;
98
+ else return { name: args[i], args: args.slice(i + 1) };
99
+ }
100
+ return { name: '', args: [] };
101
+ }
102
+
103
+ function dangerousReason(source, depth = 0) {
104
+ if (depth > 2) return '';
105
+ const commands = shellCommands(source);
106
+ for (let i = 0; i < commands.length; i++) {
107
+ const { executable, args, words } = commandParts(commands[i]);
108
+ if ((executable === 'claude' || (executable === 'npx' && args.some(arg => arg === 'claude'))) &&
109
+ words.includes('--dangerously-skip-permissions')) return 'permission bypass flags are not allowed.';
110
+ if (executable === 'git') {
111
+ const sub = gitSubcommand(args);
112
+ if (sub.name === 'push' && sub.args.some(arg => arg === '-f' || arg === '--force' || arg.startsWith('--force=')))
113
+ return 'force-push commands are not allowed.';
114
+ if (sub.name === 'reset' && sub.args.includes('--hard') && sub.args.some(arg => /^origin\//.test(arg)))
115
+ return 'hard resets to a remote branch are not allowed.';
116
+ }
117
+ if (executable === 'rm') {
118
+ let recursive = false, force = false;
119
+ const targets = [];
120
+ for (const arg of args) {
121
+ if (arg === '--recursive') recursive = true;
122
+ else if (arg === '--force') force = true;
123
+ else if (/^-[^-]/.test(arg)) { recursive ||= /[rR]/.test(arg.slice(1)); force ||= /f/.test(arg.slice(1)); }
124
+ else if (arg !== '--') targets.push(arg);
125
+ }
126
+ if (recursive && force && targets.some(arg => arg.startsWith('/') || arg === '~' || arg.startsWith('~/') || arg === '$HOME' || arg.startsWith('$HOME/') || arg.startsWith('${HOME}')))
127
+ return 'recursive forced deletion of an absolute or home path is not allowed.';
128
+ }
129
+ if (executable === 'chmod' && args.some(arg => /^(0?777|a\+rwx)$/.test(arg)))
130
+ return 'mass permission changes are not allowed.';
131
+ if (['sh', 'bash', 'zsh'].includes(executable)) {
132
+ const c = args.indexOf('-c');
133
+ if (c !== -1 && typeof args[c + 1] === 'string') {
134
+ const nested = dangerousReason(args[c + 1], depth + 1);
135
+ if (nested) return nested;
136
+ }
137
+ }
138
+ if (['curl', 'wget'].includes(executable) && commands[i].separator === '|') {
139
+ const next = commandParts(commands[i + 1] || { words: [] }).executable;
140
+ if (['sh', 'bash', 'zsh'].includes(next)) return 'downloading content directly into a shell is not allowed.';
141
+ }
142
+ }
143
+ return '';
144
+ }
145
+
146
+ const TICKET_PATH = /(^|[\\/])tickets[\\/]T-[0-9]+[^\\/]*\.md$/;
147
+ const PROTECTED = ['status', 'started', 'last_check', 'verified', 'finished'];
148
+
149
+ function ticketPath(value) {
150
+ return typeof value === 'string' && TICKET_PATH.test(value);
151
+ }
152
+
153
+ function stateFields(content) {
154
+ const result = Object.fromEntries(PROTECTED.map(key => [key, []]));
155
+ let scope = String(content ?? '');
156
+ if (scope.startsWith('---\n')) {
157
+ const end = scope.indexOf('\n---', 4);
158
+ if (end !== -1) scope = scope.slice(4, end + 1);
159
+ }
160
+ for (const line of scope.split(/\r?\n/)) {
161
+ const match = line.match(/^([a-z_]+):[ \t]*(.*)$/);
162
+ if (match && PROTECTED.includes(match[1])) result[match[1]].push(match[2]);
163
+ }
164
+ return result;
165
+ }
166
+
167
+ const sameState = (a, b) => JSON.stringify(stateFields(a)) === JSON.stringify(stateFields(b));
168
+
169
+ function absoluteToolPath(file) {
170
+ const root = process.env.CLAUDE_PROJECT_DIR || process.cwd();
171
+ return path.isAbsolute(file) ? file : path.resolve(root, file);
172
+ }
173
+
174
+ function existingContent(file) {
175
+ try { return fs.readFileSync(absoluteToolPath(file), 'utf8'); }
176
+ catch { return null; }
177
+ }
178
+
179
+ function applyEdit(content, oldText, newText, replaceAll = false) {
180
+ if (typeof oldText !== 'string' || typeof newText !== 'string') block('edit payload must contain string old_string and new_string values.');
181
+ if (!oldText) block('edit old_string must not be empty.');
182
+ if (replaceAll) return content.split(oldText).join(newText);
183
+ const index = content.indexOf(oldText);
184
+ return index === -1 ? content : content.slice(0, index) + newText + content.slice(index + oldText.length);
185
+ }
186
+
187
+ function guardEdits(tool, toolInput) {
188
+ const file = toolInput.file_path;
189
+ if (typeof file !== 'string') block(`${tool} payload must contain a string file_path.`);
190
+ if (!ticketPath(file)) return;
191
+ const before = existingContent(file);
192
+ if (tool === 'Write') {
193
+ if (typeof toolInput.content !== 'string') block('Write payload must contain string content.');
194
+ if (before !== null) {
195
+ if (!sameState(before, toolInput.content)) block('ticket lifecycle fields may only be changed by pincer-ticket.sh.');
196
+ } else {
197
+ const state = stateFields(toolInput.content);
198
+ if (state.status.length !== 1 || state.status[0].split(/[ \t]+#/)[0].trim() !== 'open' ||
199
+ PROTECTED.slice(1).some(key => state[key].length))
200
+ block('new tickets must start with status: open and no lifecycle receipts or timestamps.');
201
+ }
202
+ return;
203
+ }
204
+ const edits = tool === 'MultiEdit' ? toolInput.edits : [toolInput];
205
+ if (!Array.isArray(edits)) block('MultiEdit payload must contain an edits array.');
206
+ let after = before ?? '';
207
+ for (const edit of edits) {
208
+ if (!edit || typeof edit !== 'object') block('each edit must be an object.');
209
+ after = applyEdit(after, edit.old_string, edit.new_string, edit.replace_all === true);
210
+ }
211
+ if (!sameState(before ?? '', after)) block('ticket lifecycle fields may only be changed by pincer-ticket.sh.');
212
+ }
213
+
214
+ function isExactPincerCall(source) {
215
+ const commands = shellCommands(source).filter(command => command.words.length);
216
+ if (commands.length !== 1 || commands[0].separator) return false;
217
+ const { executable, args } = commandParts(commands[0]);
218
+ let words = [executable, ...args];
219
+ if (['bash', 'sh'].includes(words[0])) words = words.slice(1);
220
+ if (!/pincer-ticket\.sh$/.test(words[0] || '')) return false;
221
+ const action = words[1];
222
+ if (!['start', 'verify', 'done', 'bind'].includes(action)) return false;
223
+ return action === 'bind' ? words.length === 4 : words.length === 3;
224
+ }
225
+
226
+ function ticketShellMutation(source) {
227
+ if (isExactPincerCall(source)) return false;
228
+ const commands = shellCommands(source);
229
+ for (const command of commands) {
230
+ const { executable, args, words } = commandParts(command);
231
+ const hasTicket = words.some(ticketPath) || /(^|[\s'"`])tickets[\\/]T-[0-9]+[^\s'"`]*/.test(source);
232
+ if (!hasTicket) continue;
233
+ if (command.operators.some(op => op === '>' || op === '>>')) return true;
234
+ if (['rm', 'mv', 'cp', 'install', 'truncate', 'touch', 'tee', 'ed', 'ex'].includes(executable)) return true;
235
+ if (['python', 'python3', 'node', 'ruby'].includes(executable)) return true;
236
+ if (['sed', 'perl'].includes(executable) && args.some(arg => /^-[^-]*i/.test(arg) || arg === '--in-place' || arg.startsWith('--in-place='))) return true;
237
+ if (executable === 'git') {
238
+ const sub = gitSubcommand(args).name;
239
+ if (['checkout', 'restore', 'reset', 'clean'].includes(sub)) return true;
240
+ }
241
+ }
242
+ return false;
243
+ }
244
+
245
+ if (mode === 'dangerous') {
246
+ if (payload.tool_name !== 'Bash') process.exit(0);
247
+ if (typeof payload.tool_input.command !== 'string') block('Bash payload must contain a string command.');
248
+ const reason = dangerousReason(payload.tool_input.command);
249
+ if (reason) block(reason);
250
+ } else if (mode === 'ticket') {
251
+ if (['Edit', 'Write', 'MultiEdit'].includes(payload.tool_name)) guardEdits(payload.tool_name, payload.tool_input);
252
+ else if (payload.tool_name === 'Bash') {
253
+ if (typeof payload.tool_input.command !== 'string') block('Bash payload must contain a string command.');
254
+ if (ticketShellMutation(payload.tool_input.command)) block('shell commands may not write or reset ticket files; use pincer-ticket.sh for lifecycle state.');
255
+ }
256
+ } else {
257
+ block('hook policy mode is invalid.');
258
+ }