opencode-swarm 7.184.5 → 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-m3r1kmzx.js → coder-settlement-cxe9fn9d.js} +3 -3
  12. package/dist/cli/{config-doctor-1qgq2s96.js → config-doctor-f2semh9v.js} +2 -2
  13. package/dist/cli/{core-h7pp6xzs.js → core-xf46e82t.js} +1 -1
  14. package/dist/cli/{curator-drift-epvr1y5j.js → curator-drift-8ez4czhj.js} +5 -5
  15. package/dist/cli/{curator-llm-factory-782jtn0a.js → curator-llm-factory-smd5741x.js} +22 -22
  16. package/dist/cli/{curator-48mky2w8.js → curator-n2gvb7rc.js} +22 -22
  17. package/dist/cli/{evidence-summary-service-akzas6he.js → evidence-summary-service-e2gt8tnz.js} +8 -8
  18. package/dist/cli/{gate-evidence-gmq1th1b.js → gate-evidence-9zpdk87c.js} +2 -2
  19. package/dist/cli/{guardrail-explain-xgahzbts.js → guardrail-explain-e9kjxg97.js} +23 -23
  20. package/dist/cli/{guardrail-log-n2cgvbh1.js → guardrail-log-e4ysdxag.js} +3 -3
  21. package/dist/cli/{guardrail-reset-3br6fs4n.js → guardrail-reset-1fxdaqwf.js} +22 -22
  22. package/dist/cli/{hive-promoter-a1yw6evd.js → hive-promoter-y81jkaj5.js} +22 -22
  23. package/dist/cli/{index-yqcaf6r3.js → index-3w3cssas.js} +3 -3
  24. package/dist/cli/{index-tzejdtyx.js → index-47wqppx9.js} +22 -22
  25. package/dist/cli/{index-wehwbv89.js → index-4b8997qs.js} +3 -3
  26. package/dist/cli/{index-8pfcj7tz.js → index-5j6g6kqj.js} +2 -2
  27. package/dist/cli/{index-cdvb33mn.js → index-5p5tpj50.js} +2 -2
  28. package/dist/cli/{index-10rsjhfy.js → index-7136wpb2.js} +1 -1
  29. package/dist/cli/{index-ezpzd88b.js → index-77g30h6x.js} +1 -1
  30. package/dist/cli/{index-2adz24wr.js → index-8x0vtc05.js} +1 -1
  31. package/dist/cli/{index-tajh3egk.js → index-9vbsarg2.js} +2 -2
  32. package/dist/cli/{index-2zzmthnv.js → index-b5xcv0ec.js} +3 -3
  33. package/dist/cli/{index-qf75z3xz.js → index-d4rhnvtf.js} +1 -1
  34. package/dist/cli/{index-e602am4j.js → index-dtgj8d9k.js} +1 -1
  35. package/dist/cli/{index-h9hvszf7.js → index-ejk27p7n.js} +24 -24
  36. package/dist/cli/{index-3ny2d85s.js → index-g1aa3sdm.js} +1 -1
  37. package/dist/cli/{index-82hy3v8f.js → index-gekvpzaf.js} +1 -1
  38. package/dist/cli/{index-f4z1ez09.js → index-h2vpqaf1.js} +1 -1
  39. package/dist/cli/{index-fnbcd9ve.js → index-jjdrhr7z.js} +1 -1
  40. package/dist/cli/{index-ae2jf510.js → index-k1p87csx.js} +1 -1
  41. package/dist/cli/{index-gcdgtcg5.js → index-kkj76rky.js} +1 -1
  42. package/dist/cli/{index-7ge1gcd3.js → index-kx3hfpb8.js} +5 -5
  43. package/dist/cli/{index-6bn74qs9.js → index-m62eysrr.js} +57 -57
  44. package/dist/cli/{index-zeg8y1fw.js → index-p45t3c66.js} +1 -1
  45. package/dist/cli/{index-xf6j4ypk.js → index-qksf7d8b.js} +3 -3
  46. package/dist/cli/{index-k1cxzdbd.js → index-skw3bgg1.js} +1 -1
  47. package/dist/cli/{index-d1bfyxj5.js → index-taw9b4v5.js} +1 -1
  48. package/dist/cli/{index-1511m10x.js → index-y1xtkdys.js} +4 -4
  49. package/dist/cli/{index-6jqxstq4.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-rdgspj3v.js → knowledge-escalator-pw06n575.js} +6 -6
  53. package/dist/cli/{knowledge-events-zh16e78y.js → knowledge-events-f4wgd8n2.js} +4 -4
  54. package/dist/cli/{knowledge-store-jxke13cv.js → knowledge-store-rttx0zb9.js} +1 -1
  55. package/dist/cli/{knowledge-validator-24myt9fp.js → knowledge-validator-nakzbx9d.js} +2 -2
  56. package/dist/cli/{mcp-a4q99fmb.js → mcp-fdgfryxa.js} +1 -1
  57. package/dist/cli/{model-preflight-qy88p9fc.js → model-preflight-wmm3tard.js} +22 -22
  58. package/dist/cli/{pending-delegations-9vqyq3bj.js → pending-delegations-eswsasts.js} +2 -2
  59. package/dist/cli/{pr-subscriptions-hrefmtay.js → pr-subscriptions-fvwnd217.js} +1 -1
  60. package/dist/cli/{pr-workflow-gate-1jcs4wnp.js → pr-workflow-gate-v4e8k3gn.js} +22 -22
  61. package/dist/cli/{scan-cursor-krn56ew2.js → scan-cursor-krrzh2x5.js} +2 -2
  62. package/dist/cli/{schema-zqankczb.js → schema-wv7r4x4q.js} +1 -1
  63. package/dist/cli/{scope-persistence-5qrjm5d2.js → scope-persistence-d3jv0v2w.js} +5 -5
  64. package/dist/cli/{server-7wydssat.js → server-cxf8qmej.js} +22 -22
  65. package/dist/cli/{skill-generator-br4wctes.js → skill-generator-gbs540wv.js} +7 -7
  66. package/dist/cli/{snapshot-coordination-init-g4sg2fxv.js → snapshot-coordination-init-p98yacqd.js} +22 -22
  67. package/dist/cli/{speckit-checkoff-82s1qy0c.js → speckit-checkoff-1zhq6dsh.js} +3 -3
  68. package/dist/cli/{worktree-collision-ownership-19n9em8b.js → worktree-collision-ownership-rm7ybyyc.js} +2 -2
  69. package/dist/cli/{worktree-isolation-hkd7xtdp.js → worktree-isolation-vz6et0s2.js} +22 -22
  70. package/dist/index.js +1 -1
  71. package/package.json +1 -1
@@ -10,20 +10,85 @@ to_shell_path() {
10
10
  printf '%s\n' "$1"
11
11
  }
12
12
 
13
+ to_native_path() {
14
+ case "$(uname -s 2>/dev/null || true)" in
15
+ MINGW* | MSYS* | CYGWIN*)
16
+ command -v cygpath >/dev/null 2>&1 || return 1
17
+ cygpath -w "$1"
18
+ ;;
19
+ *) printf '%s\n' "$1" ;;
20
+ esac
21
+ }
22
+
13
23
  root="$(git rev-parse --show-toplevel 2>/dev/null)" || { echo "repro-check: not inside a git work tree" >&2; exit 2; }
14
24
  root="$(to_shell_path "$root")"
15
25
  root_real="$(cd "$root" && pwd -P)"
16
26
  script_dir="$(cd "$(dirname "$0")" && pwd -P)"
27
+ trace_root_real=""
17
28
 
18
- usage() { echo "usage: repro-check.sh {run|checkpoint|verify-checkpoint} --slug <slug> ..." >&2; exit 2; }
29
+ usage() { echo "usage: repro-check.sh {run|checkpoint|verify-checkpoint|verify-semantics|anchor|verify-anchor} --slug <slug> ..." >&2; exit 2; }
19
30
  valid_slug() { case "$1" in ''|*[!a-z0-9-]*) return 1;; *) return 0;; esac; }
20
- valid_id() { case "$1" in C[0-9]* ) return 0;; *) return 1;; esac; }
31
+ valid_id() {
32
+ local suffix
33
+ case "$1" in
34
+ C[0-9]*) suffix="${1#C}"; case "$suffix" in ''|*[!0-9]*) return 1;; esac; return 0;;
35
+ *) return 1;;
36
+ esac
37
+ }
21
38
  has_bad_field() { case "$1" in *$'\t'*|*$'\n'*|*$'\r'*) return 0;; *) return 1;; esac; }
39
+ # Unlike a shell option, a regex beginning with `--` is valid input. Keep it
40
+ # as data for grep by using `--` at the option/operand boundary. Reject control
41
+ # bytes before echoing the regex in the result artifact.
42
+ has_bad_control() {
43
+ # Shell variables can carry newlines, but grep treats them as record
44
+ # separators; reject those explicitly before scanning the remaining bytes.
45
+ case "$1" in *$'\t'*|*$'\n'*|*$'\r'*) return 0;; esac
46
+ local matches
47
+ # grep -c drains stdin. A terminal grep -q can make printf receive SIGPIPE
48
+ # under inherited pipefail, turning a real control byte into a false miss.
49
+ matches="$(LC_ALL=C printf '%s' "$1" | LC_ALL=C grep -c '[[:cntrl:]]' || true)"
50
+ [ "${matches:-0}" -gt 0 ]
51
+ }
52
+ # POSIX pathnames may contain tabs/newlines, but trace paths are also rendered
53
+ # in diagnostics. Reject every C0/DEL byte at the checkpoint boundary so an
54
+ # ANSI escape or other control byte cannot become terminal-visible evidence.
55
+ has_bad_path() {
56
+ has_bad_control "$1"
57
+ }
58
+ is_sha1() { printf '%s' "$1" | grep -Eq '^[0-9a-f]{40}$'; }
22
59
  is_inside_root() {
23
60
  case "$1" in /*|[A-Za-z]:*|*\\*) return 1;; esac
24
61
  case "/$1/" in */../*|*/./*) return 1;; esac
25
62
  return 0
26
63
  }
64
+
65
+ # Resolve a repository-relative path without following a symlink or junction in
66
+ # any component. `-f`, `cp`, and `git hash-object` all follow links, so checking
67
+ # only the parent directory is insufficient: a link leaf could copy or freeze
68
+ # bytes from outside the repository and later make verification report a false
69
+ # match. Use the canonical repository root as the walk anchor so a symlinked
70
+ # checkout path does not weaken the component checks.
71
+ repo_path_safe() {
72
+ local rel="$1" current part
73
+ is_inside_root "$rel" || return 1
74
+ current="$root_real"
75
+ while IFS= read -r part; do
76
+ [ -n "$part" ] || continue
77
+ current="$current/$part"
78
+ [ -L "$current" ] && return 1
79
+ done <<EOF
80
+ $(printf '%s' "$rel" | tr '/' '\n')
81
+ EOF
82
+ [ -e "$current" ] || return 1
83
+ return 0
84
+ }
85
+
86
+ repo_file_safe() {
87
+ local rel="$1"
88
+ repo_path_safe "$rel" || return 1
89
+ [ -f "$root_real/$rel" ] || return 1
90
+ return 0
91
+ }
27
92
  issue_traces_base="$root_real/.agents/issue-traces"
28
93
 
29
94
  # Path-only preflight: trace_dir (default or --trace-dir override) must be
@@ -31,6 +96,10 @@ issue_traces_base="$root_real/.agents/issue-traces"
31
96
  # filesystem. Runs before any mkdir/write, so a caller cannot point the
32
97
  # script at an arbitrary path via that flag.
33
98
  validate_trace_dir_prefix() {
99
+ has_bad_path "$trace_dir" && {
100
+ echo "repro-check: --trace-dir cannot contain control bytes" >&2
101
+ exit 2
102
+ }
34
103
  case "$trace_dir/" in
35
104
  "$root"/.agents/issue-traces/*) ;;
36
105
  *)
@@ -45,12 +114,19 @@ validate_trace_dir_prefix() {
45
114
  # run before any mkdir -p, since mkdir -p happily follows an existing
46
115
  # symlinked ancestor before any later check on the final path can catch it.
47
116
  refuse_ancestor_symlink_escape() {
48
- local check="$trace_dir" resolved
49
- while [ ! -e "$check" ]; do check="$(dirname "$check")"; done
50
- if [ -L "$check" ]; then
51
- echo "repro-check: --trace-dir must be inside .agents/issue-traces" >&2
52
- exit 2
53
- fi
117
+ local check="${1:-$trace_dir}" resolved parent
118
+ while :; do
119
+ # `-e` is false for a dangling link. Test `-L` before deciding whether to
120
+ # ascend so a broken link cannot be skipped and later followed by mkdir -p.
121
+ if [ -L "$check" ]; then
122
+ echo "repro-check: --trace-dir must be inside .agents/issue-traces" >&2
123
+ exit 2
124
+ fi
125
+ [ -e "$check" ] && break
126
+ parent="$(dirname "$check")"
127
+ [ "$parent" != "$check" ] || break
128
+ check="$parent"
129
+ done
54
130
  resolved="$(cd "$check" && pwd -P)"
55
131
  case "$resolved/" in
56
132
  "$root_real"/*) ;;
@@ -82,6 +158,52 @@ require_contained() {
82
158
  esac
83
159
  }
84
160
 
161
+ # Validate a trace artifact immediately before reading or hashing it. Shell
162
+ # tests such as `[ -f ]` and `git hash-object` follow symlinks, so walk every
163
+ # component first and require the canonical parent to remain beneath the
164
+ # canonical trace root. This rejects both POSIX symlinks and Windows
165
+ # junctions surfaced by MSYS as `test -L`, including dangling leaf links.
166
+ trace_path_safe() {
167
+ local path="$1" kind="${2:-file}" ancestor parent resolved
168
+ [ -n "$trace_root_real" ] || { echo "repro-check: trace root is not initialized" >&2; exit 2; }
169
+ case "$path/" in
170
+ "$trace_root_real/"*) ;;
171
+ *) echo "repro-check: refusing path outside canonical trace root: $path" >&2; exit 2;;
172
+ esac
173
+ case "$path/" in
174
+ */../*|*/./*) echo "repro-check: refusing ambiguous trace path: $path" >&2; exit 2;;
175
+ esac
176
+ ancestor="$path"
177
+ while [ "$ancestor" != "$trace_root_real" ] && [ "$ancestor" != "/" ]; do
178
+ if [ -L "$ancestor" ]; then
179
+ echo "repro-check: refusing symlinked trace component: $ancestor" >&2
180
+ exit 2
181
+ fi
182
+ ancestor="$(dirname "$ancestor")"
183
+ done
184
+ [ "$ancestor" = "$trace_root_real" ] || { echo "repro-check: trace path is not rooted at canonical trace directory: $path" >&2; exit 2; }
185
+ [ "$path" = "$trace_root_real" ] && { [ "$kind" = dir ] && [ -d "$path" ]; return $?; }
186
+ parent="$(dirname "$path")"
187
+ [ -d "$parent" ] || return 1
188
+ resolved="$(cd "$parent" 2>/dev/null && pwd -P)" || { echo "repro-check: could not resolve trace artifact parent: $path" >&2; exit 2; }
189
+ case "$resolved/" in
190
+ "$trace_root_real/"*) ;;
191
+ *) echo "repro-check: trace artifact parent resolves outside canonical trace root: $path" >&2; exit 2;;
192
+ esac
193
+ case "$kind" in
194
+ file) [ -f "$path" ] || return 1;;
195
+ dir) [ -d "$path" ] || return 1;;
196
+ *) echo "repro-check: internal invalid trace path kind: $kind" >&2; exit 2;;
197
+ esac
198
+ }
199
+
200
+ set_trace_root() {
201
+ require_contained "$trace_dir"
202
+ [ -d "$trace_dir" ] || { echo "repro-check: trace directory is missing: $trace_dir" >&2; exit 2; }
203
+ trace_root_real="$(cd "$trace_dir" 2>/dev/null && pwd -P)" || { echo "repro-check: could not resolve trace directory: $trace_dir" >&2; exit 2; }
204
+ trace_path_safe "$trace_root_real" dir >/dev/null
205
+ }
206
+
85
207
  trace_for() {
86
208
  [ -n "$trace_dir" ] || trace_dir="$root/.agents/issue-traces/$slug"
87
209
  validate_trace_dir_prefix
@@ -106,9 +228,11 @@ manifest_for() { trace_for; printf '%s\n' "$trace_dir/repro/checkpoint.manifest"
106
228
  #
107
229
  # The legacy header with no `rows=` count (`... v1`) is REJECTED rather than
108
230
  # accepted for compatibility: accepting it would itself be a one-line bypass
109
- # (write the old header, then truncate freely). Nothing is lost by failing
110
- # closed - the manifest is an ephemeral, git-excluded, per-issue trace artifact
111
- # seeded fresh by trace-init.sh, so there is no installed base to migrate.
231
+ # (write the old header, then truncate freely). Existing v3 manifests can,
232
+ # however, contain an AMEND row with the historical FORMAT_ONLY reason. That
233
+ # reason is accepted only by verification (and by a later, newly reasoned
234
+ # amendment while recovering such a trace); do_checkpoint never accepts it as
235
+ # a new reason, so FORMAT_ONLY cannot be introduced through this script.
112
236
  #
113
237
  # This is tamper-EVIDENCE, not tamper-proofing: the trace directory is the
114
238
  # agent's own write surface, so a rewriter that renumbers every row AND
@@ -117,8 +241,17 @@ manifest_for() { trace_for; printf '%s\n' "$trace_dir/repro/checkpoint.manifest"
117
241
  # existence or completeness to anything outside it). What it stops is a
118
242
  # partial write or a hand edit that leaves the file internally inconsistent.
119
243
  validate_manifest() {
120
- local file="$1" problem counts
121
- problem="$(awk -F '\t' '
244
+ local file="$1" allow_conflicts="${2:-}" allow_legacy_format_only="${3:-}" problem counts header
245
+ trace_path_safe "$file" file || { echo "repro-check: checkpoint manifest missing or unsafe" >&2; exit 2; }
246
+ # `awk` on some supported shells normalizes CRLF records, so inspect the
247
+ # raw header first. A CRLF manifest is not byte-valid for the checkpoint
248
+ # format and must fail as a malformed header rather than reaching replay and
249
+ # reporting a stale digest.
250
+ header=""
251
+ IFS= read -r header < "$file" || true
252
+ case "$header" in
253
+ *$'\r'*) problem="header" ;;
254
+ *) problem="$(awk -F '\t' -v allow_conflicts="$allow_conflicts" -v allow_legacy_format_only="$allow_legacy_format_only" '
122
255
  NR == 1 {
123
256
  if ($0 !~ /^# issue-tracer checkpoint manifest v1 rows=[0-9]+$/) { bad = "header"; exit }
124
257
  declared = $0
@@ -130,26 +263,51 @@ validate_manifest() {
130
263
  rows += 1
131
264
  if (NF != 10) { bad = "fields " NR; exit }
132
265
  if ($1 "" != rows "") { bad = "seq " NR; exit }
133
- # A path is frozen by its first row; every later row for it must be a
134
- # reasoned AMEND. do_checkpoint refuses a duplicate CHECKPOINT at write
135
- # time, but do_verify is last-writer-wins per path, so without this a
136
- # forged CHECKPOINT row appended by hand (plus a bumped header count)
137
- # would silently re-baseline a frozen blob. Checking it here makes the
138
- # rule hold in both commands.
139
- if (seen[$3] && $2 != "AMEND") { bad = "supersede " NR; exit }
140
- seen[$3] = 1
266
+ if ($3 ~ /[[:cntrl:]]/) { bad = "unsafe-path"; exit }
267
+ if ($2 != "CHECKPOINT" && $2 != "AMEND") { bad = "kind " NR; exit }
268
+ if (($2 == "CHECKPOINT" && $10 != "-") || ($2 == "AMEND" && $10 != "CHECK_WRONG" && $10 != "AC_CHANGED_BY_USER" && !(allow_legacy_format_only == "allow-legacy-format-only" && $10 == "FORMAT_ONLY"))) { bad = "reason " NR; exit }
269
+ # A pair is frozen by its first row; every later row for that exact
270
+ # (path, check-id) pair must be a reasoned AMEND. A path may legitimately
271
+ # carry multiple checks, so path alone is not an identity key here.
272
+ # Length-prefix the path so the composite key remains injective even if
273
+ # a legal path contains the awk SUBSEP byte.
274
+ pair = length($3) ":" $3 ":" $6
275
+ if ($2 == "CHECKPOINT" && seen_pair[pair]) { bad = "duplicate " NR; exit }
276
+ if ($2 == "AMEND" && !seen_pair[pair]) { bad = "orphan " NR; exit }
277
+ seen_pair[pair] = 1
278
+ latest_path[pair] = $3
279
+ latest_blob[pair] = $4
141
280
  }
142
281
  END {
143
282
  if (bad != "") { print bad; exit }
144
283
  if (NR == 0) { print "header"; exit }
284
+ # The effective manifest is one latest row per pair. A checkpoint tree
285
+ # has one blob per path, so identical blobs for multiple checks are
286
+ # safely deduplicable but divergent effective blobs are invalid rather
287
+ # than silently becoming last-writer-wins by path.
288
+ for (pair in latest_path) {
289
+ path = latest_path[pair]
290
+ if (effective_seen[path] && effective_blob[path] != latest_blob[pair] && allow_conflicts != "allow-conflicts") {
291
+ print "conflict " path
292
+ exit
293
+ }
294
+ effective_seen[path] = 1
295
+ effective_blob[path] = latest_blob[pair]
296
+ }
145
297
  if (declared != rows + 0) { print "count " declared " " rows + 0 }
146
298
  }
147
- ' "$file")"
299
+ ' "$file")" ;;
300
+ esac
148
301
  case "$problem" in
149
302
  '') return 0 ;;
150
303
  'fields '*) echo "repro-check: checkpoint manifest line ${problem#fields } does not have 10 tab-separated fields" >&2 ;;
151
304
  'seq '*) echo "repro-check: checkpoint manifest seq is not contiguous (row deleted or reordered) at line ${problem#seq }" >&2 ;;
152
- 'supersede '*) echo "repro-check: checkpoint manifest line ${problem#supersede } re-freezes an already-recorded path without an AMEND reason" >&2 ;;
305
+ 'duplicate '*) echo "repro-check: checkpoint manifest line ${problem#duplicate } duplicates an existing CHECKPOINT pair (re-freezes without an AMEND reason)" >&2 ;;
306
+ 'orphan '*) echo "repro-check: checkpoint manifest line ${problem#orphan } AMENDs an unknown path/check-id pair" >&2 ;;
307
+ 'unsafe-path') echo "repro-check: checkpoint manifest contains a path with control bytes" >&2 ;;
308
+ 'conflict '*) echo "repro-check: checkpoint manifest has conflicting effective blobs for path ${problem#conflict }" >&2 ;;
309
+ 'kind '*) echo "repro-check: checkpoint manifest line ${problem#kind } has an invalid kind (use CHECKPOINT or AMEND)" >&2 ;;
310
+ 'reason '*) echo "repro-check: checkpoint manifest line ${problem#reason } has an invalid reason for its kind" >&2 ;;
153
311
  'count '*)
154
312
  counts="${problem#count }"
155
313
  echo "repro-check: checkpoint manifest header records ${counts%% *} rows, found ${counts#* } (rows deleted or truncated)" >&2
@@ -159,10 +317,13 @@ validate_manifest() {
159
317
  exit 2
160
318
  }
161
319
 
162
- # True when field 3 (the path column) of any data row already names this path.
163
- # A recorded path is frozen: only a reasoned AMEND may supersede it.
164
- manifest_has_path() {
165
- awk -F '\t' -v want="$2" 'NR > 1 && $3 == want { found = 1; exit } END { exit !found }' "$1"
320
+ # True when an exact (path, check-id) pair already appears in the manifest.
321
+ # Multiple checks may share a path, but a pair can only be introduced once and
322
+ # then superseded by an AMEND row.
323
+ manifest_has_pair() {
324
+ trace_path_safe "$1" file || return 1
325
+ awk -F '\t' -v want_path="$2" -v want_check="$3" \
326
+ 'NR > 1 && $3 == want_path && $6 == want_check { found = 1; exit } END { exit !found }' "$1"
166
327
  }
167
328
 
168
329
  # Append one data row and restamp the header's recorded count in a single
@@ -175,13 +336,16 @@ manifest_has_path() {
175
336
  append_manifest_row() {
176
337
  local file="$1" count="$2" row="$3" temp
177
338
  temp="$file.append.tmp"
339
+ trace_path_safe "$(dirname "$file")" dir || { echo "repro-check: manifest directory is missing or unsafe" >&2; exit 2; }
340
+ trace_path_safe "$file" file || true
178
341
  refuse_nonregular_target "$file"
179
- refuse_nonregular_target "$temp"
342
+ open_exclusive_target "$temp" || { echo "repro-check: could not create manifest temp file safely" >&2; exit 2; }
180
343
  {
181
344
  printf '# issue-tracer checkpoint manifest v1 rows=%s\n' "$count"
182
345
  awk 'NR > 1' "$file"
183
346
  printf '%s\n' "$row"
184
- } > "$temp"
347
+ } >&"$exclusive_fd"
348
+ close_exclusive_target
185
349
  mv "$temp" "$file"
186
350
  }
187
351
 
@@ -199,31 +363,88 @@ refuse_nonregular_target() {
199
363
  fi
200
364
  }
201
365
 
366
+ # Open a new regular file and retain a descriptor. `exec {var}>...` would be
367
+ # convenient, but automatic brace descriptor allocation is Bash 4+ syntax and
368
+ # breaks the system Bash 3.2 shipped with macOS. Probe a small reserved range
369
+ # of descriptors instead; callers write through $exclusive_fd and close it
370
+ # before publishing the path with rename/mv. The parent directory is checked
371
+ # by the caller; portable shell code cannot hold an open directory descriptor
372
+ # across all supported Bash/MSYS environments, so the final rename remains
373
+ # the containment boundary.
374
+ open_exclusive_target() {
375
+ local path="$1" fd
376
+ refuse_nonregular_target "$path"
377
+ set -C
378
+ for fd in 9 10 11 12 13 14 15 16 17 18 19; do
379
+ # A successful duplication means the descriptor is already in use; leave
380
+ # it untouched and try the next reserved descriptor.
381
+ if true >&"$fd" 2>/dev/null; then
382
+ continue
383
+ fi
384
+ case "$fd" in
385
+ 9) exec 9>"$path" && exclusive_fd=9 && set +C && return 0 ;;
386
+ 10) exec 10>"$path" && exclusive_fd=10 && set +C && return 0 ;;
387
+ 11) exec 11>"$path" && exclusive_fd=11 && set +C && return 0 ;;
388
+ 12) exec 12>"$path" && exclusive_fd=12 && set +C && return 0 ;;
389
+ 13) exec 13>"$path" && exclusive_fd=13 && set +C && return 0 ;;
390
+ 14) exec 14>"$path" && exclusive_fd=14 && set +C && return 0 ;;
391
+ 15) exec 15>"$path" && exclusive_fd=15 && set +C && return 0 ;;
392
+ 16) exec 16>"$path" && exclusive_fd=16 && set +C && return 0 ;;
393
+ 17) exec 17>"$path" && exclusive_fd=17 && set +C && return 0 ;;
394
+ 18) exec 18>"$path" && exclusive_fd=18 && set +C && return 0 ;;
395
+ 19) exec 19>"$path" && exclusive_fd=19 && set +C && return 0 ;;
396
+ esac
397
+ done
398
+ set +C
399
+ return 1
400
+ }
401
+
402
+ close_exclusive_target() {
403
+ case "${exclusive_fd:-}" in
404
+ 9) exec 9>&- ;;
405
+ 10) exec 10>&- ;;
406
+ 11) exec 11>&- ;;
407
+ 12) exec 12>&- ;;
408
+ 13) exec 13>&- ;;
409
+ 14) exec 14>&- ;;
410
+ 15) exec 15>&- ;;
411
+ 16) exec 16>&- ;;
412
+ 17) exec 17>&- ;;
413
+ 18) exec 18>&- ;;
414
+ 19) exec 19>&- ;;
415
+ esac
416
+ exclusive_fd=""
417
+ }
418
+
202
419
  bound_log() {
203
420
  local log="$1" total kept=1048576 omitted temp
204
421
  total="$(wc -c < "$log" | tr -d ' ')"
205
422
  [ "$total" -le 2097152 ] && return
206
423
  temp="$log.truncate.tmp"
207
- refuse_nonregular_target "$temp"
208
- { head -c "$kept" "$log"; printf '\n[... truncated %s bytes ...]\n' "$((total - (kept * 2)))"; tail -c "$kept" "$log"; } > "$temp"
424
+ open_exclusive_target "$temp" || { echo "repro-check: could not create log temp file safely" >&2; exit 2; }
425
+ { head -c "$kept" "$log"; printf '\n[... truncated %s bytes ...]\n' "$((total - (kept * 2)))"; tail -c "$kept" "$log"; } >&"$exclusive_fd"
426
+ close_exclusive_target
209
427
  mv "$temp" "$log"
210
428
  }
211
429
 
212
430
  run_one() {
213
431
  local cwd="$1" log="$2" seconds="$3"; shift 3
214
- local status=0 pid started timed=0 waited=0
432
+ local status=0 pid started timed=0 waited=0 temp parent
433
+ parent="$(dirname "$log")"
434
+ trace_path_safe "$parent" dir || { echo "repro-check: log parent is missing or unsafe" >&2; exit 2; }
215
435
  refuse_nonregular_target "$log"
216
- : > "$log"
436
+ temp="$log.run.tmp.$$.$RANDOM"
437
+ open_exclusive_target "$temp" || { echo "repro-check: could not create log temp file safely" >&2; exit 2; }
217
438
  if [ "${REPRO_CHECK_FORCE_FALLBACK:-0}" != "1" ] && command -v timeout >/dev/null 2>&1; then
218
- ( cd "$cwd" && timeout --foreground -k 5 "${seconds}s" "$@" ) > "$log" 2>&1 || status=$?
439
+ ( cd "$cwd" && timeout --foreground -k 5 "${seconds}s" "$@" ) >&"$exclusive_fd" 2>&1 || status=$?
219
440
  else
220
441
  # POSIX watchdog fallback: run the child in its own process group so the
221
442
  # whole group (not just the immediate child) can be killed on timeout.
222
443
  if command -v setsid >/dev/null 2>&1; then
223
- ( cd "$cwd" && exec setsid "$@" ) > "$log" 2>&1 &
444
+ ( cd "$cwd" && exec setsid "$@" ) >&"$exclusive_fd" 2>&1 &
224
445
  else
225
446
  set -m
226
- ( cd "$cwd" && "$@" ) > "$log" 2>&1 &
447
+ ( cd "$cwd" && "$@" ) >&"$exclusive_fd" 2>&1 &
227
448
  set +m
228
449
  fi
229
450
  pid=$!
@@ -242,31 +463,98 @@ run_one() {
242
463
  wait "$pid" 2>/dev/null || status=$?
243
464
  [ "$timed" -eq 0 ] || status=124
244
465
  fi
466
+ close_exclusive_target
467
+ trace_path_safe "$parent" dir || { echo "repro-check: log parent became unsafe" >&2; exit 2; }
468
+ refuse_nonregular_target "$log"
469
+ mv "$temp" "$log"
245
470
  bound_log "$log"
246
471
  printf '%s\n' "$status"
247
472
  }
248
473
 
474
+ worktree_path_safe() {
475
+ local rel="$1" current part resolved target
476
+ is_inside_root "$rel" || return 1
477
+ [ -n "${worktree_real:-}" ] || return 1
478
+ current="$worktree_real"
479
+ while IFS= read -r part; do
480
+ [ -n "$part" ] || continue
481
+ current="$current/$part"
482
+ [ -L "$current" ] && return 1
483
+ if [ -e "$current" ]; then
484
+ if [ -d "$current" ]; then
485
+ resolved="$(cd "$current" 2>/dev/null && pwd -P)" || return 1
486
+ case "$resolved/" in
487
+ "$worktree_real/"*) ;;
488
+ *) return 1 ;;
489
+ esac
490
+ elif [ "$current" != "$worktree_real/$rel" ] || [ ! -f "$current" ]; then
491
+ return 1
492
+ fi
493
+ fi
494
+ done <<EOF
495
+ $(printf '%s' "$rel" | tr '/' '\n')
496
+ EOF
497
+ target="$worktree_real/$rel"
498
+ [ ! -L "$target" ] || return 1
499
+ return 0
500
+ }
501
+
249
502
  copy_path() {
250
- local rel="$1" source target source_dir
503
+ local rel="$1" source target source_dir target_parent
251
504
  is_inside_root "$rel" || { echo "repro-check: --copy path must be repo-relative without ..: $rel" >&2; exit 2; }
252
- source="$root/$rel"
253
- [ -e "$source" ] || { echo "repro-check: --copy path does not exist: $rel" >&2; exit 2; }
505
+ repo_path_safe "$rel" || { echo "repro-check: --copy path must remain inside repo without symlink components: $rel" >&2; exit 2; }
506
+ source="$root_real/$rel"
254
507
  source_dir="$(cd "$(dirname "$source")" && pwd -P)"
255
508
  case "$source_dir" in "$root_real"|"$root_real"/*) ;; *) echo "repro-check: --copy path resolves outside repo: $rel" >&2; exit 2;; esac
256
- target="$worktree/$rel"
257
- mkdir -p "$(dirname "$target")"
258
- rm -rf "$target"
259
- cp -R "$source" "$target"
509
+ target="$worktree_real/$rel"
510
+ target_parent="$(dirname "$target")"
511
+ worktree_path_safe "$rel" || { echo "repro-check: --copy target must remain inside disposable worktree: $rel" >&2; exit 2; }
512
+ mkdir -p -- "$target_parent"
513
+ worktree_path_safe "$rel" || { echo "repro-check: --copy target became unsafe while preparing destination: $rel" >&2; exit 2; }
514
+ rm -rf -- "$target"
515
+ worktree_path_safe "$rel" || { echo "repro-check: --copy target became unsafe before copy: $rel" >&2; exit 2; }
516
+ cp -R -- "$source" "$target"
517
+ worktree_path_safe "$rel" || { echo "repro-check: --copy target became unsafe after copy: $rel" >&2; exit 2; }
260
518
  }
261
519
 
262
520
  link_deps() {
263
521
  [ "$deps" = link ] || return 0
264
522
  [ -e "$root/node_modules" ] || return 0
265
- [ ! -e "$worktree/node_modules" ] || return 0
523
+ local source_real linked_real
524
+ source_real="$(cd "$root/node_modules" 2>/dev/null && pwd -P)" || { echo "repro-check: dependency source cannot be resolved" >&2; return 1; }
525
+ # An existing directory, junction, or symlink is not silently accepted: it
526
+ # must resolve to the exact dependency source this run would have linked.
527
+ # This also rejects dangling links, which otherwise look absent to `-e`.
528
+ if [ -e "$worktree/node_modules" ] || [ -L "$worktree/node_modules" ]; then
529
+ linked_real="$(cd "$worktree/node_modules" 2>/dev/null && pwd -P)" || {
530
+ echo "repro-check: existing dependency target cannot be resolved" >&2
531
+ return 1
532
+ }
533
+ if [ "$source_real" = "$linked_real" ]; then
534
+ return 0
535
+ fi
536
+ echo "repro-check: existing dependency target resolves to '$linked_real', expected '$source_real'" >&2
537
+ return 1
538
+ fi
266
539
  case "$(uname -s 2>/dev/null || true)" in
267
- MINGW* | MSYS* | CYGWIN*) cmd //c mklink //J "$(to_shell_path "$worktree/node_modules")" "$(to_shell_path "$root/node_modules")" >/dev/null 2>&1 || true ;;
268
- *) ln -s "$root/node_modules" "$worktree/node_modules" ;;
540
+ MINGW* | MSYS* | CYGWIN*)
541
+ local destination source
542
+ destination="$(to_native_path "$worktree/node_modules")" || { echo "repro-check: cygpath is required to create a Windows dependency junction" >&2; return 1; }
543
+ source="$(to_native_path "$root/node_modules")" || { echo "repro-check: cygpath is required to create a Windows dependency junction" >&2; return 1; }
544
+ if ! command -v cmd >/dev/null 2>&1 || ! cmd //c mklink //J "$destination" "$source" >/dev/null 2>&1; then
545
+ echo "repro-check: could not create dependency junction: $destination -> $source" >&2
546
+ return 1
547
+ fi
548
+ ;;
549
+ *)
550
+ ln -s "$root/node_modules" "$worktree/node_modules" || { echo "repro-check: could not link dependency directory" >&2; return 1; }
551
+ ;;
269
552
  esac
553
+ linked_real="$(cd "$worktree/node_modules" 2>/dev/null && pwd -P)" || { echo "repro-check: dependency link cannot be resolved" >&2; return 1; }
554
+ if [ "$source_real" != "$linked_real" ]; then
555
+ echo "repro-check: dependency link resolved to '$linked_real', expected '$source_real'" >&2
556
+ return 1
557
+ fi
270
558
  }
271
559
 
272
560
  quoted_argv() { local arg; for arg in "$@"; do printf '%q ' "$arg"; done; }
@@ -298,25 +586,31 @@ do_run() {
298
586
  case "$deps" in link|none) ;; *) echo "repro-check: --deps must be link or none" >&2; exit 2;; esac
299
587
  case "$timeout_seconds" in ''|*[!0-9]*|0) echo "repro-check: --timeout must be a positive integer" >&2; exit 2;; esac
300
588
  case "$class" in DISCRIMINATING|NEW-SURFACE) [ -n "$expect" ] || { echo "repro-check: --expect is required for $class" >&2; exit 2; };; esac
301
- has_bad_field "$expect" && { echo "repro-check: --expect cannot contain tabs or newlines" >&2; exit 2; }
589
+ has_bad_control "$expect" && { echo "repro-check: --expect cannot contain control bytes" >&2; exit 2; }
302
590
  trace_for
303
591
  require_contained "$trace_dir"
304
592
  require_contained "$trace_dir/repro"
593
+ refuse_ancestor_symlink_escape "$trace_dir/repro"
305
594
  mkdir -p "$trace_dir/repro"
306
595
  require_contained "$trace_dir/repro"
596
+ set_trace_root
597
+ trace_path_safe "$trace_dir/repro" dir || { echo "repro-check: repro directory is missing or unsafe" >&2; exit 2; }
307
598
  worktree="$(mktemp -d "${TMPDIR:-/tmp}/issue-tracer-repro.XXXXXX")"
308
599
  cleanup() { git worktree remove --force "$worktree" >/dev/null 2>&1 || true; rm -rf "$worktree"; }
309
600
  trap cleanup EXIT HUP INT TERM
310
601
  git worktree add --detach "$worktree" "$base" >/dev/null 2>&1 || { echo "repro-check: could not create disposable worktree" >&2; exit 2; }
602
+ worktree_real="$(cd "$worktree" 2>/dev/null && pwd -P)" || { echo "repro-check: disposable worktree cannot be resolved" >&2; exit 2; }
311
603
  for arg in ${copies[@]+"${copies[@]}"}; do copy_path "$arg"; done
312
604
  link_deps
313
605
  base_status="$(run_one "$worktree" "$trace_dir/repro/$check_id.base.log" "$timeout_seconds" "$@")"
606
+ trace_path_safe "$trace_dir/repro/$check_id.base.log" file || { echo "repro-check: base log was not created safely" >&2; exit 2; }
314
607
  head_status="$(run_one "$root" "$trace_dir/repro/$check_id.head.log" "$timeout_seconds" "$@")"
608
+ trace_path_safe "$trace_dir/repro/$check_id.head.log" file || { echo "repro-check: head log was not created safely" >&2; exit 2; }
315
609
  if [ "$base_status" -eq 124 ] || [ "$head_status" -eq 124 ]; then
316
610
  base_result="TIMEOUT"; head_result="TIMEOUT"; verdict="FAIL"; exit_code=6
317
611
  elif [ "$class" = DISCRIMINATING ]; then
318
612
  if [ "$base_status" -eq 0 ]; then base_result="VACUOUS"; verdict="VACUOUS"; exit_code=4
319
- elif grep -Eq "$expect" "$trace_dir/repro/$check_id.base.log"; then
613
+ elif trace_path_safe "$trace_dir/repro/$check_id.base.log" file && grep -Eq -- "$expect" "$trace_dir/repro/$check_id.base.log"; then
320
614
  base_result="RED"
321
615
  if [ "$head_status" -eq 0 ]; then head_result="GREEN"; verdict="PASS"; else head_result="FAIL"; verdict="FAIL"; exit_code=5; fi
322
616
  else base_result="ERROR"; verdict="ERROR"; exit_code=3; fi
@@ -325,7 +619,7 @@ do_run() {
325
619
  head_result="$( [ "$head_status" -eq 0 ] && echo GREEN || echo FAIL )"
326
620
  if [ "$base_status" -eq 0 ] && [ "$head_status" -eq 0 ]; then verdict=PASS; else verdict=FAIL; exit_code=5; fi
327
621
  else
328
- if [ "$base_status" -ne 0 ] && grep -Eq "$expect" "$trace_dir/repro/$check_id.base.log"; then
622
+ if [ "$base_status" -ne 0 ] && trace_path_safe "$trace_dir/repro/$check_id.base.log" file && grep -Eq -- "$expect" "$trace_dir/repro/$check_id.base.log"; then
329
623
  base_result="ERROR"
330
624
  if [ "$head_status" -eq 0 ]; then head_result="GREEN"; verdict=PASS; else head_result="FAIL"; verdict=FAIL; exit_code=5; fi
331
625
  else base_result="FAIL"; head_result="$( [ "$head_status" -eq 0 ] && echo GREEN || echo FAIL )"; verdict=FAIL; exit_code=5; fi
@@ -335,6 +629,91 @@ do_run() {
335
629
  exit "$exit_code"
336
630
  }
337
631
 
632
+ acceptance_rows() {
633
+ local file="$trace_dir/02-reproduction.md"
634
+ trace_path_safe "$file" file || return 1
635
+ awk -F '|' '
636
+ # Acceptance tables deliberately accept LF and CRLF files. Remove only
637
+ # the record terminator CR; an embedded CR remains a rejected control byte.
638
+ { sub(/\r$/, "", $0) }
639
+ /^## Acceptance checks$/ { in_table = 1; next }
640
+ /^## / { if (in_table) in_table = 0 }
641
+ !in_table { next }
642
+ $0 == "| AC | class | check | argv | expect | pre-fix | post-fix | notes |" { header = 1; next }
643
+ /^\|[-[:space:]|]+\|[[:space:]]*$/ { next }
644
+ /^\|[[:space:]]*AC[0-9]+[[:space:]]*\|/ {
645
+ if (NF != 10) { bad = 1; next }
646
+ for (i = 2; i <= 9; i++) {
647
+ cell = $i
648
+ sub(/^[ \t]+/, "", cell)
649
+ sub(/[ \t]+$/, "", cell)
650
+ if (cell ~ /[[:cntrl:]]/) bad = 1
651
+ cells[i] = cell
652
+ }
653
+ print cells[2] "\t" cells[3] "\t" cells[4] "\t" cells[5] "\t" cells[6]
654
+ count += 1
655
+ next
656
+ }
657
+ /^\|/ { bad = 1 }
658
+ END { if (!header || bad || count == 0) exit 1 }
659
+ ' "$file"
660
+ }
661
+
662
+ semantic_digest() {
663
+ local canonical
664
+ canonical="$(acceptance_rows)" || {
665
+ echo "repro-check: acceptance table is missing or malformed" >&2
666
+ return 1
667
+ }
668
+ printf '%s\n' "$canonical" | git hash-object --stdin
669
+ }
670
+
671
+ verify_manifest_semantics() {
672
+ local manifest="$1" rows table_exec manifest_exec manifest_semantics
673
+ trace_path_safe "$manifest" file || return 1
674
+ validate_manifest "$manifest" "" allow-legacy-format-only
675
+ rows="$(acceptance_rows)" || {
676
+ echo "repro-check: acceptance table is missing or malformed" >&2
677
+ return 1
678
+ }
679
+ table_exec="$(printf '%s\n' "$rows" | awk -F '\t' '$2 != "NON-EXECUTABLE" { print $3 "\t" $4 "\t" $5 }' | LC_ALL=C sort)"
680
+ # A single acceptance check may freeze multiple files. The manifest keeps
681
+ # one row per (path, check-id) pair for file-integrity replay, while the
682
+ # acceptance table has one semantic row per check. Collapse the effective
683
+ # manifest by check-id here, but reject a hand-edited manifest that gives one
684
+ # check divergent argv/expect semantics on different paths.
685
+ if ! manifest_semantics="$(awk -F '\t' '
686
+ NR > 1 {
687
+ pair = length($3) ":" $3 ":" $6
688
+ latest_seq[pair] = $1
689
+ latest_check[pair] = $6
690
+ latest_argv[pair] = $7
691
+ latest_expect[pair] = $8
692
+ }
693
+ END {
694
+ for (pair in latest_check) {
695
+ check = latest_check[pair]
696
+ if (seen[check] && (check_argv[check] != latest_argv[pair] || check_expect[check] != latest_expect[pair])) {
697
+ exit 1
698
+ }
699
+ seen[check] = 1
700
+ check_argv[check] = latest_argv[pair]
701
+ check_expect[check] = latest_expect[pair]
702
+ }
703
+ for (check in seen) print check "\t" check_argv[check] "\t" check_expect[check]
704
+ }
705
+ ' "$manifest")"; then
706
+ echo "repro-check: checkpoint manifest has divergent semantics for one check" >&2
707
+ return 1
708
+ fi
709
+ manifest_exec="$(printf '%s\n' "$manifest_semantics" | LC_ALL=C sort)"
710
+ if [ "$table_exec" = "$manifest_exec" ]; then
711
+ return 0
712
+ fi
713
+ echo "repro-check: acceptance table semantics do not match checkpoint manifest" >&2
714
+ return 1
715
+ }
716
+
338
717
  do_checkpoint() {
339
718
  local reason="-" check_id="" argv="" expect="" base="" path manifest seq kind mode blob row
340
719
  trace_dir=""; slug=""
@@ -348,42 +727,66 @@ do_checkpoint() {
348
727
  done
349
728
  [ "$#" -gt 0 ] || usage
350
729
  valid_slug "$slug" && valid_id "$check_id" || { echo "repro-check: invalid slug or check id" >&2; exit 2; }
351
- case "$reason" in -) kind=CHECKPOINT;; CHECK_WRONG|FORMAT_ONLY|AC_CHANGED_BY_USER) kind=AMEND;; *) echo "repro-check: invalid amendment reason" >&2; exit 2;; esac
730
+ case "$reason" in -) kind=CHECKPOINT;; CHECK_WRONG|AC_CHANGED_BY_USER) kind=AMEND;; *) echo "repro-check: invalid amendment reason (use CHECK_WRONG or AC_CHANGED_BY_USER)" >&2; exit 2;; esac
352
731
  case "$base" in ''|-*) echo "repro-check: --base must name a commit" >&2; exit 2;; esac
353
732
  git rev-parse --verify --quiet "$base^{commit}" >/dev/null || { echo "repro-check: --base does not resolve to a commit" >&2; exit 2; }
354
- if has_bad_field "$argv" || has_bad_field "$expect"; then
355
- echo "repro-check: manifest fields cannot contain tabs or newlines" >&2
733
+ if has_bad_control "$argv" || has_bad_control "$expect"; then
734
+ echo "repro-check: manifest fields cannot contain control bytes" >&2
356
735
  exit 2
357
736
  fi
358
- manifest="$(manifest_for)"
737
+ trace_for
359
738
  require_contained "$trace_dir"
360
- require_contained "$(dirname "$manifest")"
361
- mkdir -p "$(dirname "$manifest")"
362
- require_contained "$(dirname "$manifest")"
739
+ require_contained "$trace_dir/repro"
740
+ refuse_ancestor_symlink_escape "$trace_dir/repro"
741
+ mkdir -p "$trace_dir/repro"
742
+ require_contained "$trace_dir/repro"
743
+ set_trace_root
744
+ manifest="$trace_dir/repro/checkpoint.manifest"
745
+ trace_path_safe "$trace_dir/repro" dir || { echo "repro-check: repro directory is missing or unsafe" >&2; exit 2; }
746
+ trace_path_safe "$manifest" file || true
363
747
  refuse_nonregular_target "$manifest"
364
- [ -f "$manifest" ] || printf '# issue-tracer checkpoint manifest v1 rows=0\n' > "$manifest"
748
+ if [ ! -f "$manifest" ]; then
749
+ open_exclusive_target "$manifest" || { echo "repro-check: could not create checkpoint manifest safely" >&2; exit 2; }
750
+ printf '# issue-tracer checkpoint manifest v1 rows=0\n' >&"$exclusive_fd"
751
+ close_exclusive_target
752
+ fi
365
753
  # validate_manifest owns the header check: it is strictly stronger than the
366
754
  # old `grep -Fx` (which matched the string on ANY line) and additionally
367
755
  # proves the recorded count, the seq run, and the field count.
368
- validate_manifest "$manifest"
756
+ if [ "$kind" = AMEND ]; then
757
+ # An amendment may be the next step in reconciling two same-path checks
758
+ # that captured different bytes. Strict verification still rejects the
759
+ # intermediate manifest; permit only this targeted append to proceed. The
760
+ # legacy FORMAT_ONLY exception is validation-only: the reason parser above
761
+ # still rejects a newly requested FORMAT_ONLY amendment.
762
+ validate_manifest "$manifest" allow-conflicts allow-legacy-format-only
763
+ else
764
+ validate_manifest "$manifest"
765
+ fi
369
766
  seq="$(awk 'END {print NR - 1}' "$manifest")"
370
767
  for path in "$@"; do
371
768
  is_inside_root "$path" || { echo "repro-check: checkpoint path must be repo-relative without ..: $path" >&2; exit 2; }
372
- has_bad_field "$path" && { echo "repro-check: checkpoint path cannot contain tabs or newlines" >&2; exit 2; }
373
- [ -f "$root/$path" ] || { echo "repro-check: checkpoint path must be a file: $path" >&2; exit 2; }
374
- # Re-running the sanctioned `checkpoint` command on an already-frozen path
769
+ has_bad_path "$path" && { echo "repro-check: checkpoint path cannot contain control bytes" >&2; exit 2; }
770
+ repo_file_safe "$path" || { echo "repro-check: checkpoint path must be a regular in-repository file: $path" >&2; exit 2; }
771
+ # Re-running the sanctioned `checkpoint` command on an already-frozen pair
375
772
  # would append a fresh CHECKPOINT row that last-writer-wins re-baselines a
376
773
  # weakened check to green in do_verify. Refuse it: superseding a frozen
377
- # blob requires an AMEND row that names a reason and stays in the file.
378
- # The manifest is re-read per path, so a path frozen by an earlier
774
+ # pair requires an AMEND row that names a reason and stays in the file.
775
+ # The manifest is re-read per path, so a pair frozen by an earlier
379
776
  # iteration of this same invocation is already recorded and also refused.
380
- if [ "$kind" = CHECKPOINT ] && manifest_has_path "$manifest" "$path"; then
381
- echo "repro-check: $path is already frozen; supersede it with --reason CHECK_WRONG|FORMAT_ONLY|AC_CHANGED_BY_USER" >&2
777
+ if [ "$kind" = CHECKPOINT ] && manifest_has_pair "$manifest" "$path" "$check_id"; then
778
+ echo "repro-check: $path ($check_id) is already frozen; supersede it with --reason CHECK_WRONG|AC_CHANGED_BY_USER" >&2
779
+ exit 2
780
+ fi
781
+ if [ "$kind" = AMEND ] && ! manifest_has_pair "$manifest" "$path" "$check_id"; then
782
+ echo "repro-check: $path ($check_id) cannot be amended before it is checkpointed" >&2
382
783
  exit 2
383
784
  fi
384
- blob="$(git hash-object "$root/$path")"
785
+ # Always capture the current bytes for a new pair. Do not inherit the blob
786
+ # from another check that happens to use the same path.
787
+ blob="$(git hash-object "$root_real/$path")"
385
788
  mode="$(git ls-files -s -- "$path" | awk 'NR==1 {print $1}')"
386
- [ -n "$mode" ] || { [ -x "$root/$path" ] && mode=100755 || mode=100644; }
789
+ [ -n "$mode" ] || { [ -x "$root_real/$path" ] && mode=100755 || mode=100644; }
387
790
  seq=$((seq + 1))
388
791
  # $seq is post-increment, so it is also the new total row count that the
389
792
  # header must record. Row and header land together in one temp-file swap.
@@ -394,23 +797,147 @@ do_checkpoint() {
394
797
  }
395
798
 
396
799
  do_verify() {
397
- local manifest line path old new changed=0
800
+ local manifest line path old check_id new changed=0
398
801
  trace_dir=""; slug=""
399
802
  while [ "$#" -gt 0 ]; do case "$1" in --slug) [ "$#" -ge 2 ] || usage; slug="$2"; shift 2;; --trace-dir) [ "$#" -ge 2 ] || usage; trace_dir="$2"; shift 2;; *) usage;; esac; done
400
803
  valid_slug "$slug" || { echo "repro-check: invalid slug" >&2; exit 2; }
401
- manifest="$(manifest_for)"
402
- [ -f "$manifest" ] || { echo "repro-check: checkpoint manifest missing or invalid" >&2; exit 2; }
804
+ trace_for
805
+ set_trace_root
806
+ manifest="$trace_dir/repro/checkpoint.manifest"
807
+ trace_path_safe "$manifest" file || { echo "repro-check: checkpoint manifest missing or invalid" >&2; exit 2; }
403
808
  # Iterating only the surviving rows would silently drop a frozen check when a
404
809
  # row is deleted OR the tail is truncated, so structure - header count, seq
405
810
  # run, field count - is proven before any row is replayed.
406
- validate_manifest "$manifest"
407
- while IFS=$'\t' read -r path old; do
408
- [ -f "$root/$path" ] || { echo "CHANGED $path $old MISSING"; changed=1; continue; }
409
- new="$(git hash-object "$root/$path")"
410
- if [ "$old" = "$new" ]; then echo "OK $path"; else echo "CHANGED $path $old $new"; changed=1; fi
411
- done < <(awk -F '\t' 'NR > 1 {blob[$3]=$4} END {for (path in blob) print path "\t" blob[path]}' "$manifest")
811
+ validate_manifest "$manifest" "" allow-legacy-format-only
812
+ while IFS=$'\t' read -r path old check_id; do
813
+ if [ -z "$path" ] || ! is_inside_root "$path" || ! valid_id "$check_id"; then
814
+ echo "repro-check: checkpoint manifest contains an unsafe path or invalid check id" >&2
815
+ exit 2
816
+ fi
817
+ if ! repo_file_safe "$path"; then
818
+ echo "CHANGED $path $old MISSING"; changed=1; continue
819
+ fi
820
+ new="$(git hash-object "$root_real/$path")"
821
+ if [ "$old" = "$new" ]; then echo "OK $path ($check_id)"; else echo "CHANGED $path $old $new ($check_id)"; changed=1; fi
822
+ done < <(awk -F '\t' '
823
+ NR > 1 {
824
+ pair = length($3) ":" $3 ":" $6
825
+ latest_seq[pair] = $1
826
+ latest_path[pair] = $3
827
+ latest_blob[pair] = $4
828
+ latest_check[pair] = $6
829
+ }
830
+ END {
831
+ for (pair in latest_path) print latest_seq[pair] "\t" latest_path[pair] "\t" latest_blob[pair] "\t" latest_check[pair]
832
+ }
833
+ ' "$manifest" | sort -n -k1,1 | cut -f2-)
412
834
  [ "$changed" -eq 0 ] || exit 1
413
835
  }
414
836
 
837
+ do_verify_semantics() {
838
+ local manifest
839
+ trace_dir=""; slug=""
840
+ while [ "$#" -gt 0 ]; do
841
+ case "$1" in
842
+ --slug) [ "$#" -ge 2 ] || usage; slug="$2"; shift 2;;
843
+ --trace-dir) [ "$#" -ge 2 ] || usage; trace_dir="$2"; shift 2;;
844
+ *) usage;;
845
+ esac
846
+ done
847
+ valid_slug "$slug" || { echo "repro-check: invalid slug" >&2; exit 2; }
848
+ trace_for
849
+ set_trace_root
850
+ manifest="$trace_dir/repro/checkpoint.manifest"
851
+ trace_path_safe "$manifest" file || { echo "repro-check: checkpoint manifest missing or invalid" >&2; exit 2; }
852
+ verify_manifest_semantics "$manifest"
853
+ echo "semantics: OK"
854
+ }
855
+
856
+ state_value() {
857
+ local key="$1" state_file="$trace_dir/state.md"
858
+ [ -n "$trace_dir" ] || state_file="$root/.agents/issue-traces/$slug/state.md"
859
+ state_file="$(to_shell_path "$state_file")"
860
+ trace_path_safe "$state_file" file || return 0
861
+ awk -F ': ' -v key="$key" '$1 == key { print substr($0, length(key) + 3); exit }' "$state_file" 2>/dev/null || true
862
+ }
863
+
864
+ receipt_for() {
865
+ local manifest="$1" digest semantics tree
866
+ trace_path_safe "$manifest" file || { echo "repro-check: checkpoint manifest missing or unsafe" >&2; exit 2; }
867
+ digest="$(git hash-object --no-filters "$manifest")"
868
+ semantics="$(semantic_digest)" || exit 2
869
+ tree="$(state_value checkpoint-tree-id)"
870
+ is_sha1 "$tree" || { echo "repro-check: state.md has no valid checkpoint-tree-id" >&2; exit 2; }
871
+ printf 'issue-tracer-checkpoint-v1 slug=%s manifest=%s semantics=%s tree=%s\n' "$slug" "$digest" "$semantics" "$tree"
872
+ }
873
+
874
+ do_anchor() {
875
+ local manifest
876
+ trace_dir=""; slug=""
877
+ while [ "$#" -gt 0 ]; do
878
+ case "$1" in
879
+ --slug) [ "$#" -ge 2 ] || usage; slug="$2"; shift 2;;
880
+ --trace-dir) [ "$#" -ge 2 ] || usage; trace_dir="$2"; shift 2;;
881
+ *) usage;;
882
+ esac
883
+ done
884
+ valid_slug "$slug" || { echo "repro-check: invalid slug" >&2; exit 2; }
885
+ trace_for
886
+ set_trace_root
887
+ manifest="$trace_dir/repro/checkpoint.manifest"
888
+ trace_path_safe "$manifest" file || { echo "repro-check: checkpoint manifest missing or invalid" >&2; exit 2; }
889
+ # Verification diagnostics are intentionally kept on stderr by do_verify;
890
+ # anchor's stdout is a machine-readable, single-line receipt for reviewers
891
+ # to copy without filtering replay output.
892
+ do_verify --slug "$slug" --trace-dir "$trace_dir" >/dev/null
893
+ verify_manifest_semantics "$manifest" >/dev/null
894
+ receipt_for "$manifest"
895
+ }
896
+
897
+ do_verify_anchor() {
898
+ local receipt="" manifest expected_digest expected_semantics expected_tree parsed_slug parsed_manifest parsed_semantics parsed_tree
899
+ trace_dir=""; slug=""
900
+ while [ "$#" -gt 0 ]; do
901
+ case "$1" in
902
+ --slug) [ "$#" -ge 2 ] || usage; slug="$2"; shift 2;;
903
+ --trace-dir) [ "$#" -ge 2 ] || usage; trace_dir="$2"; shift 2;;
904
+ --receipt) [ "$#" -ge 2 ] || usage; receipt="$2"; shift 2;;
905
+ *) usage;;
906
+ esac
907
+ done
908
+ valid_slug "$slug" || { echo "repro-check: invalid slug" >&2; exit 2; }
909
+ case "$receipt" in *$'\n'*|*$'\r'*|*$'\t'*) echo "repro-check: anchor receipt must be one single line" >&2; exit 2;; esac
910
+ if ! printf '%s\n' "$receipt" | grep -Eq '^issue-tracer-checkpoint-v1 slug=[a-z0-9-]+ manifest=[0-9a-f]{40} semantics=[0-9a-f]{40} tree=[0-9a-f]{40}$'; then
911
+ echo "repro-check: malformed anchor receipt" >&2
912
+ exit 2
913
+ fi
914
+ parsed_slug="${receipt#*slug=}"; parsed_slug="${parsed_slug%% manifest=*}"
915
+ parsed_manifest="${receipt#*manifest=}"; parsed_manifest="${parsed_manifest%% semantics=*}"
916
+ parsed_semantics="${receipt#*semantics=}"; parsed_semantics="${parsed_semantics%% tree=*}"
917
+ parsed_tree="${receipt##* tree=}"
918
+ [ "$parsed_slug" = "$slug" ] || { echo "repro-check: anchor receipt slug does not match --slug" >&2; exit 2; }
919
+ trace_for
920
+ set_trace_root
921
+ manifest="$trace_dir/repro/checkpoint.manifest"
922
+ trace_path_safe "$manifest" file || { echo "repro-check: checkpoint manifest missing or invalid" >&2; exit 2; }
923
+ do_verify --slug "$slug" --trace-dir "$trace_dir"
924
+ trace_path_safe "$manifest" file || { echo "repro-check: checkpoint manifest became unsafe" >&2; exit 2; }
925
+ verify_manifest_semantics "$manifest" >/dev/null
926
+ expected_digest="$(git hash-object --no-filters "$manifest")"
927
+ expected_semantics="$(semantic_digest)"
928
+ expected_tree="$(state_value checkpoint-tree-id)"
929
+ [ "$expected_digest" = "$parsed_manifest" ] || { echo "repro-check: anchor manifest digest does not match checkpoint manifest" >&2; exit 1; }
930
+ [ "$expected_semantics" = "$parsed_semantics" ] || { echo "repro-check: anchor semantic digest does not match acceptance table" >&2; exit 1; }
931
+ [ "$expected_tree" = "$parsed_tree" ] || { echo "repro-check: anchor tree does not match state.md checkpoint-tree-id" >&2; exit 1; }
932
+ }
933
+
415
934
  command="${1:-}"; shift || true
416
- case "$command" in run) do_run "$@";; checkpoint) do_checkpoint "$@";; verify-checkpoint) do_verify "$@";; *) usage;; esac
935
+ case "$command" in
936
+ run) do_run "$@";;
937
+ checkpoint) do_checkpoint "$@";;
938
+ verify-checkpoint) do_verify "$@";;
939
+ verify-semantics) do_verify_semantics "$@";;
940
+ anchor) do_anchor "$@";;
941
+ verify-anchor) do_verify_anchor "$@";;
942
+ *) usage;;
943
+ esac