pincer-workflow 0.2.3 → 0.4.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 (35) hide show
  1. package/README.md +19 -14
  2. package/bin/pincer.js +47 -11
  3. package/package.json +2 -2
  4. package/template/.agents/skills/pincer-code/SKILL.md +62 -17
  5. package/template/.agents/skills/pincer-evaluate/SKILL.md +85 -15
  6. package/template/.agents/skills/pincer-narrow/SKILL.md +67 -22
  7. package/template/.agents/skills/pincer-plan/SKILL.md +62 -25
  8. package/template/.agents/skills/pincer-release/SKILL.md +31 -12
  9. package/template/.agents/skills/pincer-status/SKILL.md +5 -3
  10. package/template/.claude/commands/pincer-code.md +61 -16
  11. package/template/.claude/commands/pincer-evaluate.md +85 -15
  12. package/template/.claude/commands/pincer-narrow.md +65 -20
  13. package/template/.claude/commands/pincer-plan.md +57 -20
  14. package/template/.claude/commands/pincer-release.md +30 -11
  15. package/template/.claude/commands/pincer-status.md +5 -3
  16. package/template/.claude/hooks/block-dangerous.sh +7 -18
  17. package/template/.claude/hooks/hook-policy.cjs +351 -0
  18. package/template/.claude/hooks/ticket-guard.sh +6 -63
  19. package/template/.claude/references/prd-template.md +41 -9
  20. package/template/.claude/references/ticket-template.md +37 -4
  21. package/template/.codex/README.md +4 -4
  22. package/template/.github/prompts/pincer-code.prompt.md +61 -16
  23. package/template/.github/prompts/pincer-evaluate.prompt.md +85 -15
  24. package/template/.github/prompts/pincer-narrow.prompt.md +65 -20
  25. package/template/.github/prompts/pincer-plan.prompt.md +57 -20
  26. package/template/.github/prompts/pincer-release.prompt.md +30 -11
  27. package/template/.github/prompts/pincer-status.prompt.md +5 -3
  28. package/template/AGENTS.md +11 -5
  29. package/template/docs/dry-run-checklist.md +143 -27
  30. package/template/docs/release-checklist.md +35 -0
  31. package/template/scripts/pincer-evidence.cjs +292 -0
  32. package/template/scripts/pincer-status.sh +83 -26
  33. package/template/scripts/pincer-ticket-lib.sh +321 -0
  34. package/template/scripts/pincer-ticket.sh +58 -47
  35. package/template/scripts/sync-prompts.sh +6 -1
@@ -5,20 +5,34 @@ 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
+ Choose the planning profile and record why it fits: `profile: small` for bounded
13
+ scope, low risk, known behavior and straightforward verification; otherwise
14
+ `standard` (the default). Few changed lines alone do not qualify — migrations,
15
+ authorization boundaries, uncertain requirements and broad effects need proper
16
+ investigation even for a tiny patch. Honor an explicit budget the user supplied;
17
+ there is no default timebox, and a budget never silently cuts requirements —
18
+ record any cut in Out of Scope.
19
+
12
20
  **Initial request:** $ARGUMENTS
13
21
 
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.
22
+ First run `scripts/pincer-status.sh`. If a PRD already exists, preserve it and select
23
+ the next unused numeric version for this change. Use the brief and repository state to
24
+ distinguish a revision from a new change; ask only if that distinction changes scope or
25
+ architecture. If tickets for the current PRD are in progress, resume `/pincer-code`
26
+ unless the user explicitly authorized a separate change.
17
27
 
18
- ## Phase 1: Discovery (~5 min)
28
+ ## Phase 1: Discovery
19
29
 
20
30
  1. If `$ARGUMENTS` contains the brief, extract what you can before asking anything.
21
- Never ask a question the brief already answers.
31
+ Never ask a question the brief already answers. Preserve or link the original
32
+ brief in the PRD, and record the desired outcome, assumptions and exclusions.
33
+ If the user supplied a PRD, review it: keep its meaning and existing requirement
34
+ IDs; do not silently replace either. Where its structure needs adapting to the
35
+ template, record a mapping table (`their section or ID → R-NN`) inside the PRD.
22
36
  2. Ask only the questions whose answers would change the architecture or scope.
23
37
  Batch them (max 3–4 at once). Typical ones:
24
38
  - What does "done" look like — what will be run, demoed, or reviewed at the end?
@@ -27,9 +41,10 @@ or a fresh start; if tickets are in progress, stop and point at `/pincer-code` i
27
41
  3. If the project has a frontend, ask one design question: "What should this feel like,
28
42
  and what should it NOT look like?" Capture the answer for the Visual Direction section.
29
43
 
30
- Summarize your understanding in 3–5 sentences and confirm before moving on.
44
+ Summarize your understanding in 3–5 sentences. Existing authorization in the request or
45
+ session carries forward; ask only about an unresolved choice that materially changes the result.
31
46
 
32
- ## Phase 2: Codebase scan (conditional, ~5 min)
47
+ ## Phase 2: Codebase scan (conditional)
33
48
 
34
49
  If the repo already contains source code, launch 1–2 `codebase-explorer` agents in parallel
35
50
  (one for architecture/structure, one for patterns relevant to the feature). Read the 2–3 most
@@ -44,7 +59,7 @@ touches are load-bearing, what test coverage protects them (run the suite, don't
44
59
  and the blast radius + rollback story for the change. Record these in the PRD's
45
60
  Architecture section. Greenfield speed assumptions do not transfer to brownfield work.
46
61
 
47
- ## Phase 3: Architecture (~5 min)
62
+ ## Phase 3: Architecture
48
63
 
49
64
  Propose the architecture: components, data flow, integration points, and key decisions.
50
65
  - Recommend one approach; mention an alternative only when the trade-off is real.
@@ -57,23 +72,45 @@ Propose the architecture: components, data flow, integration points, and key dec
57
72
  - Verify the contract of any external API the plan builds on (one live request or the
58
73
  current official docs) before designing around it — endpoint shapes remembered from
59
74
  training data are guesses.
60
- - Bias every decision toward "finishable in the remaining time". Cut before you gold-plate.
75
+ - Respect any explicit delivery budget. Record deliberate cuts in Out of Scope.
61
76
 
62
- **Gate (heavy):** Ask for explicit approval of the architecture before writing the PRD.
77
+ Prepare the full draft before seeking any approval still required. The user should review a
78
+ concrete scope and architecture; do not repeat an approval already given for the same decision.
63
79
 
64
- ## Phase 4: Write the PRD (~5 min)
80
+ ## Phase 4: Write the PRD
65
81
 
66
82
  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:
83
+ In Requirements, assign stable `R-NN` IDs within the selected PRD: a revision
84
+ keeps existing IDs and adds new ones, never renumbers. Every requirement has
85
+ observable acceptance scenarios, the relevant failure paths, and the existing
86
+ behavior it must preserve — `/pincer-narrow` maps each scenario to a ticket and
87
+ a check, and `/pincer-evaluate` dispositions every ID.
88
+ 2. Include optional sections when risk or the product context warrants them.
89
+ 3. Save to the next unused `.prd/prd-v{N}.md` (create `.prd/` if needed), with `N`
90
+ matching the filename and frontmatter:
69
91
  ```yaml
70
92
  ---
71
- version: 1
93
+ version: {N}
72
94
  status: draft
73
95
  date: {today}
96
+ profile: small # only when small; omit for standard
74
97
  ---
75
98
  ```
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.
78
-
79
- Finish with: "PRD saved to `.prd/prd-v1.md`. Run `/pincer-narrow` to break it into work items."
99
+ 4. If `.git/` doesn't exist, run `git init`. Commit the PRD and only the intended setup
100
+ paths after inspecting existing staged work; planning should be visible in history
101
+ without absorbing unrelated brownfield changes.
102
+
103
+ Present the saved draft and obtain approval only when the same scope/architecture was not
104
+ already authorized. Finish with: "PRD saved to `.prd/prd-v{N}.md`. Run `/pincer-narrow`
105
+ to break it into work items."
106
+
107
+ ## Authorization rule (shared by plan, narrow, code and evaluate)
108
+
109
+ Reuse explicit authorization for the same scope and decisions; ask only about a
110
+ material choice not already authorized, and prepare the concrete proposal before
111
+ asking. A decision the user delegated (for example "pick the architecture") does not
112
+ need another approval when you exercise it, but a newly discovered consequential
113
+ choice is surfaced before implementation. Record the authorization basis and the
114
+ scope it covers in the PRD or the handover. An agent-written record or a status
115
+ field is not authenticated human approval. When resuming without the context that
116
+ granted authorization, do not invent it — ask.
@@ -5,28 +5,47 @@ 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:
9
- report pass/fail, never fix anything — fixes belong to the stage commands.
8
+ You are auditing the current repo state against `docs/release-checklist.md`. Read-only:
9
+ report pass/fail, never fix anything — fixes belong to the stage commands. Release does
10
+ not repair tickets, rewrite evidence, change PRD state, or publish. A check that mutates
11
+ the candidate invalidates the audit: if `git status --short` is not empty afterwards,
12
+ the verdict is FAIL and names the mutation. The verdict is reported to the user; a
13
+ durable runtime-owned release record is later work.
10
14
 
11
15
  **Requested stage:** $ARGUMENTS
12
16
 
13
17
  ## Steps
14
18
 
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
19
+ 1. Read `docs/release-checklist.md` — it is the source of truth for product-candidate
20
+ readiness. `docs/dry-run-checklist.md` is a separate manual platform trial and must
21
+ not impose toy-project or Pincer-kit assumptions on this audit.
22
+ 2. Run `scripts/pincer-status.sh` to determine the selected PRD and which stages have run
23
+ (it reads `.prd/`, associated `tickets/`, `NOTES.md`; add `git log`). If `$ARGUMENTS`
24
+ names a stage, check only up
18
25
  to that stage.
19
26
  3. Check every applicable item mechanically where possible:
20
27
  - File existence and frontmatter: read the files.
21
28
  - 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.
29
+ - Receipts: every done ticket carries current `last_check` and `verified` evidence;
30
+ any status warning fails the audit. Do not call `pincer-ticket.sh` from Release:
31
+ it writes receipts and would invalidate the evaluated candidate.
32
+ - Evidence: `scripts/pincer-status.sh` runs the shared validator
33
+ (`scripts/pincer-evidence.cjs`) against the manifest `NOTES.md` names. The `Notes`
34
+ line must read `current` and the `Evidence` line `ok`; any other text fails the
35
+ audit with that diagnostic. Do not re-implement evidence checks and do not accept
36
+ screenshots described in chat: read the manifest's `checks`, requirement
37
+ dispositions and `visual_review`. Validation establishes that the record is
38
+ consistent, not that the commands ran — say so if asked.
39
+ - Every file the manifest lists is tracked, and `git status --short` is empty before
40
+ and after the audit.
41
+ - Run the repository's candidate-wide release gate directly (`npm test`, or the
42
+ equivalent declared by the project) and report its actual output. Any failure
43
+ blocks PASS. Confirm `git status --short` remains clean afterward.
26
44
  4. For judgment items (tickets genuinely S/M, history reads as a story), give your
27
45
  verdict AND one sentence of evidence — never a bare pass.
28
46
  5. Present a table: checklist item | pass/fail/skipped | evidence. Order by stage.
29
- 6. End with a one-line verdict: "PASS — workflow artifacts complete" or
30
- "FAIL — {n} items failed; fix via {command}". For each failure, name the command
47
+ 6. End with a one-line verdict naming the candidate: "PASS — candidate {sha}: workflow
48
+ artifacts complete" or "FAIL — candidate {sha}: {n} items failed; fix via {command}".
49
+ Fixes go through a new ticket, a new candidate and a new evaluation. For each failure, name the command
31
50
  file (`.claude/commands/*.md`) whose instructions should be tightened if the
32
51
  failure is a workflow bug rather than a run mistake.
@@ -11,11 +11,13 @@ start of a session. Read-only: change nothing.
11
11
  ## Steps
12
12
 
13
13
  1. Run `scripts/pincer-status.sh`. It reads the artifacts on disk (`.prd/`, `tickets/`,
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
16
- marked done without a verification receipt), and the next command to run.
14
+ `NOTES.md`) and prints the PRD state and profile, every ticket with its state and
15
+ clock-based elapsed time, what is blocked, wall-clock build time while a ticket is in
16
+ progress or against an explicit user budget, the evidence verdict for the evaluated
17
+ candidate, any warnings (each readiness problem once), and the next command to run.
17
18
  2. Report in three lines: where the workflow is, what is in progress or blocked, and the
18
19
  next command. Quote the `Next` line as-is.
19
20
  3. If a ticket is `in_progress`, read it and `git status`, then offer to resume it with
20
21
  `/pincer-code T-{NN}`. If the script printed a warning, surface it — a done ticket
21
22
  without a receipt was marked by hand and needs `scripts/pincer-ticket.sh verify T-{NN}`.
23
+ Never restore a ticket file from git to clear a warning; a failed attempt is a record.
@@ -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,351 @@
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
+ // Transparent wrappers: the command they run is the one that matters.
90
+ while (['nice', 'nohup', 'time', 'timeout', 'xargs'].includes(words[i])) {
91
+ const wrapper = words[i++];
92
+ while (i < words.length && words[i].startsWith('-')) {
93
+ if (['-n', '-s', '-k', '--signal', '--kill-after', '-I', '-L', '-P', '-d', '-a'].includes(words[i])) i++;
94
+ i++;
95
+ }
96
+ if (wrapper === 'timeout' && /^[0-9]/.test(words[i] || '')) i++;
97
+ }
98
+ const executable = words[i] ? path.basename(words[i]).toLowerCase() : '';
99
+ return { executable, args: words.slice(i + 1), words };
100
+ }
101
+
102
+ // Index of a shell's -c flag, including bundled forms such as -lc or -ec.
103
+ function shellCommandFlag(args) {
104
+ return args.findIndex(arg => /^-[A-Za-z]*c[A-Za-z]*$/.test(arg));
105
+ }
106
+
107
+ function gitSubcommand(args) {
108
+ let i = 0, cdir = '';
109
+ while (i < args.length) {
110
+ if (args[i] === '-C') { cdir = args[i + 1] || ''; i += 2; }
111
+ else if (args[i] === '-c' && /^alias\./.test(args[i + 1] || '')) return { name: 'alias', args: [], cdir };
112
+ else if (['-c', '--git-dir', '--work-tree'].includes(args[i])) i += 2;
113
+ else if (args[i].startsWith('-')) i++;
114
+ else return { name: args[i], args: args.slice(i + 1), cdir };
115
+ }
116
+ return { name: '', args: [], cdir };
117
+ }
118
+
119
+ function dangerousReason(source, depth = 0) {
120
+ if (depth > 2) return '';
121
+ const commands = shellCommands(source);
122
+ for (let i = 0; i < commands.length; i++) {
123
+ const { executable, args, words } = commandParts(commands[i]);
124
+ if ((executable === 'claude' || (executable === 'npx' && args.some(arg => arg === 'claude'))) &&
125
+ words.includes('--dangerously-skip-permissions')) return 'permission bypass flags are not allowed.';
126
+ if (executable === 'git') {
127
+ const sub = gitSubcommand(args);
128
+ if (sub.name === 'push' && sub.args.some(arg => arg === '-f' || arg === '--force' || arg.startsWith('--force=')))
129
+ return 'force-push commands are not allowed.';
130
+ if (sub.name === 'reset' && sub.args.includes('--hard') && sub.args.some(arg => /^origin\//.test(arg)))
131
+ return 'hard resets to a remote branch are not allowed.';
132
+ }
133
+ if (executable === 'rm') {
134
+ let recursive = false, force = false;
135
+ const targets = [];
136
+ for (const arg of args) {
137
+ if (arg === '--recursive') recursive = true;
138
+ else if (arg === '--force') force = true;
139
+ else if (/^-[^-]/.test(arg)) { recursive ||= /[rR]/.test(arg.slice(1)); force ||= /f/.test(arg.slice(1)); }
140
+ else if (arg !== '--') targets.push(arg);
141
+ }
142
+ if (recursive && force && targets.some(arg => arg.startsWith('/') || arg === '~' || arg.startsWith('~/') || arg === '$HOME' || arg.startsWith('$HOME/') || arg.startsWith('${HOME}')))
143
+ return 'recursive forced deletion of an absolute or home path is not allowed.';
144
+ }
145
+ if (executable === 'chmod' && args.some(arg => /^(0?777|a\+rwx)$/.test(arg)))
146
+ return 'mass permission changes are not allowed.';
147
+ if (['sh', 'bash', 'zsh'].includes(executable)) {
148
+ const c = shellCommandFlag(args);
149
+ if (c !== -1 && typeof args[c + 1] === 'string') {
150
+ const nested = dangerousReason(args[c + 1], depth + 1);
151
+ if (nested) return nested;
152
+ }
153
+ }
154
+ if (['curl', 'wget'].includes(executable) && commands[i].separator === '|') {
155
+ const next = commandParts(commands[i + 1] || { words: [] }).executable;
156
+ if (['sh', 'bash', 'zsh'].includes(next)) return 'downloading content directly into a shell is not allowed.';
157
+ }
158
+ }
159
+ return '';
160
+ }
161
+
162
+ const TICKET_PATH = /(^|[\\/])tickets[\\/]T-[0-9]+[^\\/]*\.md$/;
163
+ const PROTECTED = ['status', 'started', 'last_check', 'verified', 'finished'];
164
+
165
+ function ticketPath(value) {
166
+ return typeof value === 'string' && TICKET_PATH.test(value);
167
+ }
168
+
169
+ function stateFields(content) {
170
+ const result = Object.fromEntries(PROTECTED.map(key => [key, []]));
171
+ let scope = String(content ?? '');
172
+ if (scope.startsWith('---\n')) {
173
+ const end = scope.indexOf('\n---', 4);
174
+ if (end !== -1) scope = scope.slice(4, end + 1);
175
+ }
176
+ for (const line of scope.split(/\r?\n/)) {
177
+ const match = line.match(/^([a-z_]+):[ \t]*(.*)$/);
178
+ if (match && PROTECTED.includes(match[1])) result[match[1]].push(match[2]);
179
+ }
180
+ return result;
181
+ }
182
+
183
+ const sameState = (a, b) => JSON.stringify(stateFields(a)) === JSON.stringify(stateFields(b));
184
+
185
+ function absoluteToolPath(file) {
186
+ const root = process.env.CLAUDE_PROJECT_DIR || process.cwd();
187
+ return path.isAbsolute(file) ? file : path.resolve(root, file);
188
+ }
189
+
190
+ function existingContent(file) {
191
+ try { return fs.readFileSync(absoluteToolPath(file), 'utf8'); }
192
+ catch { return null; }
193
+ }
194
+
195
+ function applyEdit(content, oldText, newText, replaceAll = false) {
196
+ if (typeof oldText !== 'string' || typeof newText !== 'string') block('edit payload must contain string old_string and new_string values.');
197
+ if (!oldText) block('edit old_string must not be empty.');
198
+ if (replaceAll) return content.split(oldText).join(newText);
199
+ const index = content.indexOf(oldText);
200
+ return index === -1 ? content : content.slice(0, index) + newText + content.slice(index + oldText.length);
201
+ }
202
+
203
+ function guardEdits(tool, toolInput) {
204
+ const file = toolInput.file_path;
205
+ if (typeof file !== 'string') block(`${tool} payload must contain a string file_path.`);
206
+ if (!ticketPath(file)) return;
207
+ const before = existingContent(file);
208
+ if (tool === 'Write') {
209
+ if (typeof toolInput.content !== 'string') block('Write payload must contain string content.');
210
+ if (before !== null) {
211
+ if (!sameState(before, toolInput.content)) block('ticket lifecycle fields may only be changed by pincer-ticket.sh.');
212
+ } else {
213
+ const state = stateFields(toolInput.content);
214
+ if (state.status.length !== 1 || state.status[0].split(/[ \t]+#/)[0].trim() !== 'open' ||
215
+ PROTECTED.slice(1).some(key => state[key].length))
216
+ block('new tickets must start with status: open and no lifecycle receipts or timestamps.');
217
+ }
218
+ return;
219
+ }
220
+ const edits = tool === 'MultiEdit' ? toolInput.edits : [toolInput];
221
+ if (!Array.isArray(edits)) block('MultiEdit payload must contain an edits array.');
222
+ let after = before ?? '';
223
+ for (const edit of edits) {
224
+ if (!edit || typeof edit !== 'object') block('each edit must be an object.');
225
+ after = applyEdit(after, edit.old_string, edit.new_string, edit.replace_all === true);
226
+ }
227
+ if (!sameState(before ?? '', after)) block('ticket lifecycle fields may only be changed by pincer-ticket.sh.');
228
+ }
229
+
230
+ function isExactPincerCall(source) {
231
+ const commands = shellCommands(source).filter(command => command.words.length);
232
+ if (commands.length !== 1 || commands[0].separator) return false;
233
+ const { executable, args } = commandParts(commands[0]);
234
+ let words = [executable, ...args];
235
+ if (['bash', 'sh'].includes(words[0])) words = words.slice(1);
236
+ if (!/pincer-ticket\.sh$/.test(words[0] || '')) return false;
237
+ const action = words[1];
238
+ if (!['start', 'verify', 'done', 'bind'].includes(action)) return false;
239
+ return action === 'bind' ? words.length === 4 : words.length === 3;
240
+ }
241
+
242
+ // A pathspec is "wide" when, after normalisation, it cannot be shown to stay
243
+ // outside tickets/: the whole tree, an absolute or unexpanded path, a glob at
244
+ // the top level, or anything whose first segment is tickets. Exclude entries
245
+ // never widen. `cdir` is a `git -C <dir>` prefix.
246
+ function widePathspec(arg, cdir = '') {
247
+ if (/[$`]/.test(arg) || /[$`]/.test(cdir) || cdir.startsWith('/') || cdir.startsWith('~') || /^[A-Za-z]:[\\/]/.test(cdir)) return true;
248
+ let p = arg;
249
+ if (p.startsWith(':(')) {
250
+ const end = p.indexOf(')');
251
+ if (end < 0) return true;
252
+ const magic = p.slice(2, end).split(',').map(s => s.trim());
253
+ if (magic.includes('exclude')) return false;
254
+ p = p.slice(end + 1);
255
+ if (magic.includes('top')) cdir = '';
256
+ } else if (p.startsWith(':/')) { p = p.slice(2); cdir = ''; }
257
+ else if (p.startsWith(':!') || p.startsWith(':^')) return false;
258
+ else if (p.startsWith(':')) p = p.slice(1);
259
+ if (p.startsWith('/') || p.startsWith('~') || /^[A-Za-z]:[\\/]/.test(p)) return true;
260
+ if (cdir) p = `${cdir}/${p}`;
261
+ const segments = [];
262
+ for (const segment of p.split(/[\\/]+/)) {
263
+ if (segment === '' || segment === '.') continue;
264
+ if (segment === '..') { if (!segments.length) return true; segments.pop(); continue; }
265
+ segments.push(segment);
266
+ }
267
+ if (!segments.length) return true;
268
+ if (/[*?[{]/.test(segments[0])) return true;
269
+ return segments[0].toLowerCase() === 'tickets';
270
+ }
271
+
272
+ // Git forms that restore the working tree wholesale — and with it any ticket file
273
+ // whose failed attempt would be erased and whose revoked receipt would come back.
274
+ function wholeTreeRestore(sub) {
275
+ const { name, args, cdir } = sub;
276
+ const positional = [];
277
+ for (let i = 0; i < args.length; i++) {
278
+ const arg = args[i];
279
+ if (arg === '--') { positional.push(...args.slice(i + 1)); break; }
280
+ if (['-e', '--exclude', '--source', '-b', '-B', '--orphan', '-c', '-C', '--conflict'].includes(arg)) { i++; continue; }
281
+ if (arg.startsWith('-')) continue;
282
+ positional.push(arg);
283
+ }
284
+ const force = args.some(arg => arg === '--force' || arg === '--discard-changes' || /^-[A-Za-z]*f[A-Za-z]*$/.test(arg));
285
+ const fromFile = args.some(arg => arg === '--pathspec-from-file' || arg.startsWith('--pathspec-from-file='));
286
+ const patch = args.some(arg => arg === '-p' || arg === '--patch');
287
+ // `git checkout <ref>` / `git switch <ref>`: one positional, no `--`, no force flag is a branch switch,
288
+ // allowed even through a variable; every pathspec form keeps the wide test.
289
+ const branchSwitch = (name === 'checkout' || name === 'switch') && positional.length === 1 && !args.includes('--');
290
+ const wide = branchSwitch ? widePathspec(positional[0].replace(/[$`]/g, 'x'), cdir) : positional.some(p => widePathspec(p, cdir));
291
+ switch (name) {
292
+ case 'alias': return true;
293
+ case 'checkout': case 'switch': return force || fromFile || wide || (patch && positional.length === 0);
294
+ case 'restore': return fromFile || wide || positional.length === 0;
295
+ case 'reset': return args.some(arg => ['--hard', '--merge', '--keep'].includes(arg));
296
+ case 'stash':
297
+ if (args.some(arg => arg === '-h' || arg === '--help')) return false;
298
+ return !['list', 'show', 'create', 'store'].includes(positional[0] || '');
299
+ case 'clean': return force && (positional.length === 0 || wide);
300
+ case 'checkout-index': return args.some(arg => arg === '--all' || /^-[A-Za-z]*a/.test(arg));
301
+ case 'read-tree': return args.some(arg => arg === '--reset' || /^-[A-Za-z]*u/.test(arg));
302
+ default: return false;
303
+ }
304
+ }
305
+
306
+ function ticketShellMutation(source, depth = 0) {
307
+ if (depth > 2) return false;
308
+ if (isExactPincerCall(source)) return false;
309
+ const commands = shellCommands(source);
310
+ for (const command of commands) {
311
+ const { executable, args, words } = commandParts(command);
312
+ if (['sh', 'bash', 'zsh'].includes(executable)) {
313
+ const c = shellCommandFlag(args);
314
+ if (c !== -1 && typeof args[c + 1] === 'string' && ticketShellMutation(args[c + 1], depth + 1)) return true;
315
+ }
316
+ if (executable === 'eval' && ticketShellMutation(args.join(' '), depth + 1)) return true;
317
+ if (executable === 'git') {
318
+ const sub = gitSubcommand(args);
319
+ if (wholeTreeRestore(sub)) return true;
320
+ const viaXargs = command.words.some(word => path.basename(word).toLowerCase() === 'xargs');
321
+ const stdinPathspec = !sub.args.some(arg => !arg.startsWith('-')) || sub.args[sub.args.length - 1] === '--';
322
+ if (viaXargs && ['checkout', 'restore', 'clean'].includes(sub.name) && stdinPathspec) return true;
323
+ }
324
+ const hasTicket = words.some(ticketPath) || /(^|[\s'"`])tickets[\\/]T-[0-9]+[^\s'"`]*/.test(source);
325
+ if (!hasTicket) continue;
326
+ if (command.operators.some(op => op === '>' || op === '>>')) return true;
327
+ if (['rm', 'mv', 'cp', 'install', 'truncate', 'touch', 'tee', 'ed', 'ex'].includes(executable)) return true;
328
+ if (['python', 'python3', 'node', 'ruby'].includes(executable)) return true;
329
+ if (['sed', 'perl'].includes(executable) && args.some(arg => /^-[^-]*i/.test(arg) || arg === '--in-place' || arg.startsWith('--in-place='))) return true;
330
+ if (executable === 'git') {
331
+ const sub = gitSubcommand(args).name;
332
+ if (['checkout', 'restore', 'reset', 'clean'].includes(sub)) return true;
333
+ }
334
+ }
335
+ return false;
336
+ }
337
+
338
+ if (mode === 'dangerous') {
339
+ if (payload.tool_name !== 'Bash') process.exit(0);
340
+ if (typeof payload.tool_input.command !== 'string') block('Bash payload must contain a string command.');
341
+ const reason = dangerousReason(payload.tool_input.command);
342
+ if (reason) block(reason);
343
+ } else if (mode === 'ticket') {
344
+ if (['Edit', 'Write', 'MultiEdit'].includes(payload.tool_name)) guardEdits(payload.tool_name, payload.tool_input);
345
+ else if (payload.tool_name === 'Bash') {
346
+ if (typeof payload.tool_input.command !== 'string') block('Bash payload must contain a string command.');
347
+ if (ticketShellMutation(payload.tool_input.command)) block('shell commands may not write, reset, or restore ticket files (including whole-tree checkout/restore, reset --hard, stash, clean -f); use pincer-ticket.sh for lifecycle state.');
348
+ }
349
+ } else {
350
+ block('hook policy mode is invalid.');
351
+ }