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.
- package/README.md +19 -14
- package/bin/pincer.js +47 -11
- package/package.json +2 -2
- package/template/.agents/skills/pincer-code/SKILL.md +62 -17
- package/template/.agents/skills/pincer-evaluate/SKILL.md +85 -15
- package/template/.agents/skills/pincer-narrow/SKILL.md +67 -22
- package/template/.agents/skills/pincer-plan/SKILL.md +62 -25
- package/template/.agents/skills/pincer-release/SKILL.md +31 -12
- package/template/.agents/skills/pincer-status/SKILL.md +5 -3
- package/template/.claude/commands/pincer-code.md +61 -16
- package/template/.claude/commands/pincer-evaluate.md +85 -15
- package/template/.claude/commands/pincer-narrow.md +65 -20
- package/template/.claude/commands/pincer-plan.md +57 -20
- package/template/.claude/commands/pincer-release.md +30 -11
- package/template/.claude/commands/pincer-status.md +5 -3
- package/template/.claude/hooks/block-dangerous.sh +7 -18
- package/template/.claude/hooks/hook-policy.cjs +351 -0
- package/template/.claude/hooks/ticket-guard.sh +6 -63
- package/template/.claude/references/prd-template.md +41 -9
- package/template/.claude/references/ticket-template.md +37 -4
- package/template/.codex/README.md +4 -4
- package/template/.github/prompts/pincer-code.prompt.md +61 -16
- package/template/.github/prompts/pincer-evaluate.prompt.md +85 -15
- package/template/.github/prompts/pincer-narrow.prompt.md +65 -20
- package/template/.github/prompts/pincer-plan.prompt.md +57 -20
- package/template/.github/prompts/pincer-release.prompt.md +30 -11
- package/template/.github/prompts/pincer-status.prompt.md +5 -3
- package/template/AGENTS.md +11 -5
- package/template/docs/dry-run-checklist.md +143 -27
- package/template/docs/release-checklist.md +35 -0
- package/template/scripts/pincer-evidence.cjs +292 -0
- package/template/scripts/pincer-status.sh +83 -26
- package/template/scripts/pincer-ticket-lib.sh +321 -0
- package/template/scripts/pincer-ticket.sh +58 -47
- 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
|
|
9
|
-
|
|
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,
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
-
|
|
75
|
+
- Respect any explicit delivery budget. Record deliberate cuts in Out of Scope.
|
|
61
76
|
|
|
62
|
-
|
|
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
|
|
80
|
+
## Phase 4: Write the PRD
|
|
65
81
|
|
|
66
82
|
1. Load `.claude/references/prd-template.md` and write all core sections.
|
|
67
|
-
|
|
68
|
-
|
|
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:
|
|
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
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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/
|
|
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/
|
|
16
|
-
|
|
17
|
-
|
|
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 `
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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 —
|
|
30
|
-
"FAIL — {n} items failed; fix via {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
|
|
15
|
-
elapsed time, what is blocked, build time
|
|
16
|
-
|
|
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.
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
|
|
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
|
-
|
|
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
|
+
}
|