pincer-workflow 0.4.0 → 0.5.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 (45) hide show
  1. package/README.md +7 -5
  2. package/bin/pincer.js +42 -5
  3. package/package.json +2 -2
  4. package/template/.agents/skills/pincer-code/SKILL.md +51 -4
  5. package/template/.agents/skills/pincer-evaluate/SKILL.md +29 -2
  6. package/template/.agents/skills/pincer-narrow/SKILL.md +11 -2
  7. package/template/.agents/skills/pincer-plan/SKILL.md +9 -1
  8. package/template/.agents/skills/pincer-release/SKILL.md +14 -3
  9. package/template/.agents/skills/pincer-status/SKILL.md +17 -2
  10. package/template/.claude/commands/pincer-code.md +51 -4
  11. package/template/.claude/commands/pincer-evaluate.md +29 -2
  12. package/template/.claude/commands/pincer-narrow.md +11 -2
  13. package/template/.claude/commands/pincer-plan.md +9 -1
  14. package/template/.claude/commands/pincer-release.md +14 -3
  15. package/template/.claude/commands/pincer-status.md +17 -2
  16. package/template/.claude/hooks/hook-policy.cjs +17 -3
  17. package/template/.claude/references/ticket-template.md +4 -0
  18. package/template/.codex/README.md +3 -2
  19. package/template/.github/prompts/pincer-code.prompt.md +51 -4
  20. package/template/.github/prompts/pincer-evaluate.prompt.md +29 -2
  21. package/template/.github/prompts/pincer-narrow.prompt.md +11 -2
  22. package/template/.github/prompts/pincer-plan.prompt.md +9 -1
  23. package/template/.github/prompts/pincer-release.prompt.md +14 -3
  24. package/template/.github/prompts/pincer-status.prompt.md +17 -2
  25. package/template/AGENTS.md +6 -0
  26. package/template/docs/dry-run-checklist.md +46 -3
  27. package/template/docs/release-checklist.md +2 -1
  28. package/template/docs/runtime-contracts.md +437 -0
  29. package/template/scripts/pincer-evidence.cjs +5 -223
  30. package/template/scripts/pincer-runtime/evidence.cjs +391 -0
  31. package/template/scripts/pincer-runtime/fsutil.cjs +37 -0
  32. package/template/scripts/pincer-runtime/identity.cjs +146 -0
  33. package/template/scripts/pincer-runtime/lifecycle.cjs +289 -0
  34. package/template/scripts/pincer-runtime/migrate.cjs +127 -0
  35. package/template/scripts/pincer-runtime/parse.cjs +297 -0
  36. package/template/scripts/pincer-runtime/readiness.cjs +89 -0
  37. package/template/scripts/pincer-runtime/runner.cjs +224 -0
  38. package/template/scripts/pincer-runtime/sanitize.cjs +63 -0
  39. package/template/scripts/pincer-runtime/source.cjs +129 -0
  40. package/template/scripts/pincer-runtime/state.cjs +292 -0
  41. package/template/scripts/pincer-runtime/status.cjs +358 -0
  42. package/template/scripts/pincer-runtime.cjs +350 -0
  43. package/template/scripts/pincer-status.sh +11 -162
  44. package/template/scripts/pincer-ticket.sh +19 -139
  45. package/template/scripts/pincer-ticket-lib.sh +0 -321
@@ -1,145 +1,25 @@
1
1
  #!/bin/bash
2
- # PINCER ticket state machine — the ONLY writer of a ticket's state fields
3
- # (status / started / verified / finished). Works on every platform; on Claude
4
- # Code a PreToolUse hook (.claude/hooks/ticket-guard.sh) blocks hand edits of
5
- # those fields so "done" can only be reached through a passing check.
2
+ # PINCER ticket state machine — the ONLY writer of a ticket's lifecycle fields.
3
+ # Compatibility wrapper: every call is executed by scripts/pincer-runtime.cjs
4
+ # (docs/runtime-contracts.md). On Claude Code a PreToolUse hook
5
+ # (.claude/hooks/ticket-guard.sh) blocks hand edits of those fields so "done"
6
+ # can only be reached through a passing check.
6
7
  #
7
- # scripts/pincer-ticket.sh start T-03 open -> in_progress; refuses while a depends_on ticket isn't done
8
- # scripts/pincer-ticket.sh verify T-03 runs the ticket's Verification block; exit 0 stamps a receipt
9
- # scripts/pincer-ticket.sh done T-03 needs a receipt matching the current Verification block
10
- # and no unticked acceptance criteria; in_progress -> done
8
+ # scripts/pincer-ticket.sh start T-03 open -> in_progress; refuses while a depends_on ticket isn't ready
9
+ # scripts/pincer-ticket.sh verify T-03 runs the ticket's Verification block and records the outcome
10
+ # scripts/pincer-ticket.sh done T-03 closes the ticket on a current passing check with all criteria ticked
11
+ # scripts/pincer-ticket.sh bind T-03 .prd/prd-vN.md associates a legacy ticket with its PRD
11
12
  #
12
- # The receipt is `verified: <UTC time> <12-hex hash of the Verification block>`.
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.
15
- set -euo pipefail
16
-
17
- source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/pincer-ticket-lib.sh"
18
-
19
- ROOT=${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}
20
- cd "$ROOT"
21
-
22
- die() { printf 'pincer-ticket: %b\n' "$*" >&2; exit 1; }
23
- now() { date -u +%Y-%m-%dT%H:%M:%SZ; }
24
-
25
-
26
- fm_set() { # file key value — replace inside the frontmatter (keeping an inline comment) or add before the closing ---
27
- local tmp; tmp=$(mktemp)
28
- awk -v k="$2" -v v="$3" '
29
- NR == 1 { print; next }
30
- !closed && !done && index($0, k ":") == 1 {
31
- c = $0; if (sub(/^[^#]*#/, "#", c)) print k ": " v " " c; else print k ": " v
32
- done = 1; next
33
- }
34
- !closed && $0 == "---" { if (!done) print k ": " v; closed = 1 }
35
- { print }' "$1" > "$tmp" && mv "$tmp" "$1"
13
+ # Unmigrated (legacy) projects keep the v0.4.1 receipts: `verify` writes
14
+ # `last_check` and `verified` into the ticket and `done` re-runs the check.
15
+ # Migrated projects record attempts under .pincer/runtime/ and `done` consumes
16
+ # the current passing attempt. Exit codes follow docs/runtime-contracts.md.
17
+ command -v node >/dev/null 2>&1 || {
18
+ echo 'pincer-ticket: Node.js 18+ is required — the runtime is scripts/pincer-runtime.cjs' >&2
19
+ exit 4
36
20
  }
37
-
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"
42
- }
43
-
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"
52
- }
53
-
54
- cmd_start() {
55
- local f id st dep df ref dep_ref readiness
56
- f=$(ticket_file "$1"); id=$(normalize "$1"); st=$(fm_get "$f" status)
57
- ref=$(usable_ticket_prd "$f") || exit 1
58
- case "$st" in
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 ;;
62
- done) die "$id is already done" ;;
63
- esac
64
- for dep in $(fm_get "$f" depends_on | grep -oE 'T-[0-9]+' || true); do
65
- df=$(ticket_file "$dep")
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
70
- done
71
- [ -n "$(fm_get "$f" prd)" ] || fm_set "$f" prd "$ref"
72
- fm_set "$f" status in_progress
73
- [ -n "$(fm_get "$f" started)" ] || fm_set "$f" started "$(now)"
74
- echo "▶ $id started $(fm_get "$f" started) — $f"
75
- }
76
-
77
- cmd_verify() {
78
- local f id st cmds rc hash child
79
- f=$(ticket_file "$1"); id=$(normalize "$1"); st=$(fm_get "$f" status)
80
- usable_ticket_prd "$f" >/dev/null || exit 1
81
- case "$st" in
82
- open) cmd_start "$id" ;;
83
- done) echo "$id is done — re-running its check and updating the latest outcome" ;;
84
- esac
85
- cmds=$(verification_cmds "$f")
86
- fm_unset "$f" verified
87
- hash=$(verify_hash "$f")
88
- fm_set "$f" last_check "$(now) running $hash"
89
- printf '%s\n' "$cmds" | grep -vE '^[[:space:]]*(#|$)' >/dev/null || die "no runnable command in the Verification block of $f"
90
- echo "── $id verification ──"
91
- printf '%s\n' "$cmds" | sed 's/^/ $ /'
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
96
- if [ "$rc" -ne 0 ]; then
97
- fm_set "$f" last_check "$(now) failed $hash"
98
- echo "✗ $id verification FAILED (exit $rc) — failure recorded in last_check of $f; any prior successful receipt was revoked. Fix, then re-run verify." >&2
99
- exit "$rc"
100
- fi
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"
111
- echo "✓ $id verified — receipt: $(fm_get "$f" verified)"
112
- }
113
-
114
- cmd_done() {
115
- local f id st rec cur u slug
116
- f=$(ticket_file "$1"); id=$(normalize "$1"); st=$(fm_get "$f" status)
117
- usable_ticket_prd "$f" >/dev/null || exit 1
118
- [ "$st" = in_progress ] || [ "$st" = done ] || die "$id is '$st' — run '$0 verify $id' first"
119
- rec=$(fm_get "$f" verified)
120
- [ -n "$rec" ] || die "no verification receipt on $id — run '$0 verify $id' and get a green check first"
121
- cur=$(verify_hash "$f")
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"
123
- u=$(unticked "$f")
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; }
129
- fm_set "$f" status done
130
- fm_set "$f" finished "$(now)"
131
- slug=$(basename "$f" .md); slug=${slug#T-[0-9][0-9]-}
132
- echo "✓ $id done. Inspect staged work, stage only this ticket's paths, review git diff --cached, then commit: $id: ${slug//-/ }"
133
- }
134
-
135
- case "${1:-}" in
136
- start|verify|done|bind) validate_ticket_set || exit 1 ;;
137
- esac
138
-
139
21
  case "${1:-}" in
140
- start) [ $# -eq 2 ] || die "usage: $0 start T-NN"; cmd_start "$2" ;;
141
- verify) [ $# -eq 2 ] || die "usage: $0 verify T-NN"; cmd_verify "$2" ;;
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" ;;
144
- *) sed -n '2,13p' "$0" | sed 's/^# \{0,1\}//'; exit 1 ;;
22
+ start|verify|done|bind) ;;
23
+ *) sed -n '8,11p' "$0" | sed 's/^# \{0,1\}//'; exit 2 ;;
145
24
  esac
25
+ exec node "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/pincer-runtime.cjs" "$@"
@@ -1,321 +0,0 @@
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
- PINCER_LIB_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
6
- PINCER_EVIDENCE="$PINCER_LIB_DIR/pincer-evidence.cjs"
7
-
8
- # Run the shared evidence validator; prints its combined output, returns its status.
9
- # Usage: evidence_validate <manifest> <candidate> <base> <prd> [--files]
10
- # Absent or malformed candidate/base/prd values are simply not checked, so a bad
11
- # NOTES.md field is reported by notes_current, never as a manifest verdict.
12
- evidence_validate() {
13
- local manifest=$1 candidate=$2 base=$3 prd=$4; shift 4
14
- command -v node >/dev/null 2>&1 || { printf 'evidence: %s: Node.js 18+ is required to validate evidence\n' "$manifest"; return 1; }
15
- local -a args=(validate "$manifest")
16
- if [[ $candidate =~ ^[a-f0-9]{40}$ ]]; then args+=(--candidate "$candidate"); fi
17
- if [[ $base =~ ^[a-f0-9]{40}$ ]]; then args+=(--base "$base"); fi
18
- if [[ $prd =~ ^\.prd/prd-v[1-9][0-9]{0,8}\.md$ ]]; then args+=(--prd "$prd"); fi
19
- node "$PINCER_EVIDENCE" "${args[@]}" "$@" 2>&1
20
- }
21
- evidence_reason() { # first diagnostic line without the manifest prefix; usage errors are labelled
22
- printf '%s\n' "$1" | head -1 | sed -e 's/^evidence: [^:]*: //' -e 's/^pincer-evidence: /validator usage: /'
23
- }
24
-
25
- normalize() { # CLI shorthand -> canonical ID; bound arithmetic before conversion.
26
- local n=${1#T-}; n=${n#t-}
27
- if ! [[ $n =~ ^[0-9]{1,6}$ ]] || [ "$((10#$n))" -eq 0 ]; then
28
- printf 'pincer-ticket: not a ticket id (expected 1..999999): %s\n' "$1" >&2
29
- return 1
30
- fi
31
- printf 'T-%02d' "$((10#$n))"
32
- }
33
-
34
- fm_get() {
35
- awk -v k="$2" '
36
- NR == 1 && $0 != "---" { exit }
37
- NR > 1 && $0 == "---" { exit }
38
- NR > 1 && index($0, k ":") == 1 {
39
- v = substr($0, length(k) + 2); sub(/#.*/, "", v)
40
- gsub(/^[ \t]+|[ \t]+$/, "", v); print v; exit
41
- }' "$1"
42
- }
43
-
44
- verification_cmds() {
45
- awk '
46
- inb { if ($0 ~ /^[ \t]*```[ \t]*$/) exit; print; next }
47
- otherfence { if ($0 ~ /^[ \t]*```/) otherfence = 0; next }
48
- /^## Verification[ \t]*$/ { inv = 1; next }
49
- inv && /^[ \t]*```bash[ \t]*$/ { inb = 1; next }
50
- inv && /^## / { exit }
51
- /^[ \t]*```/ { otherfence = 1 }
52
- ' "$1"
53
- }
54
-
55
- unticked() {
56
- awk '
57
- otherfence { if ($0 ~ /^[ \t]*```/) otherfence = 0; next }
58
- /^[ \t]*```/ { otherfence = 1; next }
59
- /^## Acceptance Criteria[ \t]*$/ { ina = 1; next }
60
- ina && /^## / { exit }
61
- ina && /^[ \t]*([-+*]|[0-9]+[.)])[ \t]+\[ \]/ { print }
62
- ' "$1"
63
- }
64
-
65
- validate_ticket() {
66
- local file=$1 cmds
67
- awk -v file="$file" '
68
- function fail(msg) { print "pincer-ticket: " file ": " msg > "/dev/stderr"; bad = 1; exit 1 }
69
- function trim(s) { gsub(/^[ \t]+|[ \t]+$/, "", s); return s }
70
- function idok(s, n) {
71
- if (s !~ /^T-[0-9][0-9]+$/) return 0
72
- n = substr(s, 3)
73
- return length(n) <= 6 && n + 0 > 0 && sprintf("T-%02d", n + 0) == s
74
- }
75
- NR == 1 { if ($0 != "---") fail("frontmatter must begin with ---"); next }
76
- !closed {
77
- if ($0 == "---") { closed = 1; next }
78
- if ($0 ~ /^[ \t]*(#.*)?$/) next
79
- if ($0 !~ /^[a-z_][a-z0-9_]*:[ \t]*/) fail("frontmatter requires unindented key: value fields")
80
- key = $0; sub(/:.*/, "", key)
81
- if (seen[key]++) fail("duplicate frontmatter key: " key)
82
- value = substr($0, length(key) + 2); sub(/#.*/, "", value); values[key] = trim(value)
83
- next
84
- }
85
- inverify {
86
- if ($0 ~ /^[ \t]*```[ \t]*$/) { inverify = 0; next }
87
- if ($0 ~ /^[ \t]*```/) fail("Verification requires one closed bash fence")
88
- if ($0 !~ /^[ \t]*(#.*)?$/) runnable++
89
- next
90
- }
91
- otherfence {
92
- if ($0 ~ /^[ \t]*```/) otherfence = 0
93
- next
94
- }
95
- /^## Acceptance Criteria[ \t]*$/ { if (accept++) fail("duplicate Acceptance Criteria section"); section = "accept"; next }
96
- /^## Verification[ \t]*$/ { if (verify++) fail("duplicate Verification section"); section = "verify"; next }
97
- /^## / { section = ""; next }
98
- section == "accept" {
99
- if ($0 ~ /^[ \t]*(```|~~~)/) fail("Acceptance Criteria must contain visible checkboxes, not fences")
100
- if ($0 ~ /^[ \t]*([-+*]|[0-9]+[.)])[ \t]+/ || $0 ~ /^[ \t]*([-+*]|[0-9]+[.)])?[ \t]*\[/) {
101
- if ($0 !~ /^[ \t]*([-+*]|[0-9]+[.)])[ \t]+\[[ xX]\][ \t]+[^ \t]/)
102
- fail("malformed acceptance checkbox; use - [ ] text or - [x] text (indented/list marker variants supported)")
103
- boxes++
104
- }
105
- }
106
- /^[ \t]*~~~/ { fail("use backtick fences; tilde fences are unsupported") }
107
- /^[ \t]*```/ {
108
- if (section == "verify") {
109
- if ($0 !~ /^[ \t]*```bash[ \t]*$/ || fences++) fail("Verification requires exactly one bash fence")
110
- inverify = 1
111
- } else otherfence = 1
112
- }
113
- END {
114
- if (bad) exit 1
115
- if (!closed) fail("frontmatter must close with ---")
116
- if (!seen["ticket"] || !idok(values["ticket"])) fail("ticket must be a canonical ID between T-01 and T-999999")
117
- base = file; sub(/^.*\//, "", base)
118
- prefix = values["ticket"] "-"
119
- if (index(base, prefix) != 1 || base !~ /.+\.md$/ || length(base) <= length(prefix) + 3) fail("ticket ID must match filename T-NN-slug.md")
120
- if (values["status"] !~ /^(open|in_progress|done)$/) fail("status must be open, in_progress, or done")
121
- if (values["size"] !~ /^(S|M|L)$/) fail("size must be S, M, or L")
122
- 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$"
123
- for (key in seen) {
124
- if ((key == "started" || key == "finished") && values[key] !~ timestamp) fail(key " must be an ISO UTC timestamp")
125
- if (key == "verified" || key == "last_check") {
126
- parts = split(values[key], stamp, /[ \t]+/)
127
- expected = key == "verified" ? 2 : 3
128
- if (parts != expected || stamp[1] !~ timestamp || length(stamp[parts]) != 12 || stamp[parts] !~ /^[0-9a-f]+$/)
129
- fail("malformed " key " timestamp/hash")
130
- if (key == "last_check" && stamp[2] !~ /^(running|passed|failed|interrupted)$/) fail("invalid last_check outcome")
131
- }
132
- }
133
- deps = values["depends_on"]
134
- 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]")
135
- sub(/^\[[ \t]*/, "", deps); sub(/[ \t]*\]$/, "", deps)
136
- count = split(deps, entries, ",")
137
- for (i = 1; i <= count; i++) {
138
- dep = trim(entries[i]); if (dep == "") continue
139
- if (!idok(dep)) fail("depends_on contains noncanonical ticket ID: " dep)
140
- if (dep == values["ticket"]) fail("ticket cannot depend on itself")
141
- if (depseen[dep]++) fail("duplicate depends_on ID: " dep)
142
- }
143
- if (!accept || !boxes) fail("Acceptance Criteria requires at least one nonempty checkbox")
144
- if (!verify || fences != 1 || inverify || !runnable) fail("Verification requires exactly one closed runnable bash fence")
145
- }
146
- ' "$file" || return 1
147
- cmds=$(verification_cmds "$file") || return 1
148
- if ! bash -n -c "$cmds"; then
149
- printf 'pincer-ticket: %s: invalid bash syntax in Verification block\n' "$file" >&2
150
- return 1
151
- fi
152
- }
153
-
154
- validate_ticket_set() {
155
- local file id ids=' '
156
- for file in tickets/T-*.md; do
157
- [ -e "$file" ] || continue
158
- id=$(fm_get "$file" ticket)
159
- if [ -n "$id" ]; then
160
- case "$ids" in *" $id "*) printf 'pincer-ticket: duplicate ticket ID: %s\n' "$id" >&2; return 1 ;; esac
161
- ids="$ids$id "
162
- fi
163
- done
164
- for file in tickets/T-*.md; do
165
- [ -e "$file" ] || continue
166
- validate_ticket "$file" || return 1
167
- done
168
- }
169
-
170
- ticket_file() {
171
- local id file found=''
172
- id=$(normalize "$1") || return 1
173
- for file in "tickets/$id"-*.md; do
174
- [ -e "$file" ] || continue
175
- if [ -n "$found" ]; then printf 'pincer-ticket: several files match tickets/%s-*.md\n' "$id" >&2; return 1; fi
176
- found=$file
177
- done
178
- if [ -z "$found" ]; then printf 'pincer-ticket: no ticket file tickets/%s-*.md\n' "$id" >&2; return 1; fi
179
- printf '%s' "$found"
180
- }
181
-
182
- # PRDs and evaluation notes use the same deliberately small metadata format.
183
- validate_metadata() {
184
- awk -v file="$1" '
185
- function fail(msg) { print "pincer: " file ": " msg > "/dev/stderr"; bad = 1; exit 1 }
186
- NR == 1 { if ($0 != "---") fail("frontmatter must begin with ---"); next }
187
- !closed {
188
- if ($0 == "---") { closed = 1; next }
189
- if ($0 ~ /^[ \t]*(#.*)?$/) next
190
- if ($0 !~ /^[a-z_][a-z0-9_]*:[ \t]*/) fail("expected unindented key: value metadata")
191
- key = $0; sub(/:.*/, "", key)
192
- if (seen[key]++) fail("duplicate metadata key: " key)
193
- }
194
- END { if (bad) exit 1; if (!closed) fail("frontmatter must close with ---") }
195
- ' "$1"
196
- }
197
-
198
- validate_prd() {
199
- local ref=$1 version
200
- if ! [[ $ref =~ ^\.prd/prd-v([1-9][0-9]{0,8})\.md$ ]]; then
201
- printf 'pincer: invalid PRD reference %s; use .prd/prd-vN.md\n' "$ref" >&2; return 1
202
- fi
203
- version=${BASH_REMATCH[1]}
204
- [ -f "$ref" ] || { printf 'pincer: PRD does not exist: %s\n' "$ref" >&2; return 1; }
205
- validate_metadata "$ref" || return 1
206
- [ "$(fm_get "$ref" version)" = "$version" ] || { printf 'pincer: %s: version must match filename (%s)\n' "$ref" "$version" >&2; return 1; }
207
- 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
208
- case "$(fm_get "$ref" profile)" in ''|small|standard) ;; *) printf 'pincer: %s: profile must be small or standard (omit for standard)\n' "$ref" >&2; return 1 ;; esac
209
- }
210
-
211
- prd_profile() { # effective planning profile; older PRDs without the field are standard
212
- local profile; profile=$(fm_get "$1" profile)
213
- printf '%s' "${profile:-standard}"
214
- }
215
-
216
- latest_prd() {
217
- local ref latest='' highest=0 n
218
- for ref in .prd/prd-v*.md; do
219
- [ -e "$ref" ] || continue
220
- if ! [[ $ref =~ ^\.prd/prd-v([1-9][0-9]{0,8})\.md$ ]]; then
221
- printf 'pincer: invalid PRD filename: %s\n' "$ref" >&2; return 1
222
- fi
223
- n=${BASH_REMATCH[1]}
224
- if [ "$n" -gt "$highest" ]; then highest=$n; latest=$ref; fi
225
- done
226
- [ -z "$latest" ] || validate_prd "$latest" || return 1
227
- printf '%s' "$latest"
228
- }
229
-
230
- ticket_prd() { # resolve without writing; ambiguous legacy tickets require bind.
231
- local file=$1 ref candidate count=0
232
- ref=$(fm_get "$file" prd)
233
- if [ -z "$ref" ]; then
234
- for candidate in .prd/prd-v*.md; do
235
- [ -e "$candidate" ] || continue
236
- ref=$candidate; count=$((count + 1))
237
- done
238
- if [ "$count" -ne 1 ]; then
239
- 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
240
- return 1
241
- fi
242
- fi
243
- validate_prd "$ref" || return 1
244
- printf '%s' "$ref"
245
- }
246
-
247
- usable_ticket_prd() {
248
- local ref
249
- ref=$(ticket_prd "$1") || return 1
250
- case "$(fm_get "$ref" status)" in
251
- ticketed|built) printf '%s' "$ref" ;;
252
- *) printf 'pincer: %s: PRD is draft; complete the authorized breakdown before starting (expected ticketed or built)\n' "$ref" >&2; return 1 ;;
253
- esac
254
- }
255
-
256
- sha256() { if command -v sha256sum >/dev/null; then sha256sum; else shasum -a 256; fi; }
257
- verify_hash() { verification_cmds "$1" | sha256 | cut -c1-12; }
258
-
259
- ticket_readiness() { # explain why a done ticket needs attention, without mutation.
260
- local file=$1 receipt attempt hash
261
- receipt=$(fm_get "$file" verified); attempt=$(fm_get "$file" last_check)
262
- hash=$(verify_hash "$file")
263
- if [ -z "$attempt" ]; then
264
- printf 'missing latest verification outcome — re-run verify'; return 1
265
- fi
266
- if ! [[ $attempt =~ ^[0-9T:Z-]+\ passed\ [a-f0-9]{12}$ ]] || [ "${attempt##* }" != "$hash" ]; then
267
- printf 'latest verification: %s — re-run verify' "$attempt"; return 1
268
- fi
269
- if [ -z "$receipt" ]; then printf 'done without a verification receipt — re-run verify'; return 1; fi
270
- if ! [[ $receipt =~ ^[0-9T:Z-]+\ [a-f0-9]{12}$ ]] || [ "${receipt##* }" != "$hash" ]; then
271
- printf 'stale or malformed verification receipt — re-run verify'; return 1
272
- fi
273
- if [ -n "$(unticked "$file")" ]; then printf 'unticked acceptance criteria — complete and re-run verify'; return 1; fi
274
- }
275
-
276
- # An evaluation is current when NOTES.md names this PRD, a reviewed base and
277
- # candidate that are ancestors of HEAD, and an evidence manifest that validates
278
- # for that candidate; after the candidate, only NOTES.md and the files the
279
- # manifest lists may have changed, all of them tracked, and the working tree is
280
- # clean apart from NOTES.md. Legacy notes without a manifest never grant readiness.
281
- notes_current() {
282
- local prd=$1 candidate base changes manifest output listed file stored canonical offending
283
- [ -f NOTES.md ] || { printf 'missing'; return 1; }
284
- validate_metadata NOTES.md >/dev/null 2>&1 || { printf 'stale: invalid or missing evaluation metadata'; return 1; }
285
- [ "$(fm_get NOTES.md prd)" = "$prd" ] || { printf 'stale: evaluation PRD does not match'; return 1; }
286
- candidate=$(fm_get NOTES.md candidate); base=$(fm_get NOTES.md base)
287
- if ! [[ $candidate =~ ^[a-f0-9]{40}$ ]] || ! [[ $base =~ ^[a-f0-9]{40}$ ]]; then
288
- printf 'stale: candidate and base must be full 40-hex commit IDs'; return 1
289
- fi
290
- if ! git rev-parse --verify "$candidate^{commit}" >/dev/null 2>&1 ||
291
- ! git rev-parse --verify "$base^{commit}" >/dev/null 2>&1 ||
292
- ! git merge-base --is-ancestor "$base" "$candidate" 2>/dev/null ||
293
- ! git merge-base --is-ancestor "$candidate" HEAD 2>/dev/null; then
294
- printf 'stale: evaluation commits or ancestry unavailable'; return 1
295
- fi
296
- manifest=$(fm_get NOTES.md evidence)
297
- if [ -z "$manifest" ]; then
298
- printf 'stale: legacy evaluation without evidence manifest — re-run /pincer-evaluate for evidence schema 1'; return 1
299
- fi
300
- if ! output=$(evidence_validate "$manifest" "$candidate" "$base" "$prd" --files); then
301
- printf 'stale: evidence invalid: %s' "$(evidence_reason "$output")"; return 1
302
- fi
303
- listed=$(printf '%s\n' "$output" | tail -n +2)
304
- # Every listed file must be tracked; keep git's own spelling of each path
305
- # (unquoted, relative to this directory, NFC where git normalises) so the set
306
- # compares byte-for-byte with the diff below.
307
- canonical=""
308
- while IFS= read -r file; do
309
- [ -n "$file" ] || continue
310
- stored=$(git -c core.quotePath=false ls-files --error-unmatch -- "$file" 2>/dev/null) || { printf 'stale: evidence not tracked: %s' "$file"; return 1; }
311
- canonical="$canonical$stored
312
- "
313
- done <<< "$listed"
314
- # Paths relative to the project root (which may be below the git toplevel).
315
- changes=$(git -c core.quotePath=false diff --name-only --relative "$candidate" HEAD -- . ':(exclude)NOTES.md') || return 1
316
- offending=$(printf '%s\n' "$changes" | grep -vxF -f <(printf '%s' "$canonical") | grep -v '^$' | head -1)
317
- if [ -n "$offending" ]; then printf 'stale: candidate changed after evaluation: %s' "$offending"; return 1; fi
318
- changes=$(git status --porcelain --untracked-files=all -- . ':(exclude)NOTES.md') || return 1
319
- if [ -n "$changes" ]; then printf 'stale: working tree has changes outside NOTES.md'; return 1; fi
320
- printf 'current (%s)' "$candidate"
321
- }