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.
- package/README.md +16 -15
- package/bin/pincer.js +52 -12
- package/package.json +2 -2
- package/template/.agents/skills/pincer-code/SKILL.md +19 -16
- package/template/.agents/skills/pincer-evaluate/SKILL.md +28 -9
- package/template/.agents/skills/pincer-narrow/SKILL.md +27 -21
- package/template/.agents/skills/pincer-plan/SKILL.md +28 -20
- package/template/.agents/skills/pincer-release/SKILL.md +14 -9
- package/template/.agents/skills/pincer-status/SKILL.md +1 -1
- package/template/.claude/commands/pincer-code.md +18 -15
- package/template/.claude/commands/pincer-evaluate.md +28 -9
- package/template/.claude/commands/pincer-narrow.md +25 -19
- package/template/.claude/commands/pincer-plan.md +26 -18
- package/template/.claude/commands/pincer-release.md +13 -8
- package/template/.claude/commands/pincer-status.md +1 -1
- package/template/.claude/hooks/block-dangerous.sh +7 -18
- package/template/.claude/hooks/hook-policy.cjs +258 -0
- package/template/.claude/hooks/ticket-guard.sh +6 -63
- package/template/.claude/references/prd-template.md +3 -3
- package/template/.claude/references/ticket-template.md +22 -4
- package/template/.codex/README.md +8 -4
- package/template/.github/prompts/pincer-code.prompt.md +18 -15
- package/template/.github/prompts/pincer-evaluate.prompt.md +28 -9
- package/template/.github/prompts/pincer-narrow.prompt.md +25 -19
- package/template/.github/prompts/pincer-plan.prompt.md +26 -18
- package/template/.github/prompts/pincer-release.prompt.md +13 -8
- package/template/.github/prompts/pincer-status.prompt.md +1 -1
- package/template/AGENTS.md +2 -3
- package/template/docs/dry-run-checklist.md +19 -15
- package/template/docs/release-checklist.md +34 -0
- package/template/scripts/pincer-status.sh +64 -23
- package/template/scripts/pincer-ticket-lib.sh +270 -0
- package/template/scripts/pincer-ticket.sh +57 -46
- package/template/scripts/sync-prompts.sh +6 -1
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# PINCER Dry-Run Checklist
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
This is the manual platform trial for the Pincer kit, not the product release audit.
|
|
4
|
+
For release readiness, use `docs/release-checklist.md`.
|
|
5
|
+
|
|
6
|
+
To test that the `.claude/` workflow works, run the full chain on a small toy
|
|
4
7
|
feature (e.g. "a CLI todo app in TypeScript — add, list, complete, delete, stored in a
|
|
5
8
|
local JSON file"), then tick every box below. All boxes ticked = the workflow passes.
|
|
6
9
|
A failed box points at the command file to fix.
|
|
@@ -9,20 +12,22 @@ Use a throwaway copy of this repo and a cheap model (`claude --model sonnet`).
|
|
|
9
12
|
|
|
10
13
|
## After `/pincer-plan`
|
|
11
14
|
|
|
12
|
-
- [ ] `.prd/prd-
|
|
15
|
+
- [ ] The selected `.prd/prd-vN.md` exists
|
|
13
16
|
- [ ] Its frontmatter has `version`, `status: draft`, and `date`
|
|
14
17
|
- [ ] All 6 core sections are present (Problem, Solution, Scope, Architecture,
|
|
15
18
|
Success Criteria, Out of Scope)
|
|
16
19
|
- [ ] The Scope table has both columns filled (in AND out)
|
|
17
20
|
- [ ] No implementation code inside the PRD
|
|
18
21
|
- [ ] Discovery asked ≤4 questions and none were already answered by the brief
|
|
19
|
-
- [ ] `.git/` exists and the
|
|
22
|
+
- [ ] `.git/` exists and the selected PRD is committed without unrelated brownfield work
|
|
20
23
|
|
|
21
24
|
## After `/pincer-narrow`
|
|
22
25
|
|
|
23
|
-
- [ ]
|
|
24
|
-
- [ ] Every ticket
|
|
25
|
-
|
|
26
|
+
- [ ] Ticket files are named `T-{NN}-{slug}.md`; count follows dependencies and risk
|
|
27
|
+
- [ ] Every ticket has a coherent S, M, or L scope; larger work is split when that
|
|
28
|
+
improves ownership, dependency order, or verification
|
|
29
|
+
- [ ] Greenfield uses a walking skeleton when helpful; brownfield protects the
|
|
30
|
+
smallest useful vertical change
|
|
26
31
|
- [ ] Every ticket has a runnable, non-interactive command in its fenced
|
|
27
32
|
Verification block (it is what `scripts/pincer-ticket.sh verify` runs)
|
|
28
33
|
- [ ] Dependencies are declared where they exist (`depends_on`)
|
|
@@ -30,8 +35,8 @@ Use a throwaway copy of this repo and a cheap model (`claude --model sonnet`).
|
|
|
30
35
|
verification command is the test runner
|
|
31
36
|
- [ ] Every ticket whose surface accepts external input has a reject-path
|
|
32
37
|
acceptance criterion (what invalid input produces), not only the happy path
|
|
33
|
-
- [ ]
|
|
34
|
-
`.env.example
|
|
38
|
+
- [ ] Greenfield setup covers `.env*` (except `.env.example`) and names required
|
|
39
|
+
secrets in `.env.example`; brownfield preserves and verifies existing conventions
|
|
35
40
|
- [ ] PRD frontmatter now says `status: ticketed`
|
|
36
41
|
- [ ] Tickets are committed
|
|
37
42
|
|
|
@@ -50,12 +55,12 @@ Use a throwaway copy of this repo and a cheap model (`claude --model sonnet`).
|
|
|
50
55
|
## After `/pincer-evaluate`
|
|
51
56
|
|
|
52
57
|
- [ ] Findings (if any) were presented with `file:line` references
|
|
53
|
-
- [ ] The mechanical security audit
|
|
58
|
+
- [ ] The mechanical security audit used redacted, location-only secret findings;
|
|
54
59
|
`git ls-files` shows no `.env` beyond `.env.example`, dependency audit,
|
|
55
60
|
and (if there's an API) one invalid-input request returned a clean 4xx
|
|
56
61
|
- [ ] If the project has a UI, it was actually opened and checked visually, not
|
|
57
62
|
only read as code
|
|
58
|
-
- [ ]
|
|
63
|
+
- [ ] Evaluation fixes were completed through new tickets and re-verified
|
|
59
64
|
- [ ] `NOTES.md` exists at the repo root and covers: what was built, what was cut
|
|
60
65
|
and why, known issues, next steps
|
|
61
66
|
|
|
@@ -63,8 +68,8 @@ Use a throwaway copy of this repo and a cheap model (`claude --model sonnet`).
|
|
|
63
68
|
|
|
64
69
|
- [ ] `git log --oneline` reads as a coherent story: setup → tickets → T-01…T-NN → review
|
|
65
70
|
- [ ] No `.env` file contents ever appeared in the conversation
|
|
66
|
-
- [ ]
|
|
67
|
-
|
|
71
|
+
- [ ] Relevant history was checked for committed-then-deleted secrets without printing
|
|
72
|
+
candidate values into the conversation or audit report
|
|
68
73
|
- [ ] Every dependency in the lockfile is named in the PRD's architecture or was
|
|
69
74
|
explicitly approved during build
|
|
70
75
|
- [ ] `NOTES.md` has a Handover section (orientation, dependency justification,
|
|
@@ -73,6 +78,5 @@ Use a throwaway copy of this repo and a cheap model (`claude --model sonnet`).
|
|
|
73
78
|
before being modified
|
|
74
79
|
- [ ] Platform adapters in sync: `scripts/sync-prompts.sh` then `git status`
|
|
75
80
|
shows no changes in `.agents/skills/` or `.github/prompts/`
|
|
76
|
-
- [ ] `scripts/pincer-status.sh` says `Next /pincer-release
|
|
77
|
-
|
|
78
|
-
- [ ] Total wall-clock time fit the ~2-hour budget
|
|
81
|
+
- [ ] `scripts/pincer-status.sh` says `Next /pincer-release`; if the user supplied a
|
|
82
|
+
budget, its elapsed figure and any deliberate cuts are reported against that budget
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# PINCER Release Checklist
|
|
2
|
+
|
|
3
|
+
Audit the selected PRD and its evaluated candidate. This checklist applies to product
|
|
4
|
+
changes in greenfield and brownfield repositories. It is read-only: failures return work
|
|
5
|
+
to the owning stage or a new ticket.
|
|
6
|
+
|
|
7
|
+
## Change identity and state
|
|
8
|
+
|
|
9
|
+
- [ ] `scripts/pincer-status.sh` selects the intended PRD with `status: built` and no warnings
|
|
10
|
+
- [ ] Every ticket associated with that PRD is done with current `last_check` and `verified` evidence
|
|
11
|
+
- [ ] `NOTES.md` names the selected PRD, reviewed base, and candidate; status reports it current
|
|
12
|
+
- [ ] The working tree is clean before and after the audit
|
|
13
|
+
|
|
14
|
+
## Scope and evidence
|
|
15
|
+
|
|
16
|
+
- [ ] Every in-scope success criterion has delivered evidence or an explicit non-delivered disposition
|
|
17
|
+
- [ ] Ticket dependencies, acceptance criteria, and verification commands match the implemented change
|
|
18
|
+
- [ ] The repository's candidate-wide release gate passes when run directly, without invoking the ticket state writer
|
|
19
|
+
- [ ] Scope cuts and known limitations are explicit in the PRD or evaluation notes
|
|
20
|
+
|
|
21
|
+
## Review
|
|
22
|
+
|
|
23
|
+
- [ ] High-confidence review findings have file and line evidence and a disposition
|
|
24
|
+
- [ ] Fixes made after evaluation use a new ticket, current verification, and a scoped commit
|
|
25
|
+
- [ ] UI changes have visual evidence; non-UI changes mark this item not applicable with a reason
|
|
26
|
+
- [ ] Security review reports locations and remediation without printing candidate secret values
|
|
27
|
+
- [ ] No secret environment file is tracked; dependency and invalid-input checks run when applicable
|
|
28
|
+
|
|
29
|
+
## Handover and verdict
|
|
30
|
+
|
|
31
|
+
- [ ] Commit history identifies the plan, ticket work, evaluation, and any review-fix tickets
|
|
32
|
+
- [ ] `NOTES.md` covers what shipped, cuts, limitations, next steps, dependencies, and the riskiest aging assumption
|
|
33
|
+
- [ ] Platform or environment limits are stated without claiming untested support
|
|
34
|
+
- [ ] Every failed or skipped required item is named before the final PASS or FAIL verdict
|
|
@@ -6,23 +6,15 @@
|
|
|
6
6
|
#
|
|
7
7
|
# Elapsed times come from the `started` / `finished` stamps that
|
|
8
8
|
# scripts/pincer-ticket.sh writes, i.e. from the clock — never estimated.
|
|
9
|
-
#
|
|
9
|
+
# Optional build budget: PINCER_BUILD_BUDGET_MIN.
|
|
10
10
|
set -uo pipefail
|
|
11
11
|
|
|
12
|
+
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/pincer-ticket-lib.sh"
|
|
12
13
|
ROOT=${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}
|
|
13
14
|
cd "$ROOT"
|
|
14
|
-
BUDGET=${PINCER_BUILD_BUDGET_MIN:-
|
|
15
|
+
BUDGET=${PINCER_BUILD_BUDGET_MIN:-}
|
|
15
16
|
NOW=$(date -u +%s)
|
|
16
17
|
|
|
17
|
-
fm_get() { # file key -> value with any inline comment stripped; empty if absent
|
|
18
|
-
awk -v k="$2" '
|
|
19
|
-
NR == 1 && $0 != "---" { exit }
|
|
20
|
-
NR > 1 && $0 == "---" { exit }
|
|
21
|
-
NR > 1 && index($0, k ":") == 1 {
|
|
22
|
-
v = substr($0, length(k) + 2); sub(/#.*/, "", v)
|
|
23
|
-
gsub(/^[ \t]+|[ \t]+$/, "", v); print v; exit
|
|
24
|
-
}' "$1"
|
|
25
|
-
}
|
|
26
18
|
to_epoch() { # ISO-8601 UTC -> seconds (GNU date, then BSD date)
|
|
27
19
|
date -u -d "$1" +%s 2>/dev/null || date -u -j -f '%Y-%m-%dT%H:%M:%SZ' "$1" +%s 2>/dev/null || echo 0
|
|
28
20
|
}
|
|
@@ -32,7 +24,11 @@ hhmm() { [ -n "$1" ] && printf '%s' "$1" | cut -c12-16 || printf '—'; }
|
|
|
32
24
|
echo "PINCER status · $(date -u +%Y-%m-%dT%H:%MZ) · $ROOT"
|
|
33
25
|
|
|
34
26
|
# ── PRD ──
|
|
35
|
-
prd=$(
|
|
27
|
+
if ! prd=$(latest_prd 2>&1); then
|
|
28
|
+
printf 'WARN invalid PRD: %s\n' "$prd"
|
|
29
|
+
echo 'Next repair PRD input before continuing'
|
|
30
|
+
exit 1
|
|
31
|
+
fi
|
|
36
32
|
prd_status=""
|
|
37
33
|
if [ -z "$prd" ]; then
|
|
38
34
|
echo "PRD none"
|
|
@@ -41,17 +37,43 @@ else
|
|
|
41
37
|
echo "PRD $prd · status: ${prd_status:-?} · date: $(fm_get "$prd" date)"
|
|
42
38
|
fi
|
|
43
39
|
|
|
40
|
+
# Reject malformed/ambiguous tickets instead of treating unknown states as open.
|
|
41
|
+
if ! errors=$(validate_ticket_set 2>&1); then
|
|
42
|
+
printf 'WARN invalid tickets: %s\n' "$errors"
|
|
43
|
+
echo "Next repair ticket input before continuing"
|
|
44
|
+
exit 1
|
|
45
|
+
fi
|
|
46
|
+
|
|
44
47
|
# ── Tickets ──
|
|
45
|
-
n_open=0; n_prog=0; n_done=0; first_start=""; in_prog=""; next_open=""; warn=""
|
|
46
|
-
files
|
|
48
|
+
n_open=0; n_prog=0; n_done=0; first_start=""; in_prog=""; next_open=""; warn=""; reverify=""
|
|
49
|
+
files=""; historical=0; unresolved=0
|
|
50
|
+
for f in tickets/T-*.md; do
|
|
51
|
+
[ -e "$f" ] || continue
|
|
52
|
+
if ! associated=$(ticket_prd "$f" 2>&1); then
|
|
53
|
+
printf 'WARN unresolved ticket PRD: %s\n' "$associated"
|
|
54
|
+
unresolved=$((unresolved + 1)); continue
|
|
55
|
+
fi
|
|
56
|
+
if [ "$associated" = "$prd" ]; then
|
|
57
|
+
files="$files$f
|
|
58
|
+
"
|
|
59
|
+
else
|
|
60
|
+
historical=$((historical + 1))
|
|
61
|
+
fi
|
|
62
|
+
done
|
|
63
|
+
[ "$historical" -eq 0 ] || echo "History $historical ticket(s) associated with other PRDs"
|
|
47
64
|
if [ -z "$files" ]; then
|
|
48
65
|
echo "Tickets none"
|
|
49
66
|
else
|
|
50
67
|
rows=""
|
|
51
|
-
|
|
68
|
+
while IFS= read -r f; do
|
|
69
|
+
[ -n "$f" ] || continue
|
|
52
70
|
id=$(fm_get "$f" ticket); [ -n "$id" ] || id=$(basename "$f" | cut -c1-4)
|
|
53
71
|
st=$(fm_get "$f" status); size=$(fm_get "$f" size)
|
|
54
72
|
started=$(fm_get "$f" started); verified=$(fm_get "$f" verified); finished=$(fm_get "$f" finished)
|
|
73
|
+
attempt=$(fm_get "$f" last_check)
|
|
74
|
+
if [ -n "$attempt" ] && ! printf '%s' "$attempt" | grep -q ' passed '; then
|
|
75
|
+
warn="$warn WARN $id latest verification: $attempt — re-run verify\n"
|
|
76
|
+
fi
|
|
55
77
|
deps=$(fm_get "$f" depends_on | grep -oE 'T-[0-9]+' | tr '\n' ' ' || true)
|
|
56
78
|
if [ -n "$started" ]; then
|
|
57
79
|
se=$(to_epoch "$started")
|
|
@@ -62,7 +84,10 @@ else
|
|
|
62
84
|
n_done=$((n_done + 1))
|
|
63
85
|
detail="started $(hhmm "$started") · finished $(hhmm "$finished")"
|
|
64
86
|
[ -n "$started" ] && [ -n "$finished" ] && detail="$detail ($(mins "$(to_epoch "$started")" "$(to_epoch "$finished")"))"
|
|
65
|
-
|
|
87
|
+
if ! readiness=$(ticket_readiness "$f"); then
|
|
88
|
+
warn="$warn WARN $id $readiness\n"
|
|
89
|
+
reverify="$reverify $id"
|
|
90
|
+
fi
|
|
66
91
|
;;
|
|
67
92
|
in_progress)
|
|
68
93
|
n_prog=$((n_prog + 1)); in_prog="$in_prog $id"
|
|
@@ -74,38 +99,54 @@ else
|
|
|
74
99
|
n_open=$((n_open + 1)); st=${st:-open}
|
|
75
100
|
blocked=""
|
|
76
101
|
for d in $deps; do
|
|
77
|
-
df=$(
|
|
78
|
-
|
|
102
|
+
df=$(ticket_file "$d" 2>/dev/null || true)
|
|
103
|
+
dep_prd=""
|
|
104
|
+
[ -z "$df" ] || dep_prd=$(ticket_prd "$df" 2>/dev/null || true)
|
|
105
|
+
if [ -z "$df" ] || [ "$(fm_get "$df" status)" != done ] ||
|
|
106
|
+
[ "$dep_prd" != "$prd" ] || ! usable_ticket_prd "$df" >/dev/null 2>&1 ||
|
|
107
|
+
! ticket_readiness "$df" >/dev/null; then
|
|
108
|
+
blocked="$blocked $d"
|
|
109
|
+
fi
|
|
79
110
|
done
|
|
80
111
|
if [ -n "$blocked" ]; then detail="blocked by${blocked}"; else detail="ready"; [ -n "$next_open" ] || next_open=$id; fi
|
|
81
112
|
;;
|
|
82
113
|
esac
|
|
83
114
|
rows="$rows$(printf ' %-5s %-12s %-2s %s' "$id" "$st" "${size:-?}" "$detail")\n"
|
|
84
|
-
done
|
|
115
|
+
done <<< "$files"
|
|
85
116
|
echo "Tickets $((n_open + n_prog + n_done)) total · $n_done done · $n_prog in progress · $n_open open"
|
|
86
117
|
printf '%b' "$rows"
|
|
87
118
|
printf '%b' "$warn"
|
|
88
119
|
if [ -n "$first_start" ]; then
|
|
89
|
-
|
|
120
|
+
build="Build elapsed $(mins "$first_start" "$NOW") since the first ticket started"
|
|
121
|
+
[ -z "$BUDGET" ] || build="$build · budget ${BUDGET}m"
|
|
122
|
+
echo "$build"
|
|
90
123
|
fi
|
|
91
124
|
fi
|
|
92
125
|
|
|
93
|
-
|
|
126
|
+
notes_valid=no
|
|
127
|
+
if notes=$(notes_current "$prd"); then notes_valid=yes; fi
|
|
94
128
|
echo "Notes NOTES.md: $notes"
|
|
95
129
|
|
|
96
130
|
# ── Next action ──
|
|
97
131
|
if [ -z "$prd" ]; then
|
|
98
132
|
next="/pincer-plan <brief> — no PRD yet"
|
|
133
|
+
elif [ "$unresolved" -gt 0 ]; then
|
|
134
|
+
next="resolve PRD association with pincer-ticket.sh bind T-NN .prd/prd-vN.md before continuing"
|
|
135
|
+
elif [ "$prd_status" = draft ]; then
|
|
136
|
+
next="/pincer-narrow — current PRD is draft; earlier tickets and notes do not complete it"
|
|
99
137
|
elif [ -z "$files" ]; then
|
|
100
138
|
next="/pincer-narrow — PRD exists, no tickets yet"
|
|
101
139
|
elif [ -n "$in_prog" ]; then
|
|
102
140
|
next="resume${in_prog}: /pincer-code${in_prog} (check git status for uncommitted work; then verify → done)"
|
|
103
141
|
elif [ "$n_open" -gt 0 ]; then
|
|
104
142
|
next="/pincer-code — next ready ticket: ${next_open:-none (all remaining are blocked — check depends_on)}"
|
|
105
|
-
elif [ "$
|
|
143
|
+
elif [ -n "$reverify" ]; then
|
|
144
|
+
next="/pincer-code — re-run verify for${reverify}; resolve readiness warnings before evaluation or release"
|
|
145
|
+
elif [ "$notes_valid" = no ] || [ "$prd_status" != built ]; then
|
|
106
146
|
next="/pincer-evaluate — all tickets done"
|
|
107
147
|
[ "$prd_status" = built ] || next="$next (PRD status is '${prd_status:-?}', expected 'built')"
|
|
108
148
|
else
|
|
109
|
-
next="/pincer-release —
|
|
149
|
+
next="/pincer-release — evaluation matches the current PRD and candidate; audit the artifacts"
|
|
110
150
|
fi
|
|
111
151
|
echo "Next $next"
|
|
152
|
+
[ "$unresolved" -eq 0 ] || exit 1
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Shared, read-only parsing for the supported Pincer ticket format.
|
|
3
|
+
# Validators return nonzero with a diagnostic; callers decide how to report it.
|
|
4
|
+
|
|
5
|
+
normalize() { # CLI shorthand -> canonical ID; bound arithmetic before conversion.
|
|
6
|
+
local n=${1#T-}; n=${n#t-}
|
|
7
|
+
if ! [[ $n =~ ^[0-9]{1,6}$ ]] || [ "$((10#$n))" -eq 0 ]; then
|
|
8
|
+
printf 'pincer-ticket: not a ticket id (expected 1..999999): %s\n' "$1" >&2
|
|
9
|
+
return 1
|
|
10
|
+
fi
|
|
11
|
+
printf 'T-%02d' "$((10#$n))"
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
fm_get() {
|
|
15
|
+
awk -v k="$2" '
|
|
16
|
+
NR == 1 && $0 != "---" { exit }
|
|
17
|
+
NR > 1 && $0 == "---" { exit }
|
|
18
|
+
NR > 1 && index($0, k ":") == 1 {
|
|
19
|
+
v = substr($0, length(k) + 2); sub(/#.*/, "", v)
|
|
20
|
+
gsub(/^[ \t]+|[ \t]+$/, "", v); print v; exit
|
|
21
|
+
}' "$1"
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
verification_cmds() {
|
|
25
|
+
awk '
|
|
26
|
+
inb { if ($0 ~ /^[ \t]*```[ \t]*$/) exit; print; next }
|
|
27
|
+
otherfence { if ($0 ~ /^[ \t]*```/) otherfence = 0; next }
|
|
28
|
+
/^## Verification[ \t]*$/ { inv = 1; next }
|
|
29
|
+
inv && /^[ \t]*```bash[ \t]*$/ { inb = 1; next }
|
|
30
|
+
inv && /^## / { exit }
|
|
31
|
+
/^[ \t]*```/ { otherfence = 1 }
|
|
32
|
+
' "$1"
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
unticked() {
|
|
36
|
+
awk '
|
|
37
|
+
otherfence { if ($0 ~ /^[ \t]*```/) otherfence = 0; next }
|
|
38
|
+
/^[ \t]*```/ { otherfence = 1; next }
|
|
39
|
+
/^## Acceptance Criteria[ \t]*$/ { ina = 1; next }
|
|
40
|
+
ina && /^## / { exit }
|
|
41
|
+
ina && /^[ \t]*([-+*]|[0-9]+[.)])[ \t]+\[ \]/ { print }
|
|
42
|
+
' "$1"
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
validate_ticket() {
|
|
46
|
+
local file=$1 cmds
|
|
47
|
+
awk -v file="$file" '
|
|
48
|
+
function fail(msg) { print "pincer-ticket: " file ": " msg > "/dev/stderr"; bad = 1; exit 1 }
|
|
49
|
+
function trim(s) { gsub(/^[ \t]+|[ \t]+$/, "", s); return s }
|
|
50
|
+
function idok(s, n) {
|
|
51
|
+
if (s !~ /^T-[0-9][0-9]+$/) return 0
|
|
52
|
+
n = substr(s, 3)
|
|
53
|
+
return length(n) <= 6 && n + 0 > 0 && sprintf("T-%02d", n + 0) == s
|
|
54
|
+
}
|
|
55
|
+
NR == 1 { if ($0 != "---") fail("frontmatter must begin with ---"); next }
|
|
56
|
+
!closed {
|
|
57
|
+
if ($0 == "---") { closed = 1; next }
|
|
58
|
+
if ($0 ~ /^[ \t]*(#.*)?$/) next
|
|
59
|
+
if ($0 !~ /^[a-z_][a-z0-9_]*:[ \t]*/) fail("frontmatter requires unindented key: value fields")
|
|
60
|
+
key = $0; sub(/:.*/, "", key)
|
|
61
|
+
if (seen[key]++) fail("duplicate frontmatter key: " key)
|
|
62
|
+
value = substr($0, length(key) + 2); sub(/#.*/, "", value); values[key] = trim(value)
|
|
63
|
+
next
|
|
64
|
+
}
|
|
65
|
+
inverify {
|
|
66
|
+
if ($0 ~ /^[ \t]*```[ \t]*$/) { inverify = 0; next }
|
|
67
|
+
if ($0 ~ /^[ \t]*```/) fail("Verification requires one closed bash fence")
|
|
68
|
+
if ($0 !~ /^[ \t]*(#.*)?$/) runnable++
|
|
69
|
+
next
|
|
70
|
+
}
|
|
71
|
+
otherfence {
|
|
72
|
+
if ($0 ~ /^[ \t]*```/) otherfence = 0
|
|
73
|
+
next
|
|
74
|
+
}
|
|
75
|
+
/^## Acceptance Criteria[ \t]*$/ { if (accept++) fail("duplicate Acceptance Criteria section"); section = "accept"; next }
|
|
76
|
+
/^## Verification[ \t]*$/ { if (verify++) fail("duplicate Verification section"); section = "verify"; next }
|
|
77
|
+
/^## / { section = ""; next }
|
|
78
|
+
section == "accept" {
|
|
79
|
+
if ($0 ~ /^[ \t]*(```|~~~)/) fail("Acceptance Criteria must contain visible checkboxes, not fences")
|
|
80
|
+
if ($0 ~ /^[ \t]*([-+*]|[0-9]+[.)])[ \t]+/ || $0 ~ /^[ \t]*([-+*]|[0-9]+[.)])?[ \t]*\[/) {
|
|
81
|
+
if ($0 !~ /^[ \t]*([-+*]|[0-9]+[.)])[ \t]+\[[ xX]\][ \t]+[^ \t]/)
|
|
82
|
+
fail("malformed acceptance checkbox; use - [ ] text or - [x] text (indented/list marker variants supported)")
|
|
83
|
+
boxes++
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/^[ \t]*~~~/ { fail("use backtick fences; tilde fences are unsupported") }
|
|
87
|
+
/^[ \t]*```/ {
|
|
88
|
+
if (section == "verify") {
|
|
89
|
+
if ($0 !~ /^[ \t]*```bash[ \t]*$/ || fences++) fail("Verification requires exactly one bash fence")
|
|
90
|
+
inverify = 1
|
|
91
|
+
} else otherfence = 1
|
|
92
|
+
}
|
|
93
|
+
END {
|
|
94
|
+
if (bad) exit 1
|
|
95
|
+
if (!closed) fail("frontmatter must close with ---")
|
|
96
|
+
if (!seen["ticket"] || !idok(values["ticket"])) fail("ticket must be a canonical ID between T-01 and T-999999")
|
|
97
|
+
base = file; sub(/^.*\//, "", base)
|
|
98
|
+
prefix = values["ticket"] "-"
|
|
99
|
+
if (index(base, prefix) != 1 || base !~ /.+\.md$/ || length(base) <= length(prefix) + 3) fail("ticket ID must match filename T-NN-slug.md")
|
|
100
|
+
if (values["status"] !~ /^(open|in_progress|done)$/) fail("status must be open, in_progress, or done")
|
|
101
|
+
if (values["size"] !~ /^(S|M|L)$/) fail("size must be S, M, or L")
|
|
102
|
+
timestamp = "^[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9]Z$"
|
|
103
|
+
for (key in seen) {
|
|
104
|
+
if ((key == "started" || key == "finished") && values[key] !~ timestamp) fail(key " must be an ISO UTC timestamp")
|
|
105
|
+
if (key == "verified" || key == "last_check") {
|
|
106
|
+
parts = split(values[key], stamp, /[ \t]+/)
|
|
107
|
+
expected = key == "verified" ? 2 : 3
|
|
108
|
+
if (parts != expected || stamp[1] !~ timestamp || length(stamp[parts]) != 12 || stamp[parts] !~ /^[0-9a-f]+$/)
|
|
109
|
+
fail("malformed " key " timestamp/hash")
|
|
110
|
+
if (key == "last_check" && stamp[2] !~ /^(running|passed|failed|interrupted)$/) fail("invalid last_check outcome")
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
deps = values["depends_on"]
|
|
114
|
+
if (!seen["depends_on"] || deps !~ /^\[[ \t]*(T-[0-9]+([ \t]*,[ \t]*T-[0-9]+)*)?[ \t]*\]$/) fail("depends_on must be an inline list such as [T-01, T-02]")
|
|
115
|
+
sub(/^\[[ \t]*/, "", deps); sub(/[ \t]*\]$/, "", deps)
|
|
116
|
+
count = split(deps, entries, ",")
|
|
117
|
+
for (i = 1; i <= count; i++) {
|
|
118
|
+
dep = trim(entries[i]); if (dep == "") continue
|
|
119
|
+
if (!idok(dep)) fail("depends_on contains noncanonical ticket ID: " dep)
|
|
120
|
+
if (dep == values["ticket"]) fail("ticket cannot depend on itself")
|
|
121
|
+
if (depseen[dep]++) fail("duplicate depends_on ID: " dep)
|
|
122
|
+
}
|
|
123
|
+
if (!accept || !boxes) fail("Acceptance Criteria requires at least one nonempty checkbox")
|
|
124
|
+
if (!verify || fences != 1 || inverify || !runnable) fail("Verification requires exactly one closed runnable bash fence")
|
|
125
|
+
}
|
|
126
|
+
' "$file" || return 1
|
|
127
|
+
cmds=$(verification_cmds "$file") || return 1
|
|
128
|
+
if ! bash -n -c "$cmds"; then
|
|
129
|
+
printf 'pincer-ticket: %s: invalid bash syntax in Verification block\n' "$file" >&2
|
|
130
|
+
return 1
|
|
131
|
+
fi
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
validate_ticket_set() {
|
|
135
|
+
local file id ids=' '
|
|
136
|
+
for file in tickets/T-*.md; do
|
|
137
|
+
[ -e "$file" ] || continue
|
|
138
|
+
id=$(fm_get "$file" ticket)
|
|
139
|
+
if [ -n "$id" ]; then
|
|
140
|
+
case "$ids" in *" $id "*) printf 'pincer-ticket: duplicate ticket ID: %s\n' "$id" >&2; return 1 ;; esac
|
|
141
|
+
ids="$ids$id "
|
|
142
|
+
fi
|
|
143
|
+
done
|
|
144
|
+
for file in tickets/T-*.md; do
|
|
145
|
+
[ -e "$file" ] || continue
|
|
146
|
+
validate_ticket "$file" || return 1
|
|
147
|
+
done
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
ticket_file() {
|
|
151
|
+
local id file found=''
|
|
152
|
+
id=$(normalize "$1") || return 1
|
|
153
|
+
for file in "tickets/$id"-*.md; do
|
|
154
|
+
[ -e "$file" ] || continue
|
|
155
|
+
if [ -n "$found" ]; then printf 'pincer-ticket: several files match tickets/%s-*.md\n' "$id" >&2; return 1; fi
|
|
156
|
+
found=$file
|
|
157
|
+
done
|
|
158
|
+
if [ -z "$found" ]; then printf 'pincer-ticket: no ticket file tickets/%s-*.md\n' "$id" >&2; return 1; fi
|
|
159
|
+
printf '%s' "$found"
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
# PRDs and evaluation notes use the same deliberately small metadata format.
|
|
163
|
+
validate_metadata() {
|
|
164
|
+
awk -v file="$1" '
|
|
165
|
+
function fail(msg) { print "pincer: " file ": " msg > "/dev/stderr"; bad = 1; exit 1 }
|
|
166
|
+
NR == 1 { if ($0 != "---") fail("frontmatter must begin with ---"); next }
|
|
167
|
+
!closed {
|
|
168
|
+
if ($0 == "---") { closed = 1; next }
|
|
169
|
+
if ($0 ~ /^[ \t]*(#.*)?$/) next
|
|
170
|
+
if ($0 !~ /^[a-z_][a-z0-9_]*:[ \t]*/) fail("expected unindented key: value metadata")
|
|
171
|
+
key = $0; sub(/:.*/, "", key)
|
|
172
|
+
if (seen[key]++) fail("duplicate metadata key: " key)
|
|
173
|
+
}
|
|
174
|
+
END { if (bad) exit 1; if (!closed) fail("frontmatter must close with ---") }
|
|
175
|
+
' "$1"
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
validate_prd() {
|
|
179
|
+
local ref=$1 version
|
|
180
|
+
if ! [[ $ref =~ ^\.prd/prd-v([1-9][0-9]{0,8})\.md$ ]]; then
|
|
181
|
+
printf 'pincer: invalid PRD reference %s; use .prd/prd-vN.md\n' "$ref" >&2; return 1
|
|
182
|
+
fi
|
|
183
|
+
version=${BASH_REMATCH[1]}
|
|
184
|
+
[ -f "$ref" ] || { printf 'pincer: PRD does not exist: %s\n' "$ref" >&2; return 1; }
|
|
185
|
+
validate_metadata "$ref" || return 1
|
|
186
|
+
[ "$(fm_get "$ref" version)" = "$version" ] || { printf 'pincer: %s: version must match filename (%s)\n' "$ref" "$version" >&2; return 1; }
|
|
187
|
+
case "$(fm_get "$ref" status)" in draft|ticketed|built) ;; *) printf 'pincer: %s: PRD status must be draft, ticketed, or built\n' "$ref" >&2; return 1 ;; esac
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
latest_prd() {
|
|
191
|
+
local ref latest='' highest=0 n
|
|
192
|
+
for ref in .prd/prd-v*.md; do
|
|
193
|
+
[ -e "$ref" ] || continue
|
|
194
|
+
if ! [[ $ref =~ ^\.prd/prd-v([1-9][0-9]{0,8})\.md$ ]]; then
|
|
195
|
+
printf 'pincer: invalid PRD filename: %s\n' "$ref" >&2; return 1
|
|
196
|
+
fi
|
|
197
|
+
n=${BASH_REMATCH[1]}
|
|
198
|
+
if [ "$n" -gt "$highest" ]; then highest=$n; latest=$ref; fi
|
|
199
|
+
done
|
|
200
|
+
[ -z "$latest" ] || validate_prd "$latest" || return 1
|
|
201
|
+
printf '%s' "$latest"
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
ticket_prd() { # resolve without writing; ambiguous legacy tickets require bind.
|
|
205
|
+
local file=$1 ref candidate count=0
|
|
206
|
+
ref=$(fm_get "$file" prd)
|
|
207
|
+
if [ -z "$ref" ]; then
|
|
208
|
+
for candidate in .prd/prd-v*.md; do
|
|
209
|
+
[ -e "$candidate" ] || continue
|
|
210
|
+
ref=$candidate; count=$((count + 1))
|
|
211
|
+
done
|
|
212
|
+
if [ "$count" -ne 1 ]; then
|
|
213
|
+
printf 'pincer: %s: missing or ambiguous PRD association; use pincer-ticket.sh bind %s .prd/prd-vN.md\n' "$file" "$(fm_get "$file" ticket)" >&2
|
|
214
|
+
return 1
|
|
215
|
+
fi
|
|
216
|
+
fi
|
|
217
|
+
validate_prd "$ref" || return 1
|
|
218
|
+
printf '%s' "$ref"
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
usable_ticket_prd() {
|
|
222
|
+
local ref
|
|
223
|
+
ref=$(ticket_prd "$1") || return 1
|
|
224
|
+
case "$(fm_get "$ref" status)" in
|
|
225
|
+
ticketed|built) printf '%s' "$ref" ;;
|
|
226
|
+
*) printf 'pincer: %s: PRD is draft; complete the authorized breakdown before starting (expected ticketed or built)\n' "$ref" >&2; return 1 ;;
|
|
227
|
+
esac
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
sha256() { if command -v sha256sum >/dev/null; then sha256sum; else shasum -a 256; fi; }
|
|
231
|
+
verify_hash() { verification_cmds "$1" | sha256 | cut -c1-12; }
|
|
232
|
+
|
|
233
|
+
ticket_readiness() { # explain why a done ticket needs attention, without mutation.
|
|
234
|
+
local file=$1 receipt attempt hash
|
|
235
|
+
receipt=$(fm_get "$file" verified); attempt=$(fm_get "$file" last_check)
|
|
236
|
+
hash=$(verify_hash "$file")
|
|
237
|
+
if [ -z "$attempt" ]; then
|
|
238
|
+
printf 'missing latest verification outcome — re-run verify'; return 1
|
|
239
|
+
fi
|
|
240
|
+
if ! [[ $attempt =~ ^[0-9T:Z-]+\ passed\ [a-f0-9]{12}$ ]] || [ "${attempt##* }" != "$hash" ]; then
|
|
241
|
+
printf 'latest verification: %s — re-run verify' "$attempt"; return 1
|
|
242
|
+
fi
|
|
243
|
+
if [ -z "$receipt" ]; then printf 'done without a verification receipt — re-run verify'; return 1; fi
|
|
244
|
+
if ! [[ $receipt =~ ^[0-9T:Z-]+\ [a-f0-9]{12}$ ]] || [ "${receipt##* }" != "$hash" ]; then
|
|
245
|
+
printf 'stale or malformed verification receipt — re-run verify'; return 1
|
|
246
|
+
fi
|
|
247
|
+
if [ -n "$(unticked "$file")" ]; then printf 'unticked acceptance criteria — complete and re-run verify'; return 1; fi
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
notes_current() {
|
|
251
|
+
local prd=$1 candidate base changes
|
|
252
|
+
[ -f NOTES.md ] || { printf 'missing'; return 1; }
|
|
253
|
+
validate_metadata NOTES.md >/dev/null 2>&1 || { printf 'stale: invalid or missing evaluation metadata'; return 1; }
|
|
254
|
+
[ "$(fm_get NOTES.md prd)" = "$prd" ] || { printf 'stale: evaluation PRD does not match'; return 1; }
|
|
255
|
+
candidate=$(fm_get NOTES.md candidate); base=$(fm_get NOTES.md base)
|
|
256
|
+
if ! [[ $candidate =~ ^([a-f0-9]{40}|[a-f0-9]{64})$ ]] || ! [[ $base =~ ^([a-f0-9]{40}|[a-f0-9]{64})$ ]]; then
|
|
257
|
+
printf 'stale: candidate and base must be full commit IDs'; return 1
|
|
258
|
+
fi
|
|
259
|
+
if ! git rev-parse --verify "$candidate^{commit}" >/dev/null 2>&1 ||
|
|
260
|
+
! git rev-parse --verify "$base^{commit}" >/dev/null 2>&1 ||
|
|
261
|
+
! git merge-base --is-ancestor "$base" "$candidate" 2>/dev/null ||
|
|
262
|
+
! git merge-base --is-ancestor "$candidate" HEAD 2>/dev/null; then
|
|
263
|
+
printf 'stale: evaluation commits or ancestry unavailable'; return 1
|
|
264
|
+
fi
|
|
265
|
+
changes=$(git diff --name-only "$candidate" HEAD -- . ':(exclude)NOTES.md') || return 1
|
|
266
|
+
if [ -n "$changes" ]; then printf 'stale: candidate changed after evaluation'; return 1; fi
|
|
267
|
+
changes=$(git status --porcelain --untracked-files=all -- . ':(exclude)NOTES.md') || return 1
|
|
268
|
+
if [ -n "$changes" ]; then printf 'stale: working tree has changes outside NOTES.md'; return 1; fi
|
|
269
|
+
printf 'current (%s)' "$candidate"
|
|
270
|
+
}
|