opencode-swarm 7.184.4 → 7.184.6

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 (71) hide show
  1. package/.opencode/skills/issue-tracer/SKILL.md +3 -3
  2. package/.opencode/skills/issue-tracer/assets/pr-template.md +7 -0
  3. package/.opencode/skills/issue-tracer/references/acceptance-checks.md +30 -6
  4. package/.opencode/skills/issue-tracer/references/critic-gate.md +5 -0
  5. package/.opencode/skills/issue-tracer/references/evidence-artifacts.md +18 -2
  6. package/.opencode/skills/issue-tracer/scripts/repro-check.sh +604 -77
  7. package/.opencode/skills/issue-tracer/scripts/trace-check.sh +486 -60
  8. package/.opencode/skills/issue-tracer/scripts/trace-init.sh +30 -0
  9. package/binaries/win32-arm64/swarm-sandbox-runner.exe +0 -0
  10. package/binaries/win32-x64/swarm-sandbox-runner.exe +0 -0
  11. package/dist/cli/{coder-settlement-633ec0fb.js → coder-settlement-cxe9fn9d.js} +3 -3
  12. package/dist/cli/{config-doctor-bczcm4sg.js → config-doctor-f2semh9v.js} +2 -2
  13. package/dist/cli/{core-f4tzsgxp.js → core-xf46e82t.js} +1 -1
  14. package/dist/cli/{curator-drift-1x67b2vg.js → curator-drift-8ez4czhj.js} +5 -5
  15. package/dist/cli/{curator-llm-factory-vs1hgm6e.js → curator-llm-factory-smd5741x.js} +22 -22
  16. package/dist/cli/{curator-9mzgd9dd.js → curator-n2gvb7rc.js} +22 -22
  17. package/dist/cli/{evidence-summary-service-fj2qa9eh.js → evidence-summary-service-e2gt8tnz.js} +8 -8
  18. package/dist/cli/{gate-evidence-cvkr2ybm.js → gate-evidence-9zpdk87c.js} +2 -2
  19. package/dist/cli/{guardrail-explain-64q268m0.js → guardrail-explain-e9kjxg97.js} +23 -23
  20. package/dist/cli/{guardrail-log-gp01x3n1.js → guardrail-log-e4ysdxag.js} +3 -3
  21. package/dist/cli/{guardrail-reset-z0t943fx.js → guardrail-reset-1fxdaqwf.js} +22 -22
  22. package/dist/cli/{hive-promoter-vjb7y55n.js → hive-promoter-y81jkaj5.js} +22 -22
  23. package/dist/cli/{index-11z82cjy.js → index-3w3cssas.js} +3 -3
  24. package/dist/cli/{index-a86t7pa9.js → index-47wqppx9.js} +22 -22
  25. package/dist/cli/{index-z3xbmp3x.js → index-4b8997qs.js} +3 -3
  26. package/dist/cli/{index-mybjc24z.js → index-5j6g6kqj.js} +2 -2
  27. package/dist/cli/{index-2xm8hp34.js → index-5p5tpj50.js} +2 -2
  28. package/dist/cli/{index-vww242dg.js → index-7136wpb2.js} +1 -1
  29. package/dist/cli/{index-egd6f37q.js → index-77g30h6x.js} +1 -1
  30. package/dist/cli/{index-hrza3045.js → index-8x0vtc05.js} +1 -1
  31. package/dist/cli/{index-r93jbg90.js → index-9vbsarg2.js} +2 -2
  32. package/dist/cli/{index-90byps3b.js → index-b5xcv0ec.js} +3 -3
  33. package/dist/cli/{index-5qhkd1fc.js → index-d4rhnvtf.js} +1 -1
  34. package/dist/cli/{index-5qdhf317.js → index-dtgj8d9k.js} +1 -1
  35. package/dist/cli/{index-88vt0941.js → index-ejk27p7n.js} +24 -24
  36. package/dist/cli/{index-yjkgrerq.js → index-g1aa3sdm.js} +1 -1
  37. package/dist/cli/{index-ak936pwb.js → index-gekvpzaf.js} +1 -1
  38. package/dist/cli/{index-jqqhq9h6.js → index-h2vpqaf1.js} +1 -1
  39. package/dist/cli/{index-8cd5abna.js → index-jjdrhr7z.js} +1 -1
  40. package/dist/cli/{index-hphprp03.js → index-k1p87csx.js} +1 -1
  41. package/dist/cli/{index-9a2vvwk2.js → index-kkj76rky.js} +1 -1
  42. package/dist/cli/{index-gbshh8v2.js → index-kx3hfpb8.js} +5 -5
  43. package/dist/cli/{index-ye8t9qq3.js → index-m62eysrr.js} +57 -57
  44. package/dist/cli/{index-1d0p1v2q.js → index-p45t3c66.js} +1 -1
  45. package/dist/cli/{index-w06xcw6v.js → index-qksf7d8b.js} +3 -3
  46. package/dist/cli/{index-kaknnfaw.js → index-skw3bgg1.js} +1 -1
  47. package/dist/cli/{index-raqa5vnb.js → index-taw9b4v5.js} +1 -1
  48. package/dist/cli/{index-ae2kzm55.js → index-y1xtkdys.js} +4 -4
  49. package/dist/cli/{index-q7vtghff.js → index-zm08qe68.js} +5 -5
  50. package/dist/cli/index.d.ts +36 -7
  51. package/dist/cli/index.js +336 -76
  52. package/dist/cli/{knowledge-escalator-agfq92ps.js → knowledge-escalator-pw06n575.js} +6 -6
  53. package/dist/cli/{knowledge-events-4krnrjdh.js → knowledge-events-f4wgd8n2.js} +4 -4
  54. package/dist/cli/{knowledge-store-bd0380r4.js → knowledge-store-rttx0zb9.js} +1 -1
  55. package/dist/cli/{knowledge-validator-b9wrs08q.js → knowledge-validator-nakzbx9d.js} +2 -2
  56. package/dist/cli/{mcp-43m5e2bb.js → mcp-fdgfryxa.js} +1 -1
  57. package/dist/cli/{model-preflight-tb6ne377.js → model-preflight-wmm3tard.js} +22 -22
  58. package/dist/cli/{pending-delegations-0qbxpf68.js → pending-delegations-eswsasts.js} +2 -2
  59. package/dist/cli/{pr-subscriptions-dek1sqh0.js → pr-subscriptions-fvwnd217.js} +1 -1
  60. package/dist/cli/{pr-workflow-gate-md8brvw1.js → pr-workflow-gate-v4e8k3gn.js} +22 -22
  61. package/dist/cli/{scan-cursor-w333f57f.js → scan-cursor-krrzh2x5.js} +2 -2
  62. package/dist/cli/{schema-3r41ckef.js → schema-wv7r4x4q.js} +1 -1
  63. package/dist/cli/{scope-persistence-dthn7rxz.js → scope-persistence-d3jv0v2w.js} +5 -5
  64. package/dist/cli/{server-b0teb7vg.js → server-cxf8qmej.js} +22 -22
  65. package/dist/cli/{skill-generator-474vdbar.js → skill-generator-gbs540wv.js} +7 -7
  66. package/dist/cli/{snapshot-coordination-init-wtydyah2.js → snapshot-coordination-init-p98yacqd.js} +22 -22
  67. package/dist/cli/{speckit-checkoff-27mk0e41.js → speckit-checkoff-1zhq6dsh.js} +3 -3
  68. package/dist/cli/{worktree-collision-ownership-2mtmrdkp.js → worktree-collision-ownership-rm7ybyyc.js} +2 -2
  69. package/dist/cli/{worktree-isolation-kgda5awq.js → worktree-isolation-vz6et0s2.js} +22 -22
  70. package/dist/index.js +1 -1
  71. package/package.json +1 -1
@@ -14,13 +14,148 @@ to_shell_path() {
14
14
 
15
15
  root="$(git rev-parse --show-toplevel 2>/dev/null)" || { echo "trace-check: not inside a git work tree" >&2; exit 2; }
16
16
  root="$(to_shell_path "$root")"
17
+ root_real="$(cd "$root" && pwd -P)"
18
+ issue_traces_base="$root_real/.agents/issue-traces"
17
19
  script_dir="$(cd "$(dirname "$0")" && pwd -P)"
18
20
  failed=0
19
21
  legacy=0
22
+ trace_root_real=""
20
23
 
21
24
  usage() { echo "usage: trace-check.sh {tree-id|handshake|phase <phase> --slug <slug> [--trace-dir <dir>]|merge --slug <slug>}" >&2; exit 2; }
22
25
  valid_slug() { case "$1" in ''|*[!a-z0-9-]*) return 1;; *) return 0;; esac; }
23
26
  trim() { printf '%s' "$1" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//'; }
27
+ has_bad_control() {
28
+ # Shell variables can carry newlines, but grep treats them as record
29
+ # separators; reject those explicitly before scanning the remaining bytes.
30
+ case "$1" in *$'\t'*|*$'\n'*|*$'\r'*) return 0;; esac
31
+ local matches
32
+ # grep -c drains stdin. A terminal grep -q can make printf receive SIGPIPE
33
+ # under inherited pipefail, turning a real control byte into a false miss.
34
+ matches="$(LC_ALL=C printf '%s' "$1" | LC_ALL=C grep -c '[[:cntrl:]]' || true)"
35
+ [ "${matches:-0}" -gt 0 ]
36
+ }
37
+
38
+ # Markdown trace artifacts are commonly authored on Windows. Keep every
39
+ # line-oriented parser below independent of the file's record separator while
40
+ # preserving embedded control bytes for the explicit validation paths.
41
+ normalize_terminal_cr() { sed 's/\r$//' "$1"; }
42
+
43
+ # Validate the caller-selected trace directory before opening state.md. The
44
+ # lexical prefix check rejects absolute escapes and dot components before any
45
+ # canonicalization (which would otherwise turn an escaped path back into an
46
+ # apparently safe one). The deepest existing ancestor and each existing
47
+ # component are then checked for symlinks/junctions so a path that looks inside
48
+ # the root cannot redirect reads outside it.
49
+ validate_trace_dir() {
50
+ local candidate="$1" check parent resolved canonical_candidate
51
+ has_bad_control "$candidate" && { echo "trace-check: --trace-dir cannot contain control bytes" >&2; exit 2; }
52
+ candidate="$(to_shell_path "$candidate")"
53
+ has_bad_control "$candidate" && { echo "trace-check: --trace-dir cannot contain control bytes" >&2; exit 2; }
54
+ case "$candidate/" in
55
+ */../*|*/./*|*\\*) echo "trace-check: --trace-dir cannot contain . or .. components or backslashes" >&2; exit 2;;
56
+ esac
57
+ # Inspect the caller's spelling before the alias fallback canonicalizes it.
58
+ # Otherwise a symlink/junction that resolves back inside the trace root would
59
+ # disappear from the later component walk and be accepted as a safe path.
60
+ check="$candidate"
61
+ while :; do
62
+ if [ -L "$check" ]; then
63
+ echo "trace-check: refusing symlinked trace component: $check" >&2
64
+ exit 2
65
+ fi
66
+ [ "$check" = "/" ] && break
67
+ parent="$(dirname "$check")"
68
+ [ "$parent" != "$check" ] || break
69
+ check="$parent"
70
+ done
71
+ case "$candidate/" in
72
+ "$root_real/.agents/issue-traces/"*) ;;
73
+ *)
74
+ # Windows may preserve an 8.3 alias (for example RUNNER~1) in the
75
+ # caller's absolute path while Git resolves the repository through its
76
+ # long spelling. Resolve an existing explicit directory before rejecting
77
+ # it, while retaining the lexical traversal/backslash rejection above.
78
+ canonical_candidate="$(cd "$candidate" 2>/dev/null && pwd -P)" || {
79
+ echo "trace-check: --trace-dir must be inside .agents/issue-traces" >&2
80
+ exit 2
81
+ }
82
+ case "$canonical_candidate/" in
83
+ "$root_real/.agents/issue-traces/"*) candidate="$canonical_candidate" ;;
84
+ *) echo "trace-check: --trace-dir must be inside .agents/issue-traces" >&2; exit 2;;
85
+ esac
86
+ ;;
87
+ esac
88
+
89
+ check="$candidate"
90
+ while [ -L "$check" ]; do
91
+ echo "trace-check: refusing symlinked trace path: $candidate" >&2
92
+ exit 2
93
+ done
94
+ while [ ! -e "$check" ]; do
95
+ parent="$(dirname "$check")"
96
+ [ "$parent" != "$check" ] || break
97
+ check="$parent"
98
+ [ -L "$check" ] || continue
99
+ echo "trace-check: refusing symlinked trace ancestor: $check" >&2
100
+ exit 2
101
+ done
102
+ [ -e "$check" ] || { echo "trace-check: could not resolve trace path: $candidate" >&2; exit 2; }
103
+ resolved="$(cd "$check" 2>/dev/null && pwd -P)" || { echo "trace-check: could not resolve trace path: $candidate" >&2; exit 2; }
104
+ case "$resolved/" in
105
+ "$root_real/"*) ;;
106
+ *) echo "trace-check: --trace-dir resolves outside the project root" >&2; exit 2;;
107
+ esac
108
+
109
+ check="$candidate"
110
+ while [ "$check" != "$root_real" ] && [ "$check" != "/" ]; do
111
+ if [ -L "$check" ]; then
112
+ echo "trace-check: refusing symlinked trace component: $check" >&2
113
+ exit 2
114
+ fi
115
+ check="$(dirname "$check")"
116
+ done
117
+ [ "$check" = "$root_real" ] || { echo "trace-check: --trace-dir is not rooted at the project" >&2; exit 2; }
118
+ }
119
+
120
+ # Validate a path below the already-canonical trace root immediately before it
121
+ # is read. `[ -f ]` follows symlinks, so it cannot be used as the first check:
122
+ # a leaf or an intermediate directory could redirect an otherwise in-root
123
+ # artifact to an arbitrary outside file. The ancestor walk catches both POSIX
124
+ # symlinks and Windows junctions as reported by MSYS `test -L`; the canonical
125
+ # parent check is defense in depth for filesystem races and unusual link forms.
126
+ trace_path_safe() {
127
+ local path="$1" kind="${2:-file}" ancestor parent resolved
128
+ [ -n "$trace_root_real" ] || { echo "trace-check: trace root is not initialized" >&2; exit 2; }
129
+ case "$path/" in
130
+ "$trace_root_real/"*) ;;
131
+ *) echo "trace-check: refusing path outside canonical trace root: $path" >&2; exit 2;;
132
+ esac
133
+ case "$path/" in
134
+ */../*|*/./*|*\\*) echo "trace-check: refusing ambiguous trace path: $path" >&2; exit 2;;
135
+ esac
136
+ ancestor="$path"
137
+ while [ "$ancestor" != "$trace_root_real" ] && [ "$ancestor" != "/" ]; do
138
+ if [ -L "$ancestor" ]; then
139
+ echo "trace-check: refusing symlinked trace component: $ancestor" >&2
140
+ exit 2
141
+ fi
142
+ ancestor="$(dirname "$ancestor")"
143
+ done
144
+ [ "$ancestor" = "$trace_root_real" ] || { echo "trace-check: trace path is not rooted at the canonical trace directory: $path" >&2; exit 2; }
145
+ [ "$path" = "$trace_root_real" ] && { [ "$kind" = dir ] && [ -d "$path" ]; return $?; }
146
+ parent="$(dirname "$path")"
147
+ [ -d "$parent" ] || return 1
148
+ resolved="$(cd "$parent" 2>/dev/null && pwd -P)" || { echo "trace-check: could not resolve trace artifact parent: $path" >&2; exit 2; }
149
+ case "$resolved/" in
150
+ "$trace_root_real/"*) ;;
151
+ *) echo "trace-check: trace artifact parent resolves outside canonical trace root: $path" >&2; exit 2;;
152
+ esac
153
+ case "$kind" in
154
+ file) [ -f "$path" ] || return 1;;
155
+ dir) [ -d "$path" ] || return 1;;
156
+ *) echo "trace-check: internal invalid trace path kind: $kind" >&2; exit 2;;
157
+ esac
158
+ }
24
159
 
25
160
  tree_id() {
26
161
  # Trace artifacts under .agents/issue-traces/ must never affect this
@@ -46,14 +181,14 @@ tree_id() {
46
181
 
47
182
  handshake() {
48
183
  local version candidate value shim verdict worst="MATCH"
49
- version="$(awk '/^metadata:/{in_metadata=1; next} in_metadata && /^ version:/{sub(/^ version:[[:space:]]*/, ""); print; exit}' "$root/.opencode/skills/issue-tracer/SKILL.md" 2>/dev/null || true)"
184
+ version="$(awk '{ sub(/\r$/, "", $0) } /^metadata:/{in_metadata=1; next} in_metadata && /^ version:/{sub(/^ version:[[:space:]]*/, ""); print; exit}' "$root/.opencode/skills/issue-tracer/SKILL.md" 2>/dev/null || true)"
50
185
  [ -n "$version" ] || version="unknown"
51
186
  for candidate in "${HOME:-}/.claude/skills/issue-tracer/SKILL.md" "${HOME:-}/.codex/skills/issue-tracer/SKILL.md" "${HOME:-}/.agents/skills/issue-tracer/SKILL.md" "${HOME:-}/.zcode/skills/issue-tracer/SKILL.md"; do
52
187
  if [ ! -f "$candidate" ]; then
53
188
  verdict="ABSENT"
54
189
  else
55
- value="$(grep -m1 '^ version:' "$candidate" 2>/dev/null | sed 's/^ version:[[:space:]]*//' || true)"
56
- shim="$(grep -m1 '^shim:' "$candidate" 2>/dev/null | sed 's/^shim:[[:space:]]*//' || true)"
190
+ value="$(normalize_terminal_cr "$candidate" 2>/dev/null | grep -m1 '^ version:' | sed 's/^ version:[[:space:]]*//' || true)"
191
+ shim="$(normalize_terminal_cr "$candidate" 2>/dev/null | grep -m1 '^shim:' | sed 's/^shim:[[:space:]]*//' || true)"
57
192
  if [ "$value" = "$version" ] && [ "$shim" = "true" ]; then verdict="SHIM"
58
193
  elif [ "$value" = "$version" ]; then verdict="MATCH"
59
194
  else verdict="STALE:$candidate"; fi
@@ -72,21 +207,28 @@ rule_ok() { echo "OK $1"; }
72
207
  rule_bad() {
73
208
  if [ "$legacy" -eq 1 ]; then echo "WARN $1: $2"; else echo "FAIL $1: $2"; failed=1; fi
74
209
  }
75
- state_value() { awk -F ': ' -v key="$1" '$1 == key { print substr($0, length(key) + 3); exit }' "$state" 2>/dev/null || true; }
210
+ state_lines() {
211
+ trace_path_safe "$state" file || return 0
212
+ normalize_terminal_cr "$state"
213
+ }
214
+ state_value() {
215
+ trace_path_safe "$state" file || return 0
216
+ state_lines | awk -F ': ' -v key="$1" '$1 == key { print substr($0, length(key) + 3); exit }' 2>/dev/null || true
217
+ }
76
218
  is_hex() { case "$1" in [0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]) return 0;; *) return 1;; esac; }
77
219
 
78
220
  check_headings() {
79
221
  local file="$1"; shift
80
- if [ ! -f "$file" ]; then rule_bad "artifact-$(basename "$file")" "missing"; return; fi
222
+ if ! trace_path_safe "$file" file; then rule_bad "artifact-$(basename "$file")" "missing"; return; fi
81
223
  local heading count
82
224
  for heading in "$@"; do
83
- count="$(grep -Fx "$heading" "$file" 2>/dev/null | wc -l | tr -d ' ')"
225
+ count="$(awk -v want="$heading" '{ sub(/\r$/, "", $0); if ($0 == want) n++ } END { print n + 0 }' "$file" 2>/dev/null)"
84
226
  if [ "$count" -eq 1 ]; then rule_ok "heading-${heading#\#\# }"
85
227
  elif [ "$count" -gt 1 ]; then rule_bad "duplicate-heading-${heading#\#\# }" "in $(basename "$file")"
86
228
  else rule_bad "heading-${heading#\#\# }" "missing in $(basename "$file")"; fi
87
229
  done
88
230
  # Any duplicated level-two heading is invalid even when it is not required.
89
- while IFS= read -r heading; do rule_bad "duplicate-heading-${heading#\#\# }" "in $(basename "$file")"; done < <(grep '^## ' "$file" 2>/dev/null | sort | uniq -d)
231
+ while IFS= read -r heading; do rule_bad "duplicate-heading-${heading#\#\# }" "in $(basename "$file")"; done < <(awk '{ sub(/\r$/, "", $0); if ($0 ~ /^## /) print }' "$file" 2>/dev/null | sort | uniq -d)
90
232
  }
91
233
 
92
234
  # Parse the `## Gates` table row-by-row (split on '|', trim each cell) and
@@ -97,6 +239,7 @@ check_headings() {
97
239
  # "DISAPPROVED"; matching is done cell-by-cell after trimming instead.
98
240
  gate_row_exists() {
99
241
  local gate="$1" verdict_want="$2" commit="$3" treeid="$4" line g v c t
242
+ trace_path_safe "$state" file || return 1
100
243
  while IFS= read -r line || [ -n "$line" ]; do
101
244
  case "$line" in '|'*) ;; *) continue;; esac
102
245
  IFS='|' read -r _ g v c t _ <<EOF
@@ -108,7 +251,7 @@ EOF
108
251
  [ -z "$commit" ] || [ "$c" = "$commit" ] || continue
109
252
  [ -z "$treeid" ] || [ "$t" = "$treeid" ] || continue
110
253
  return 0
111
- done < "$state"
254
+ done < <(state_lines)
112
255
  return 1
113
256
  }
114
257
 
@@ -127,8 +270,9 @@ state_gate() {
127
270
  # APPROVE.
128
271
  artifact_verdict_approved() {
129
272
  local file="$1"
130
- [ -f "$file" ] || return 1
273
+ trace_path_safe "$file" file || return 1
131
274
  awk '
275
+ { sub(/\r$/, "", $0) }
132
276
  /^## Verdict$/ { infield = 1; next }
133
277
  /^## / { infield = 0 }
134
278
  infield {
@@ -158,7 +302,9 @@ artifact_verdict_approved() {
158
302
  # callers can distinguish "0 matches" from "duplicate matches").
159
303
  artifact_identity() {
160
304
  local file="$1" section
305
+ trace_path_safe "$file" file || return 1
161
306
  section="$(awk '
307
+ { sub(/\r$/, "", $0) }
162
308
  /^## Reviewed SHA \/ diff hash$/ { infield = 1; next }
163
309
  /^## / { infield = 0 }
164
310
  infield { print }
@@ -205,7 +351,9 @@ artifact_identity_matches_gate() {
205
351
 
206
352
  recurrence_justification_ok() {
207
353
  local file="$1"
354
+ trace_path_safe "$file" file || return 1
208
355
  awk '
356
+ { sub(/\r$/, "", $0) }
209
357
  /^## Justification$/ { infield = 1; next }
210
358
  /^## / { infield = 0 }
211
359
  infield {
@@ -227,11 +375,12 @@ clean_tree() {
227
375
 
228
376
  phase0() {
229
377
  local keys key expected actual previous=0 line fresh
378
+ trace_path_safe "$state" file || return
230
379
  keys='protocol phase tier classification base-ref base-sha freshness phase0-tree-id checkpoint-tree-id handshake tools merge next-action'
231
380
  for key in $keys; do
232
- line="$(grep -n "^$key: " "$state" 2>/dev/null | cut -d: -f1 | head -n1 || true)"
381
+ line="$(state_lines | grep -n "^$key: " | cut -d: -f1 | head -n1 || true)"
233
382
  if [ -z "$line" ]; then rule_bad "state-$key" "missing"; continue; fi
234
- if [ "$(grep -c "^$key: " "$state" 2>/dev/null || true)" -ne 1 ]; then rule_bad "state-$key" "must appear once"; fi
383
+ if [ "$(state_lines | grep -c "^$key: " || true)" -ne 1 ]; then rule_bad "state-$key" "must appear once"; fi
235
384
  if [ "$line" -le "$previous" ]; then rule_bad "state-order" "$key is out of order"; fi
236
385
  previous="$line"
237
386
  done
@@ -273,67 +422,177 @@ phase0() {
273
422
  [ "$(state_value handshake)" != "unset" ] && [ -n "$(state_value handshake)" ] && rule_ok handshake || rule_bad handshake "must be recorded"
274
423
  }
275
424
 
276
- acceptance_ids() { grep -E '^- \[[ x]\] AC[0-9]+:' "$trace/01-issue-summary.md" 2>/dev/null | sed -E 's/^- \[[ x]\] (AC[0-9]+):.*/\1/'; }
425
+ acceptance_ids() {
426
+ local file="$trace/01-issue-summary.md"
427
+ sed 's/\r$//' "$file" 2>/dev/null | grep -E '^- \[[ x]\] AC[0-9]+:' | sed -E 's/^- \[[ x]\] (AC[0-9]+):.*/\1/'
428
+ }
429
+
430
+ # Return normalized acceptance-table rows as tab-separated cells. The table is
431
+ # intentionally tolerant of LF or CRLF line endings, but never of an embedded
432
+ # control byte: strip only the record-ending CR before validating cells. Shape
433
+ # and control validation happen in this complete pass before phase25 uses AC,
434
+ # check, argv, expect, or notes in rule names/messages.
435
+ acceptance_table_header() {
436
+ awk '
437
+ { sub(/\r$/, "", $0) }
438
+ /^## Acceptance checks$/ { in_table = 1; next }
439
+ /^## / { if (in_table) in_table = 0 }
440
+ in_table && $0 == "| AC | class | check | argv | expect | pre-fix | post-fix | notes |" { found = 1 }
441
+ END { exit !found }
442
+ ' "$1"
443
+ }
444
+ acceptance_table_rows() {
445
+ awk -F '|' '
446
+ { sub(/\r$/, "", $0) }
447
+ /^## Acceptance checks$/ { in_table = 1; next }
448
+ /^## / { if (in_table) in_table = 0 }
449
+ !in_table { next }
450
+ $0 == "| AC | class | check | argv | expect | pre-fix | post-fix | notes |" { header = 1; next }
451
+ /^\|[-|[:space:]]+\|[[:space:]]*$/ { next }
452
+ /^\|[[:space:]]*AC[0-9]+[[:space:]]*\|/ {
453
+ # The leading and trailing delimiters make a valid row exactly ten
454
+ # fields. Do not inspect or interpolate any cell until this is true.
455
+ if (NF != 10) { bad_shape = 1; next }
456
+ for (i = 2; i <= 9; i++) {
457
+ cell = $i
458
+ sub(/^[ \t]+/, "", cell)
459
+ sub(/[ \t]+$/, "", cell)
460
+ if (cell ~ /[[:cntrl:]]/) { bad_control = 1 }
461
+ cells[i] = cell
462
+ }
463
+ print cells[2] "\t" cells[3] "\t" cells[4] "\t" cells[5] "\t" cells[6] "\t" cells[7] "\t" cells[8] "\t" cells[9]
464
+ count += 1
465
+ next
466
+ }
467
+ /^\|/ { bad_shape = 1 }
468
+ END {
469
+ if (!header || bad_shape || bad_control || count == 0) exit 1
470
+ }
471
+ ' "$1"
472
+ }
277
473
  is_already_fixed() { [ "$(state_value classification)" = "ALREADY_FIXED" ]; }
278
474
 
279
475
  phase1() {
280
- local file="$trace/01-issue-summary.md" value
476
+ local file="$trace/01-issue-summary.md" value acceptance_count classification_count
281
477
  check_headings "$file" '## Source' '## Observed Behavior' '## Expected Behavior' '## Acceptance Criteria' '## Classification' '## Related Issues'
282
- acceptance_ids | grep -q . && rule_ok acceptance-criteria || rule_bad acceptance-criteria "missing AC checkbox"
478
+ trace_path_safe "$file" file || return
479
+ acceptance_count="$(acceptance_ids | grep -c . || true)"
480
+ [ "${acceptance_count:-0}" -gt 0 ] && rule_ok acceptance-criteria || rule_bad acceptance-criteria "missing AC checkbox"
283
481
  value="$(state_value classification)"
284
482
  case "$value" in VALID|AMBIGUOUS|ALREADY_FIXED|NOT_A_BUG|FEATURE) ;; *) rule_bad classification "invalid state value"; return;; esac
285
- if grep -A100 '^## Classification$' "$file" 2>/dev/null | grep -Eq 'VALID|AMBIGUOUS|ALREADY_FIXED|NOT_A_BUG|FEATURE' && grep -A100 '^## Classification$' "$file" | grep -q "$value"; then rule_ok classification
483
+ classification_count="$(normalize_terminal_cr "$file" | grep -A100 '^## Classification$' | grep -c "$value" || true)"
484
+ if normalize_terminal_cr "$file" | grep -A100 '^## Classification$' | grep -Eq 'VALID|AMBIGUOUS|ALREADY_FIXED|NOT_A_BUG|FEATURE' && [ "${classification_count:-0}" -gt 0 ]; then rule_ok classification
286
485
  else rule_bad classification "artifact does not match state"; fi
287
486
  }
288
487
 
289
488
  phase2() {
290
- local file="$trace/02-reproduction.md"
489
+ local file="$trace/02-reproduction.md" text_block_count
291
490
  check_headings "$file" '## Commands Tried' '## Reproduction Verdict'
292
- grep -q '^```text$' "$file" 2>/dev/null && rule_ok reproduction-text-block || rule_bad reproduction-text-block "missing"
293
- grep -Eq '^- Exit code: [0-9]+' "$file" 2>/dev/null && rule_ok reproduction-exit-code || rule_bad reproduction-exit-code "missing"
491
+ trace_path_safe "$file" file || return
492
+ text_block_count="$(normalize_terminal_cr "$file" | grep -c '^```text$' || true)"
493
+ [ "${text_block_count:-0}" -gt 0 ] && rule_ok reproduction-text-block || rule_bad reproduction-text-block "missing"
494
+ normalize_terminal_cr "$file" | grep -Eq '^- Exit code: [0-9]+' && rule_ok reproduction-exit-code || rule_bad reproduction-exit-code "missing"
294
495
  if is_already_fixed; then check_headings "$file" '## Fixing Change'; fi
295
496
  }
296
497
 
297
498
  phase25() {
298
- local file="$trace/02-reproduction.md" header ac row found class check pre notes reason checkpoint manifest_path diff_path cells ac_probe
499
+ local file="$trace/02-reproduction.md" header ac row found class check argv expect pre post notes reason checkpoint manifest_path diff_path duplicate_check rows summary_ids
299
500
  if is_already_fixed; then rule_ok obe-subset; return; fi
501
+ check_headings "$file" '## Commands Tried' '## Reproduction Verdict'
502
+ trace_path_safe "$file" file || return
300
503
  header='| AC | class | check | argv | expect | pre-fix | post-fix | notes |'
301
- grep -Fx "$header" "$file" >/dev/null 2>&1 && rule_ok acceptance-table || { rule_bad acceptance-table "missing exact header"; return; }
504
+ acceptance_table_header "$file" && rule_ok acceptance-table || { rule_bad acceptance-table "missing exact header"; return; }
505
+ if ! rows="$(acceptance_table_rows "$file")"; then
506
+ rule_bad acceptance-table "each row must have exactly 10 pipe columns and no control bytes"
507
+ return
508
+ fi
509
+ # Keep Phase 2.5 bound to the same semantic verifier used at Phase 4. The
510
+ # helper is read-only: it validates the manifest and acceptance table, and
511
+ # does not invoke trace-check, so calling it through bash cannot recurse or
512
+ # introduce checkpoint side effects.
513
+ if bash "$script_dir/repro-check.sh" verify-semantics --slug "$slug" --trace-dir "$trace" >/dev/null 2>&1; then
514
+ rule_ok acceptance-manifest-semantics
515
+ else
516
+ rule_bad acceptance-manifest-semantics "acceptance table semantics do not match checkpoint manifest"
517
+ fi
302
518
  while IFS= read -r ac; do
303
- found="$(grep -E "^\\|[[:space:]]*$ac[[:space:]]*\\|" "$file" 2>/dev/null | wc -l | tr -d ' ')"
519
+ found=0
520
+ while IFS=$'\t' read -r row_ac _ _ _ _ _ _ _; do
521
+ [ "$row_ac" = "$ac" ] && found=$((found + 1))
522
+ done <<EOF
523
+ $rows
524
+ EOF
304
525
  [ "$found" -eq 1 ] && rule_ok "acceptance-$ac" || rule_bad "acceptance-$ac" "must appear exactly once"
305
526
  done < <(acceptance_ids)
306
- check="$(grep -E '^\|[[:space:]]*AC[0-9]+[[:space:]]*\|' "$file" 2>/dev/null | awk -F'|' '{gsub(/^[ \t]+|[ \t]+$/, "", $4); print $4}' | sort | uniq -d | head -n1 || true)"
307
- [ -z "$check" ] && rule_ok acceptance-check-ids || rule_bad acceptance-check-ids "duplicate check id $check"
308
- while IFS= read -r row; do
309
- cells="$(printf '%s' "$row" | awk -F'|' '{print NF}')"
310
- if [ "${cells:-0}" -gt 10 ]; then
311
- ac_probe="$(printf '%s' "$row" | awk -F'|' '{gsub(/^[ \t]+|[ \t]+$/, "", $2); print $2}')"
312
- rule_bad "acceptance-table-row-${ac_probe:-unknown}" "row for ${ac_probe:-unknown} has too many columns (literal | in argv?)"
313
- continue
314
- fi
315
- IFS='|' read -r _ ac class check argv expect pre post notes _ <<EOF
316
- $row
527
+ summary_ids="$(acceptance_ids)"
528
+ while IFS=$'\t' read -r row_ac _ _ _ _ _ _ _; do
529
+ [ -n "$row_ac" ] || continue
530
+ found=0
531
+ while IFS= read -r ac; do
532
+ [ "$row_ac" = "$ac" ] && found=1
533
+ done <<EOF
534
+ $summary_ids
317
535
  EOF
318
- ac="$(trim "$ac")"; class="$(trim "$class")"; check="$(trim "$check")"; pre="$(trim "$pre")"; notes="$(trim "$notes")"
536
+ [ "$found" -eq 1 ] || rule_bad "acceptance-$row_ac" "table row is absent from issue summary"
537
+ done <<EOF
538
+ $rows
539
+ EOF
540
+ duplicate_check="$(printf '%s\n' "$rows" | awk -F '\t' '{ if (++seen[$3] > 1 && duplicate == "") duplicate = $3 } END { if (duplicate != "") print duplicate }')"
541
+ check="$duplicate_check"
542
+ [ -z "$check" ] && rule_ok acceptance-check-ids || rule_bad acceptance-check-ids "duplicate check id $check"
543
+ while IFS=$'\t' read -r ac class check argv expect pre post notes; do
544
+ [ -n "$ac" ] || continue
319
545
  case "$class" in
320
- DISCRIMINATING) [ "$pre" = RED ] || rule_bad "pre-fix-$ac" "DISCRIMINATING must be RED"; [ -f "$trace/repro/$check.base.log" ] || rule_bad "base-log-$check" "missing" ;;
321
- PRESERVING) [ "$pre" = GREEN ] || rule_bad "pre-fix-$ac" "PRESERVING must be GREEN"; [ -f "$trace/repro/$check.base.log" ] || rule_bad "base-log-$check" "missing" ;;
322
- NEW-SURFACE) [ "$pre" = ERROR ] || rule_bad "pre-fix-$ac" "NEW-SURFACE must be ERROR"; [ -f "$trace/repro/$check.base.log" ] || rule_bad "base-log-$check" "missing" ;;
546
+ DISCRIMINATING) [ "$pre" = RED ] || rule_bad "pre-fix-$ac" "DISCRIMINATING must be RED"; trace_path_safe "$trace/repro/$check.base.log" file || rule_bad "base-log-$check" "missing or unsafe" ;;
547
+ PRESERVING) [ "$pre" = GREEN ] || rule_bad "pre-fix-$ac" "PRESERVING must be GREEN"; trace_path_safe "$trace/repro/$check.base.log" file || rule_bad "base-log-$check" "missing or unsafe" ;;
548
+ NEW-SURFACE) [ "$pre" = ERROR ] || rule_bad "pre-fix-$ac" "NEW-SURFACE must be ERROR"; trace_path_safe "$trace/repro/$check.base.log" file || rule_bad "base-log-$check" "missing or unsafe" ;;
323
549
  NON-EXECUTABLE) case "$check" in DOCS_ONLY|HOST_ONLY|PRODUCT_DECISION|EXTERNAL_SERVICE_UNAVAILABLE) ;; *) rule_bad "non-executable-$ac" "unknown reason";; esac; [ -n "$notes" ] && [ "$notes" != '-' ] || rule_bad "notes-$ac" "required" ;;
324
550
  *) rule_bad "class-$ac" "invalid" ;;
325
551
  esac
326
- done < <(grep -E '^\|[[:space:]]*AC[0-9]+[[:space:]]*\|' "$file" 2>/dev/null || true)
552
+ done <<EOF
553
+ $rows
554
+ EOF
327
555
  check_headings "$file" '## Red checkpoint'
328
- checkpoint="$(grep '^checkpoint-tree-id: ' "$file" 2>/dev/null | head -n1 | sed 's/^checkpoint-tree-id: //')"
556
+ checkpoint="$(awk '/^checkpoint-tree-id: / { sub(/\r$/, "", $0); sub(/^checkpoint-tree-id: /, ""); print; exit }' "$file" 2>/dev/null)"
329
557
  is_hex "$checkpoint" && [ "$checkpoint" = "$(state_value checkpoint-tree-id)" ] && rule_ok red-checkpoint || rule_bad red-checkpoint "state binding missing or invalid"
330
558
  manifest_path="$trace/repro/checkpoint.manifest"
559
+ trace_path_safe "$trace/repro" dir || { rule_bad checkpoint-manifest "missing or unsafe repro directory"; return; }
331
560
  # Header shape only - repro-check.sh owns full validation (row count, seq
332
561
  # run, field count). The `rows=<N>` suffix is required, matching the fact
333
562
  # that repro-check refuses a header with no count; awk rather than a
334
563
  # `head | grep -q` pipeline so no SIGPIPE can decide the verdict, and the
335
564
  # END guard makes an empty manifest fail instead of vacuously passing.
336
- [ -f "$manifest_path" ] && awk 'NR == 1 { if ($0 ~ /^# issue-tracer checkpoint manifest v1 rows=[0-9]+$/) exit 0; exit 1 } END { if (NR == 0) exit 1 }' "$manifest_path" && rule_ok checkpoint-manifest || { rule_bad checkpoint-manifest "missing or invalid"; return; }
565
+ trace_path_safe "$manifest_path" file && awk 'NR == 1 { sub(/\r$/, "", $0); if ($0 ~ /^# issue-tracer checkpoint manifest v1 rows=[0-9]+$/) exit 0; exit 1 } END { if (NR == 0) exit 1 }' "$manifest_path" && rule_ok checkpoint-manifest || { rule_bad checkpoint-manifest "missing or invalid"; return; }
566
+ # A checkpoint tree has one effective blob per path. Multiple acceptance
567
+ # checks may share a path when they captured identical bytes; those rows can
568
+ # be deduplicated while deriving the tree. Divergent effective blobs for one
569
+ # path are unsafe, however, and must fail before the tree/path comparison can
570
+ # accidentally treat the manifest as a path-only set.
571
+ if awk -F '\t' '
572
+ NR > 1 {
573
+ if ($3 ~ /[[:cntrl:]]/) { bad = 1; next }
574
+ pair = length($3) ":" $3 ":" $6
575
+ latest_path[pair] = $3
576
+ latest_blob[pair] = $4
577
+ }
578
+ END {
579
+ for (pair in latest_path) {
580
+ path = latest_path[pair]
581
+ if (seen[path] && blob[path] != latest_blob[pair]) {
582
+ print "conflicting effective blobs for " path > "/dev/stderr"
583
+ bad = 1
584
+ }
585
+ seen[path] = 1
586
+ blob[path] = latest_blob[pair]
587
+ }
588
+ exit bad
589
+ }
590
+ ' "$manifest_path"; then
591
+ rule_ok manifest-effective-blobs
592
+ else
593
+ rule_bad manifest-effective-blobs "same path has divergent effective blobs"
594
+ return
595
+ fi
337
596
  diff_path="$(git diff-tree -r --name-only "$(state_value phase0-tree-id)" "$checkpoint" 2>/dev/null || true)"
338
597
  while IFS= read -r check; do
339
598
  [ -z "$check" ] && continue
@@ -347,9 +606,11 @@ phase3() {
347
606
  local head tid
348
607
  check_headings "$trace/05-fix-plan.md" '## Selected Fix' '## Candidate Fixes' '## Impact Analysis' '## Anticipated Defect-Class Sweep (Phase 4.2)'
349
608
  check_headings "$trace/06-critic-review.md" '## Reviewed SHA / diff hash' '## Verdict' '## Check replay'
350
- grep -Eq '^## Round [0-9]+$' "$trace/06-critic-review.md" 2>/dev/null && rule_ok heading-round || rule_bad heading-round "missing ## Round N heading in $(basename "$trace/06-critic-review.md")"
609
+ trace_path_safe "$trace/05-fix-plan.md" file || return
610
+ trace_path_safe "$trace/06-critic-review.md" file || return
611
+ awk '{ sub(/\r$/, "", $0); if ($0 ~ /^## Round [0-9]+$/) found=1 } END { exit !found }' "$trace/06-critic-review.md" 2>/dev/null && rule_ok heading-round || rule_bad heading-round "missing ## Round N heading in $(basename "$trace/06-critic-review.md")"
351
612
  artifact_verdict_approved "$trace/06-critic-review.md" && rule_ok critic-verdict || rule_bad critic-verdict "06-critic-review.md Verdict section must be exactly APPROVE"
352
- [ -f "$trace/07-approved-plan.md" ] && rule_ok approved-plan || rule_bad approved-plan "missing"
613
+ trace_path_safe "$trace/07-approved-plan.md" file && rule_ok approved-plan || rule_bad approved-plan "missing or unsafe"
353
614
  # The checkpoint tree may be dirty relative to HEAD at Phase 3 (the fix is
354
615
  # not implemented yet), so the plan-critic gate row is bound to the current
355
616
  # HEAD commit and the current working-tree identity (tree_id), not the
@@ -359,54 +620,188 @@ phase3() {
359
620
  artifact_identity_matches_gate "$trace/06-critic-review.md" plan-critic "$head" "$tid"
360
621
  }
361
622
 
362
- executable_ids() { grep -E '^\|[[:space:]]*AC[0-9]+[[:space:]]*\|' "$trace/02-reproduction.md" 2>/dev/null | awk -F'|' '{gsub(/^[ \t]+|[ \t]+$/, "", $3); gsub(/^[ \t]+|[ \t]+$/, "", $4); if ($3 != "NON-EXECUTABLE") print $4}'; }
623
+ executable_ids() {
624
+ local file="$trace/02-reproduction.md"
625
+ normalize_terminal_cr "$file" | grep -E '^\|[[:space:]]*AC[0-9]+[[:space:]]*\|' | awk -F'|' '{gsub(/^[ \t]+|[ \t]+$/, "", $3); gsub(/^[ \t]+|[ \t]+$/, "", $4); if ($3 != "NON-EXECUTABLE") print $4}'
626
+ }
627
+ manifest_has_check_id() {
628
+ local manifest="$1" want="$2"
629
+ trace_path_safe "$manifest" file || return 1
630
+ awk -F '\t' -v want="$want" '
631
+ NR > 1 { latest[length($3) ":" $3 ":" $6] = $6 }
632
+ END { for (pair in latest) if (latest[pair] == want) found = 1; exit !found }
633
+ ' "$manifest"
634
+ }
635
+
636
+ # Validate one recorded replay block against the acceptance table and the
637
+ # checkpoint manifest. Presence of `### Check C1` alone is not evidence: the
638
+ # block must carry the base/head SHA, exit status, result, verdict, and the two
639
+ # replay logs produced by `repro-check.sh run`. The result pair is checked
640
+ # against the row's class and the post-fix column, so a fabricated narrative
641
+ # cannot turn Phase 4 green without real replay artifacts.
642
+ phase4_check_evidence() {
643
+ local file="$1" id="$2" rows row class expect post block base_line head_line
644
+ local base_sha head_sha manifest_base base_exit head_exit base_result head_result verdict
645
+ rows="$(acceptance_table_rows "$trace/02-reproduction.md")" || {
646
+ rule_bad acceptance-table "missing or malformed acceptance table"
647
+ return
648
+ }
649
+ row="$(printf '%s\n' "$rows" | awk -F '\t' -v want="$id" '$3 == want { print; exit }')"
650
+ [ -n "$row" ] || { rule_bad "check-evidence-$id" "missing acceptance row"; return; }
651
+ IFS=$'\t' read -r _ class _ _ expect _ post _ <<EOF
652
+ $row
653
+ EOF
654
+ [ "$post" = GREEN ] || rule_bad "post-fix-$id" "acceptance row must record post-fix GREEN"
655
+
656
+ block="$(normalize_terminal_cr "$file" | awk -v marker="### Check $id" '
657
+ ($0 == marker || index($0, marker " (") == 1) { found += 1; in_block = 1; next }
658
+ in_block && /^### Check / { exit }
659
+ in_block { print }
660
+ END { if (found != 1) exit 1 }
661
+ ')" || {
662
+ rule_bad "check-evidence-$id" "must contain exactly one complete replay block"
663
+ return
664
+ }
665
+ base_line="$(printf '%s\n' "$block" | awk '/^- base: / { print; exit }')"
666
+ head_line="$(printf '%s\n' "$block" | awk '/^- head: / { print; exit }')"
667
+ verdict="$(printf '%s\n' "$block" | awk '/^- verdict: / { sub(/^- verdict: /, ""); print; exit }')"
668
+ if ! printf '%s\n' "$base_line" | grep -Eq "^- base: [0-9a-f]{40} exit=[0-9]+ result=(RED|GREEN|ERROR|FAIL|TIMEOUT|VACUOUS) log=repro/${id}\.base\.log$"; then
669
+ rule_bad "base-evidence-$id" "missing bound base SHA, exit, result, or replay log"
670
+ return
671
+ fi
672
+ if ! printf '%s\n' "$head_line" | grep -Eq "^- head: [0-9a-f]{40} exit=[0-9]+ result=(RED|GREEN|ERROR|FAIL|TIMEOUT|VACUOUS) log=repro/${id}\.head\.log$"; then
673
+ rule_bad "head-evidence-$id" "missing bound head SHA, exit, result, or replay log"
674
+ return
675
+ fi
676
+ [ "$verdict" = PASS ] || { rule_bad "verdict-$id" "replay verdict must be PASS"; return; }
677
+
678
+ base_sha="$(printf '%s\n' "$base_line" | sed -E 's/^- base: ([0-9a-f]{40}).*/\1/')"
679
+ head_sha="$(printf '%s\n' "$head_line" | sed -E 's/^- head: ([0-9a-f]{40}).*/\1/')"
680
+ base_exit="$(printf '%s\n' "$base_line" | sed -E 's/.* exit=([0-9]+) result=.*/\1/')"
681
+ head_exit="$(printf '%s\n' "$head_line" | sed -E 's/.* exit=([0-9]+) result=.*/\1/')"
682
+ base_result="$(printf '%s\n' "$base_line" | sed -E 's/.* result=([^ ]+) log=.*/\1/')"
683
+ head_result="$(printf '%s\n' "$head_line" | sed -E 's/.* result=([^ ]+) log=.*/\1/')"
684
+ # A check can cover multiple paths, and an AMEND row supersedes the earlier
685
+ # row for its exact (path, check-id) pair. Resolve the effective pair rows
686
+ # first, then select the highest sequence so an amended checkpoint's base
687
+ # SHA—not the historical first row—binds the replay evidence.
688
+ manifest_base="$(awk -F '\t' -v want="$id" '
689
+ NR > 1 && $6 == want {
690
+ pair = length($3) ":" $3 ":" $6
691
+ if (!(pair in latest_seq) || ($1 + 0) > latest_seq[pair]) {
692
+ latest_seq[pair] = $1 + 0
693
+ latest_base[pair] = $9
694
+ }
695
+ }
696
+ END {
697
+ for (pair in latest_seq) {
698
+ if (!found || latest_seq[pair] > max_seq) {
699
+ found = 1
700
+ max_seq = latest_seq[pair]
701
+ selected = latest_base[pair]
702
+ }
703
+ }
704
+ if (found) print selected
705
+ }
706
+ ' "$manifest")"
707
+ [ "$base_sha" = "$manifest_base" ] || rule_bad "base-identity-$id" "base SHA does not match checkpoint manifest"
708
+ [ "$head_sha" = "$(git rev-parse HEAD)" ] || rule_bad "head-identity-$id" "head SHA does not match current HEAD"
709
+ case "$class" in
710
+ DISCRIMINATING) expected_base=RED; expected_head=GREEN; ;;
711
+ PRESERVING) expected_base=GREEN; expected_head=GREEN; ;;
712
+ NEW-SURFACE) expected_base=ERROR; expected_head=GREEN; ;;
713
+ *) rule_bad "class-$id" "invalid executable class"; return ;;
714
+ esac
715
+ [ "$base_result" = "$expected_base" ] || rule_bad "base-result-$id" "expected $expected_base, recorded $base_result"
716
+ [ "$head_result" = "$expected_head" ] || rule_bad "head-result-$id" "expected $expected_head, recorded $head_result"
717
+ [ "$head_exit" -eq 0 ] || rule_bad "head-exit-$id" "post-fix replay must exit 0"
718
+ case "$class" in
719
+ DISCRIMINATING|NEW-SURFACE)
720
+ [ "$base_exit" -ne 0 ] || rule_bad "base-exit-$id" "pre-fix replay must be nonzero"
721
+ if ! trace_path_safe "$trace/repro/$id.base.log" file || ! grep -Eq -- "$expect" "$trace/repro/$id.base.log"; then
722
+ rule_bad "base-log-$id" "pre-fix replay log is missing or does not match expect"
723
+ fi
724
+ ;;
725
+ PRESERVING) [ "$base_exit" -eq 0 ] || rule_bad "base-exit-$id" "preserving replay must be green at base" ;;
726
+ esac
727
+ trace_path_safe "$trace/repro/$id.base.log" file || rule_bad "base-log-$id" "missing or unsafe replay log"
728
+ trace_path_safe "$trace/repro/$id.head.log" file || rule_bad "head-log-$id" "missing or unsafe replay log"
729
+ rule_ok "check-evidence-$id"
730
+ }
363
731
  phase4() {
364
- local file="$trace/08-test-results.md" id
732
+ local file="$trace/08-test-results.md" id manifest check_block_count deferred_clean_count rows row class
365
733
  check_headings "$file" '## Regression Test' '## Acceptance check results' '## Quality Checks' '## Deferred-Work Scan' '## Verification Reasoning' '## Checkpoint verification'
366
- while IFS= read -r id; do [ -z "$id" ] || { grep -Fx "### Check $id" "$file" >/dev/null 2>&1 && rule_ok "check-block-$id" || rule_bad "check-block-$id" "missing"; }; done < <(executable_ids)
367
- if "$script_dir/repro-check.sh" verify-checkpoint --slug "$slug" --trace-dir "$trace" >/dev/null 2>&1; then rule_ok checkpoint-verification; else rule_bad checkpoint-verification "verify-checkpoint failed"; fi
368
- grep -A100 '^## Deferred-Work Scan$' "$file" 2>/dev/null | grep -q '^scan-deferred: clean' && rule_ok deferred-work-scan || rule_bad deferred-work-scan "clean result missing"
734
+ manifest="$trace/repro/checkpoint.manifest"
735
+ trace_path_safe "$trace/02-reproduction.md" file || { rule_bad acceptance-source "missing or unsafe reproduction artifact"; return; }
736
+ trace_path_safe "$trace/repro" dir || { rule_bad recurrence-manifest "missing or unsafe repro directory"; return; }
737
+ trace_path_safe "$manifest" file || { rule_bad recurrence-manifest "missing or unsafe checkpoint manifest"; return; }
738
+ rows="$(acceptance_table_rows "$trace/02-reproduction.md")" || { rule_bad acceptance-table "missing or malformed acceptance table"; return; }
739
+ while IFS= read -r id; do
740
+ [ -z "$id" ] || {
741
+ check_block_count="$(normalize_terminal_cr "$file" | awk -v marker="### Check $id" '$0 == marker || index($0, marker " (") == 1 { count++ } END { print count + 0 }')"
742
+ [ "${check_block_count:-0}" -eq 1 ] && rule_ok "check-block-$id" || rule_bad "check-block-$id" "must appear exactly once"
743
+ [ "${check_block_count:-0}" -eq 1 ] && phase4_check_evidence "$file" "$id"
744
+ }
745
+ done < <(executable_ids)
746
+ while IFS= read -r id; do
747
+ [ -z "$id" ] || { manifest_has_check_id "$manifest" "$id" && rule_ok "manifest-check-$id" || rule_bad "manifest-check-$id" "executable acceptance check is missing from the effective manifest"; }
748
+ done < <(executable_ids)
749
+ if bash "$script_dir/repro-check.sh" verify-semantics --slug "$slug" --trace-dir "$trace" >/dev/null 2>&1; then
750
+ rule_ok acceptance-manifest-semantics
751
+ else
752
+ rule_bad acceptance-manifest-semantics "acceptance table semantics do not match checkpoint manifest"
753
+ fi
754
+ if bash "$script_dir/repro-check.sh" verify-checkpoint --slug "$slug" --trace-dir "$trace" >/dev/null 2>&1; then rule_ok checkpoint-verification; else rule_bad checkpoint-verification "verify-checkpoint failed"; fi
755
+ deferred_clean_count="$(normalize_terminal_cr "$file" | grep -A100 '^## Deferred-Work Scan$' | grep -c '^scan-deferred: clean' || true)"
756
+ [ "${deferred_clean_count:-0}" -gt 0 ] && rule_ok deferred-work-scan || rule_bad deferred-work-scan "clean result missing"
369
757
  }
370
758
 
371
759
  phase42() {
372
760
  local file="$trace/08a-recurrence-sweep.md" hits rows
373
- [ -f "$file" ] || { rule_bad recurrence-sweep "missing"; return; }
374
- if grep -Eq '^no-defect-class: true$' "$file" 2>/dev/null; then
761
+ trace_path_safe "$file" file || { rule_bad recurrence-sweep "missing"; return; }
762
+ if normalize_terminal_cr "$file" | grep -Eq '^no-defect-class: true$'; then
375
763
  if recurrence_justification_ok "$file"; then rule_ok recurrence-sweep; else rule_bad recurrence-sweep "fast-path Justification must have non-placeholder text"; fi
376
764
  return
377
765
  fi
378
766
  check_headings "$file" '## Defect Class' '## Predicates and Results' '## Dispositions' '## Guardrail'
379
- hits="$(grep -E '^- Predicate.*hits: [0-9]+' "$file" 2>/dev/null | sed -E 's/.*hits: ([0-9]+).*/\1/' | awk '{s += $1} END {print s + 0}')"
380
- grep -Eq '^- Predicate.*hits: [0-9]+' "$file" 2>/dev/null && rule_ok recurrence-predicates || rule_bad recurrence-predicates "missing hit counts"
381
- rows="$(awk '/^## Dispositions$/{in_table=1; next} /^## /{in_table=0} in_table && /^\|/ && $0 !~ /^\|[ -]*\|/ {n++} END {print n-1}' "$file")"
767
+ hits="$(normalize_terminal_cr "$file" | grep -E '^- Predicate.*hits: [0-9]+' | sed -E 's/.*hits: ([0-9]+).*/\1/' | awk '{s += $1} END {print s + 0}')"
768
+ normalize_terminal_cr "$file" | grep -Eq '^- Predicate.*hits: [0-9]+' && rule_ok recurrence-predicates || rule_bad recurrence-predicates "missing hit counts"
769
+ rows="$(normalize_terminal_cr "$file" | awk '/^## Dispositions$/{in_table=1; next} /^## /{in_table=0} in_table && /^\|/ && $0 !~ /^\|[ -]*\|/ {n++} END {print n-1}')"
382
770
  [ "$hits" -eq "$rows" ] && rule_ok recurrence-dispositions || rule_bad recurrence-dispositions "rows ($rows) do not equal hits ($hits)"
383
- grep -A100 '^## Guardrail$' "$file" 2>/dev/null | tr '\n' ' ' | grep -Eq '### Check .*RED.*GREEN' && rule_ok recurrence-guardrail || rule_bad recurrence-guardrail "missing RED then GREEN check"
771
+ normalize_terminal_cr "$file" | grep -A100 '^## Guardrail$' | tr '\n' ' ' | grep -Eq '### Check .*RED.*GREEN' && rule_ok recurrence-guardrail || rule_bad recurrence-guardrail "missing RED then GREEN check"
384
772
  }
385
773
 
386
774
  phase45() {
387
775
  clean_tree
388
776
  local file="$trace/08b-implementation-review.md" head tid
389
777
  check_headings "$file" '## Reviewed SHA / diff hash' '## Verdict' '## Independently re-run' '## Check integrity' '## Deferred / Scoped-Out / Unwired'
778
+ trace_path_safe "$file" file || return
390
779
  artifact_verdict_approved "$file" && rule_ok artifact-verdict-implementation-review || rule_bad artifact-verdict-implementation-review "must contain APPROVE under ## Verdict"
391
780
  head="$(git rev-parse HEAD)"; tid="$(tree_id)"
392
781
  state_gate implementation-review APPROVE "$head" "$tid"
393
782
  artifact_identity_matches_gate "$file" implementation-review "$head" "$tid"
394
783
  }
395
784
  phase46() {
396
- local ac file="$trace/09-final-critic.md" head tid
785
+ local ac file="$trace/09-final-critic.md" head tid evidence_count
397
786
  clean_tree
398
787
  check_headings "$file" '## Reviewed SHA / diff hash' '## Verdict' '## Review Freshness' '## Deferred / Scoped-Out / Unwired' '## Acceptance criteria evidence'
788
+ trace_path_safe "$file" file || return
789
+ trace_path_safe "$trace/01-issue-summary.md" file || { rule_bad acceptance-source "missing or unsafe issue summary"; return; }
399
790
  artifact_verdict_approved "$file" && rule_ok artifact-verdict-final-critic || rule_bad artifact-verdict-final-critic "must contain APPROVE under ## Verdict"
400
791
  head="$(git rev-parse HEAD)"; tid="$(tree_id)"
401
792
  state_gate final-critic APPROVE "$head" "$tid"
402
793
  artifact_identity_matches_gate "$file" final-critic "$head" "$tid"
403
- while IFS= read -r ac; do grep -A100 '^## Acceptance criteria evidence$' "$file" 2>/dev/null | grep -q "$ac" && rule_ok "final-$ac" || rule_bad "final-$ac" "missing evidence"; done < <(acceptance_ids)
794
+ while IFS= read -r ac; do
795
+ evidence_count="$(normalize_terminal_cr "$file" | grep -A100 '^## Acceptance criteria evidence$' | grep -c "$ac" || true)"
796
+ [ "${evidence_count:-0}" -gt 0 ] && rule_ok "final-$ac" || rule_bad "final-$ac" "missing evidence"
797
+ done < <(acceptance_ids)
404
798
  }
405
799
  phase5() {
406
800
  if is_already_fixed; then rule_ok obe-subset; return; fi
407
801
  local file="$trace/10-pr-body.md" merge_value pr_head_line pr_head_sha
408
802
  check_headings "$file" '## Acceptance Criteria -> Evidence' '## Waivers (or none)'
409
- pr_head_line="$(grep -E '^PR head: [0-9a-f]{40}$' "$file" 2>/dev/null | head -n1 || true)"
803
+ trace_path_safe "$file" file || return
804
+ pr_head_line="$(awk '{ sub(/\r$/, "", $0); if ($0 ~ /^PR head: [0-9a-f]{40}$/) { print; exit } }' "$file" 2>/dev/null || true)"
410
805
  if [ -z "$pr_head_line" ]; then
411
806
  rule_bad pr-head "missing PR head: <40-hex> line"
412
807
  else
@@ -423,8 +818,9 @@ phase5() {
423
818
  merge_check() {
424
819
  local file="$trace/10b-merge-approval.md" pr final
425
820
  check_headings "$file" '## User approval (verbatim)' '## PR head SHA' '## Final critic reviewed-commit'
426
- pr="$(grep -A3 '^## PR head SHA$' "$file" 2>/dev/null | grep -Eo '[0-9a-f]{40}' | head -n1 || true)"
427
- final="$(grep -A3 '^## Final critic reviewed-commit$' "$file" 2>/dev/null | grep -Eo '[0-9a-f]{40}' | head -n1 || true)"
821
+ trace_path_safe "$file" file || return
822
+ pr="$(normalize_terminal_cr "$file" | grep -A3 '^## PR head SHA$' | grep -Eo '[0-9a-f]{40}' | head -n1 || true)"
823
+ final="$(normalize_terminal_cr "$file" | grep -A3 '^## Final critic reviewed-commit$' | grep -Eo '[0-9a-f]{40}' | head -n1 || true)"
428
824
  is_hex "$pr" && [ "$pr" = "$final" ] && rule_ok merge-sha-binding || rule_bad merge-sha-binding "PR and final critic SHA differ"
429
825
  state_gate merge-approval RECORDED "" ""
430
826
  echo 'NOTE: human-enforced gate; this validator checks presence and binding only'
@@ -444,17 +840,47 @@ while [ "$#" -gt 0 ]; do
444
840
  done
445
841
  valid_slug "$slug" || { echo "trace-check: invalid slug" >&2; exit 2; }
446
842
  [ -n "$trace" ] || trace="$root/.agents/issue-traces/$slug"
843
+ trace="$(to_shell_path "$trace")"
844
+ validate_trace_dir "$trace"
845
+ # Use the same canonical spelling that validate_trace_dir checked for all
846
+ # subsequent artifact reads. Windows may accept an existing 8.3 alias for the
847
+ # explicit directory while pwd -P exposes the long spelling; retaining the
848
+ # alias here would make trace_path_safe compare unlike path strings. Preserve
849
+ # the established exit-1 result for a valid-but-absent default trace directory.
850
+ if [ -d "$trace" ]; then
851
+ trace="$(cd "$trace" 2>/dev/null && pwd -P)" || {
852
+ echo "FAIL state: could not resolve canonical trace root $trace" >&2
853
+ exit 2
854
+ }
855
+ fi
447
856
  state="$trace/state.md"
448
- if [ ! -d "$trace" ] || [ ! -f "$state" ]; then
857
+ if [ ! -d "$trace" ]; then
858
+ echo "FAIL state: missing or unsafe trace directory $trace"
859
+ exit 1
860
+ fi
861
+ trace_root_real="$(cd "$trace" 2>/dev/null && pwd -P)" || {
862
+ echo "FAIL state: could not resolve canonical trace root $trace" >&2
863
+ exit 2
864
+ }
865
+ case "$trace_root_real/" in
866
+ "$root_real/.agents/issue-traces/"*) ;;
867
+ *) echo "FAIL state: canonical trace root escapes .agents/issue-traces" >&2; exit 2;;
868
+ esac
869
+ if ! trace_path_safe "$trace" dir; then
870
+ echo "FAIL state: missing or unsafe trace directory $trace"
871
+ exit 1
872
+ fi
873
+ if ! trace_path_safe "$state" file; then
449
874
  echo "FAIL state: missing $state"
450
875
  exit 1
451
876
  fi
452
- protocol_line="$(grep -c '^protocol: ' "$state" 2>/dev/null || true)"
877
+ protocol_line="$(state_lines | grep -c '^protocol: ' || true)"
453
878
  protocol_value="$(state_value protocol)"
454
879
  if [ "${protocol_line:-0}" -eq 0 ]; then
455
880
  # No protocol line at all: legacy v2 ledger unless a v3-only key is present,
456
881
  # in which case this is a v3 ledger that had its protocol line stripped.
457
- if grep -Eq '^(phase0-tree-id|checkpoint-tree-id|handshake): ' "$state" 2>/dev/null; then
882
+ v3_marker_lines="$(state_lines | grep -Ec '^(phase0-tree-id|checkpoint-tree-id|handshake): ' || true)"
883
+ if [ "${v3_marker_lines:-0}" -gt 0 ]; then
458
884
  echo "FAIL state-protocol: missing (v3 ledger without protocol line)"
459
885
  exit 1
460
886
  fi