pincer-workflow 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +15 -14
  2. package/bin/pincer.js +47 -11
  3. package/package.json +2 -2
  4. package/template/.agents/skills/pincer-code/SKILL.md +17 -14
  5. package/template/.agents/skills/pincer-evaluate/SKILL.md +28 -9
  6. package/template/.agents/skills/pincer-narrow/SKILL.md +27 -21
  7. package/template/.agents/skills/pincer-plan/SKILL.md +28 -20
  8. package/template/.agents/skills/pincer-release/SKILL.md +14 -9
  9. package/template/.agents/skills/pincer-status/SKILL.md +1 -1
  10. package/template/.claude/commands/pincer-code.md +16 -13
  11. package/template/.claude/commands/pincer-evaluate.md +28 -9
  12. package/template/.claude/commands/pincer-narrow.md +25 -19
  13. package/template/.claude/commands/pincer-plan.md +26 -18
  14. package/template/.claude/commands/pincer-release.md +13 -8
  15. package/template/.claude/commands/pincer-status.md +1 -1
  16. package/template/.claude/hooks/block-dangerous.sh +7 -18
  17. package/template/.claude/hooks/hook-policy.cjs +258 -0
  18. package/template/.claude/hooks/ticket-guard.sh +6 -63
  19. package/template/.claude/references/prd-template.md +3 -3
  20. package/template/.claude/references/ticket-template.md +22 -4
  21. package/template/.codex/README.md +4 -4
  22. package/template/.github/prompts/pincer-code.prompt.md +16 -13
  23. package/template/.github/prompts/pincer-evaluate.prompt.md +28 -9
  24. package/template/.github/prompts/pincer-narrow.prompt.md +25 -19
  25. package/template/.github/prompts/pincer-plan.prompt.md +26 -18
  26. package/template/.github/prompts/pincer-release.prompt.md +13 -8
  27. package/template/.github/prompts/pincer-status.prompt.md +1 -1
  28. package/template/AGENTS.md +2 -3
  29. package/template/docs/dry-run-checklist.md +19 -15
  30. package/template/docs/release-checklist.md +34 -0
  31. package/template/scripts/pincer-status.sh +64 -23
  32. package/template/scripts/pincer-ticket-lib.sh +270 -0
  33. package/template/scripts/pincer-ticket.sh +57 -46
  34. package/template/scripts/sync-prompts.sh +6 -1
@@ -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
- # Build budget: PINCER_BUILD_BUDGET_MIN (default 75).
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:-75}
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=$(ls .prd/prd-v*.md 2>/dev/null | sort -V | tail -1 || true)
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=$(ls tickets/T-[0-9]*.md 2>/dev/null | sort || true)
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
- for f in $files; do
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
- [ -n "$verified" ] || warn="$warn WARN $id is done without a verification receipt — was it marked done by hand?\n"
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=$(ls "tickets/$d"-*.md 2>/dev/null | head -1 || true)
78
- [ -n "$df" ] && [ "$(fm_get "$df" status)" = done ] || blocked="$blocked $d"
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
- echo "Build elapsed $(mins "$first_start" "$NOW") since the first ticket started · budget ${BUDGET}m"
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
- [ -f NOTES.md ] && notes=yes || notes=no
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 [ "$notes" = no ]; then
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 — evaluate has run (NOTES.md exists); audit the artifacts"
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
+ }
@@ -11,38 +11,17 @@
11
11
  #
12
12
  # The receipt is `verified: <UTC time> <12-hex hash of the Verification block>`.
13
13
  # Change the check after it passed and `done` refuses until it passes again.
14
+ # Every attempt revokes its predecessor; done runs a fresh final check.
14
15
  set -euo pipefail
15
16
 
17
+ source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/pincer-ticket-lib.sh"
18
+
16
19
  ROOT=${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}
17
20
  cd "$ROOT"
18
21
 
19
22
  die() { printf 'pincer-ticket: %b\n' "$*" >&2; exit 1; }
20
23
  now() { date -u +%Y-%m-%dT%H:%M:%SZ; }
21
- sha256() { if command -v sha256sum >/dev/null; then sha256sum; else shasum -a 256; fi; }
22
-
23
- normalize() { # T-3 / t-03 / 3 -> T-03
24
- local n=${1#T-}; n=${n#t-}
25
- [[ $n =~ ^[0-9]+$ ]] || die "not a ticket id: $1"
26
- printf 'T-%02d' "$((10#$n))"
27
- }
28
24
 
29
- ticket_file() { # id -> tickets/T-NN-*.md (exactly one)
30
- local id; id=$(normalize "$1")
31
- local matches; matches=$(ls "tickets/$id"-*.md 2>/dev/null || true)
32
- [ -n "$matches" ] || die "no ticket file tickets/$id-*.md"
33
- [ "$(printf '%s\n' "$matches" | wc -l)" -eq 1 ] || die "several files match tickets/$id-*.md"
34
- printf '%s' "$matches"
35
- }
36
-
37
- fm_get() { # file key -> value with any inline comment stripped; empty if absent
38
- awk -v k="$2" '
39
- NR == 1 && $0 != "---" { exit }
40
- NR > 1 && $0 == "---" { exit }
41
- NR > 1 && index($0, k ":") == 1 {
42
- v = substr($0, length(k) + 2); sub(/#.*/, "", v)
43
- gsub(/^[ \t]+|[ \t]+$/, "", v); print v; exit
44
- }' "$1"
45
- }
46
25
 
47
26
  fm_set() { # file key value — replace inside the frontmatter (keeping an inline comment) or add before the closing ---
48
27
  local tmp; tmp=$(mktemp)
@@ -56,79 +35,111 @@ fm_set() { # file key value — replace inside the frontmatter (keeping an inlin
56
35
  { print }' "$1" > "$tmp" && mv "$tmp" "$1"
57
36
  }
58
37
 
59
- verification_cmds() { # the fenced block under "## Verification"
60
- awk '
61
- /^## Verification/ { inv = 1; next }
62
- inv && /^## / { exit }
63
- inv && /^```/ { if (inb) exit; inb = 1; next }
64
- inb { print }' "$1"
38
+ fm_unset() {
39
+ local tmp; tmp=$(mktemp)
40
+ awk -v k="$2" 'NR > 1 && $0 == "---" { closed = 1 }
41
+ !closed && index($0, k ":") == 1 { next } { print }' "$1" > "$tmp" && mv "$tmp" "$1"
65
42
  }
66
43
 
67
- verify_hash() { verification_cmds "$1" | sha256 | cut -c1-12; }
68
-
69
- unticked() { # unticked boxes inside "## Acceptance Criteria"
70
- awk '
71
- /^## Acceptance Criteria/ { ina = 1; next }
72
- ina && /^## / { exit }
73
- ina && /^- \[ \]/ { print }' "$1"
44
+ cmd_bind() {
45
+ local f ref existing
46
+ f=$(ticket_file "$1"); ref=$2
47
+ validate_prd "$ref" || exit 1
48
+ existing=$(fm_get "$f" prd)
49
+ [ -z "$existing" ] || [ "$existing" = "$ref" ] || die "$1 already references $existing; refusing to rebind it to $ref"
50
+ [ "$existing" = "$ref" ] || fm_set "$f" prd "$ref"
51
+ echo "$1 bound to PRD $ref"
74
52
  }
75
53
 
76
54
  cmd_start() {
77
- local f id st dep df
55
+ local f id st dep df ref dep_ref readiness
78
56
  f=$(ticket_file "$1"); id=$(normalize "$1"); st=$(fm_get "$f" status)
57
+ ref=$(usable_ticket_prd "$f") || exit 1
79
58
  case "$st" in
80
- in_progress) echo "$id already in progress (started $(fm_get "$f" started))"; return 0 ;;
59
+ in_progress)
60
+ [ -n "$(fm_get "$f" prd)" ] || fm_set "$f" prd "$ref"
61
+ echo "$id already in progress (started $(fm_get "$f" started))"; return 0 ;;
81
62
  done) die "$id is already done" ;;
82
63
  esac
83
64
  for dep in $(fm_get "$f" depends_on | grep -oE 'T-[0-9]+' || true); do
84
65
  df=$(ticket_file "$dep")
85
66
  [ "$(fm_get "$df" status)" = done ] || die "$id depends on $dep, which is '$(fm_get "$df" status)' — finish $dep first (or fix depends_on in $f)"
67
+ dep_ref=$(usable_ticket_prd "$df") || exit 1
68
+ [ "$dep_ref" = "$ref" ] || die "$id references $ref but dependency $dep references $dep_ref"
69
+ if ! readiness=$(ticket_readiness "$df"); then die "$id depends on $dep: $readiness"; fi
86
70
  done
71
+ [ -n "$(fm_get "$f" prd)" ] || fm_set "$f" prd "$ref"
87
72
  fm_set "$f" status in_progress
88
73
  [ -n "$(fm_get "$f" started)" ] || fm_set "$f" started "$(now)"
89
74
  echo "▶ $id started $(fm_get "$f" started) — $f"
90
75
  }
91
76
 
92
77
  cmd_verify() {
93
- local f id st cmds rc
78
+ local f id st cmds rc hash child
94
79
  f=$(ticket_file "$1"); id=$(normalize "$1"); st=$(fm_get "$f" status)
80
+ usable_ticket_prd "$f" >/dev/null || exit 1
95
81
  case "$st" in
96
82
  open) cmd_start "$id" ;;
97
- done) echo "$id is done — re-running its check (receipt left unchanged)" ;;
83
+ done) echo "$id is done — re-running its check and updating the latest outcome" ;;
98
84
  esac
99
85
  cmds=$(verification_cmds "$f")
86
+ fm_unset "$f" verified
87
+ hash=$(verify_hash "$f")
88
+ fm_set "$f" last_check "$(now) running $hash"
100
89
  printf '%s\n' "$cmds" | grep -vE '^[[:space:]]*(#|$)' >/dev/null || die "no runnable command in the Verification block of $f"
101
90
  echo "── $id verification ──"
102
91
  printf '%s\n' "$cmds" | sed 's/^/ $ /'
103
- set +e; bash -eo pipefail -c "$cmds"; rc=$?; set -e
92
+ bash -eo pipefail -c "$cmds" & child=$!
93
+ trap 'kill "$child" 2>/dev/null || true; fm_set "$f" last_check "$(now) interrupted $hash"; exit 130' INT TERM
94
+ set +e; wait "$child"; rc=$?; set -e
95
+ trap - INT TERM
104
96
  if [ "$rc" -ne 0 ]; then
97
+ fm_set "$f" last_check "$(now) failed $hash"
105
98
  echo "✗ $id verification FAILED (exit $rc) — no receipt written. Fix, then re-run." >&2
106
99
  exit "$rc"
107
100
  fi
108
- [ "$st" = done ] || fm_set "$f" verified "$(now) $(verify_hash "$f")"
101
+ if [ "$hash" != "$(verify_hash "$f")" ]; then
102
+ fm_set "$f" last_check "$(now) failed $hash"
103
+ die "Verification block changed during execution — re-run verify"
104
+ fi
105
+ if ! validate_ticket "$f" || ! usable_ticket_prd "$f" >/dev/null; then
106
+ fm_set "$f" last_check "$(now) failed $hash"
107
+ die "ticket became invalid during verification — fix it and re-run verify"
108
+ fi
109
+ fm_set "$f" last_check "$(now) passed $hash"
110
+ fm_set "$f" verified "$(now) $hash"
109
111
  echo "✓ $id verified — receipt: $(fm_get "$f" verified)"
110
112
  }
111
113
 
112
114
  cmd_done() {
113
115
  local f id st rec cur u slug
114
116
  f=$(ticket_file "$1"); id=$(normalize "$1"); st=$(fm_get "$f" status)
115
- [ "$st" = done ] && { echo "$id already done"; return 0; }
116
- [ "$st" = in_progress ] || die "$id is '$st' — run '$0 verify $id' first"
117
+ usable_ticket_prd "$f" >/dev/null || exit 1
118
+ [ "$st" = in_progress ] || [ "$st" = done ] || die "$id is '$st' — run '$0 verify $id' first"
117
119
  rec=$(fm_get "$f" verified)
118
120
  [ -n "$rec" ] || die "no verification receipt on $id — run '$0 verify $id' and get a green check first"
119
121
  cur=$(verify_hash "$f")
120
122
  [ "${rec##* }" = "$cur" ] || die "receipt hash ${rec##* } does not match the current Verification block ($cur): the check changed after it passed — run '$0 verify $id' again"
121
123
  u=$(unticked "$f")
122
124
  [ -z "$u" ] || die "unticked acceptance criteria on $id:\n$u\nTick each verified criterion; a criterion that was cut is a scope change to record in the PRD, not a box to skip."
125
+ cmd_verify "$id"
126
+ u=$(unticked "$f")
127
+ [ -z "$u" ] || die "unticked acceptance criteria on $id after verification:\n$u"
128
+ [ "$st" = done ] && { echo "$id already done — current check passed"; return 0; }
123
129
  fm_set "$f" status done
124
130
  fm_set "$f" finished "$(now)"
125
131
  slug=$(basename "$f" .md); slug=${slug#T-[0-9][0-9]-}
126
- echo "✓ $id done. Commit it with the code: git add -A && git commit -m \"$id: ${slug//-/ }\""
132
+ echo "✓ $id done. Inspect staged work, stage only this ticket's paths, review git diff --cached, then commit: $id: ${slug//-/ }"
127
133
  }
128
134
 
135
+ case "${1:-}" in
136
+ start|verify|done|bind) validate_ticket_set || exit 1 ;;
137
+ esac
138
+
129
139
  case "${1:-}" in
130
140
  start) [ $# -eq 2 ] || die "usage: $0 start T-NN"; cmd_start "$2" ;;
131
141
  verify) [ $# -eq 2 ] || die "usage: $0 verify T-NN"; cmd_verify "$2" ;;
132
142
  done) [ $# -eq 2 ] || die "usage: $0 done T-NN"; cmd_done "$2" ;;
143
+ bind) [ $# -eq 3 ] || die "usage: $0 bind T-NN .prd/prd-vN.md"; cmd_bind "$2" "$3" ;;
133
144
  *) sed -n '2,13p' "$0" | sed 's/^# \{0,1\}//'; exit 1 ;;
134
145
  esac