oh-my-customcode 1.1.48 → 1.1.50

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 (47) hide show
  1. package/README.md +7 -6
  2. package/dist/cli/index.js +171 -294
  3. package/dist/index.js +65 -65
  4. package/package.json +2 -2
  5. package/templates/.claude/agents/agora-runner.md +114 -0
  6. package/templates/.claude/hooks/hooks.json +10 -0
  7. package/templates/.claude/hooks/scripts/agent-teams-advisor.sh +6 -1
  8. package/templates/.claude/hooks/scripts/claude-md-reinject.sh +65 -0
  9. package/templates/.claude/hooks/scripts/r007-r008-drift-advisor.sh +41 -3
  10. package/templates/.claude/hooks/scripts/session-env-check.sh +29 -3
  11. package/templates/.claude/rules/MAY-optimization.md +7 -1
  12. package/templates/.claude/rules/MUST-agent-design.md +15 -4
  13. package/templates/.claude/rules/MUST-agent-teams.md +19 -0
  14. package/templates/.claude/rules/MUST-completion-verification.md +53 -0
  15. package/templates/.claude/rules/MUST-continuous-improvement.md +20 -4
  16. package/templates/.claude/rules/MUST-enforcement-policy.md +7 -3
  17. package/templates/.claude/rules/MUST-orchestrator-coordination.md +45 -0
  18. package/templates/.claude/rules/MUST-parallel-execution.md +61 -8
  19. package/templates/.claude/rules/MUST-permissions.md +8 -0
  20. package/templates/.claude/rules/MUST-safety.md +6 -0
  21. package/templates/.claude/rules/MUST-sync-verification.md +21 -0
  22. package/templates/.claude/rules/MUST-tool-identification.md +22 -4
  23. package/templates/.claude/rules/SHOULD-ecomode.md +4 -0
  24. package/templates/.claude/rules/SHOULD-error-handling.md +2 -0
  25. package/templates/.claude/rules/SHOULD-interaction.md +4 -0
  26. package/templates/.claude/rules/SHOULD-memory-integration.md +2 -0
  27. package/templates/.claude/rules/SHOULD-verification-ladder.md +2 -0
  28. package/templates/.claude/skills/agora/SKILL.md +325 -0
  29. package/templates/.claude/skills/agora/scripts/agora.sh +761 -0
  30. package/templates/.claude/skills/agora/scripts/anonymize.sh +492 -0
  31. package/templates/.claude/skills/agora/scripts/judge.sh +427 -0
  32. package/templates/.claude/skills/agora/scripts/response-schema.json +26 -0
  33. package/templates/.claude/skills/agora/scripts/reviewers.sh +312 -0
  34. package/templates/.claude/skills/agora/scripts/verdict-schema.json +34 -0
  35. package/templates/.claude/skills/hada-scout/SKILL.md +1 -1
  36. package/templates/.claude/skills/help/SKILL.md +1 -1
  37. package/templates/.claude/skills/pipeline/workflows/auto-dev.yaml +21 -3
  38. package/templates/.claude/skills/sauron-watch/SKILL.md +1 -1
  39. package/templates/.claude/skills/status/SKILL.md +3 -3
  40. package/templates/.claude/skills/token-efficiency-audit/SKILL.md +1 -1
  41. package/templates/CLAUDE.md +3 -3
  42. package/templates/CLAUDE.md.en +3 -3
  43. package/templates/CLAUDE.md.ko +3 -3
  44. package/templates/README.md +5 -5
  45. package/templates/guides/agent-eval/README.md +1 -1
  46. package/templates/manifest.json +3 -3
  47. package/templates/workflows/auto-dev.yaml +21 -3
@@ -0,0 +1,492 @@
1
+ #!/usr/bin/env bash
2
+ # anonymize.sh — normalize reviewer responses, shuffle labels, seal the mapping.
3
+ # Spec: docs/superpowers/plans/2026-08-15-agora-anonymous-consensus-design.md §5 §6 §7
4
+ set -euo pipefail
5
+
6
+ # ---------------------------------------------------------------------------
7
+ # Vendor identity helpers (spec §7 `map` values are "{cli}:{model}" strings).
8
+ # ---------------------------------------------------------------------------
9
+ vendor_id() {
10
+ case "$1" in
11
+ claude) printf 'claude:claude-opus-4-8' ;;
12
+ omx) printf 'omx:default' ;;
13
+ agy) printf 'agy:gemini-3.1-pro-high' ;;
14
+ *) printf 'anonymize.sh: unknown vendor slug %s\n' "$1" >&2; return 65 ;;
15
+ esac
16
+ }
17
+
18
+ vendor_slug() {
19
+ case "$1" in
20
+ claude:claude-opus-4-8) printf 'claude' ;;
21
+ omx:default) printf 'omx' ;;
22
+ agy:gemini-3.1-pro-high) printf 'agy' ;;
23
+ *) printf 'anonymize.sh: unknown vendor id %s\n' "$1" >&2; return 65 ;;
24
+ esac
25
+ }
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # hash_int <seed> <counter> — deterministic non-negative integer.
29
+ # ---------------------------------------------------------------------------
30
+ hash_int() {
31
+ local hex
32
+ hex=$(printf '%s:%s' "$1" "$2" | shasum -a 256 | cut -c1-8)
33
+ printf '%d' "$((16#$hex))"
34
+ }
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # shuffle_labels <seed> <vendor_id>... — seeded Fisher-Yates, then A/B/C in order.
38
+ # ---------------------------------------------------------------------------
39
+ shuffle_labels() {
40
+ local seed="$1"; shift
41
+ if [ "$#" -eq 0 ]; then
42
+ printf 'anonymize.sh: shuffle_labels needs at least one vendor\n' >&2
43
+ return 64
44
+ fi
45
+
46
+ local vendors=("$@")
47
+ local n=${#vendors[@]}
48
+ local i j r tmp
49
+ for (( i = n - 1; i > 0; i-- )); do
50
+ r=$(hash_int "$seed" "$i")
51
+ j=$(( r % (i + 1) ))
52
+ tmp="${vendors[$i]}"
53
+ vendors[$i]="${vendors[$j]}"
54
+ vendors[$j]="$tmp"
55
+ done
56
+
57
+ local labels=(A B C)
58
+ local out='{'
59
+ for (( i = 0; i < n; i++ )); do
60
+ [ "$i" -gt 0 ] && out+=','
61
+ out+="\"${labels[$i]}\":\"${vendors[$i]}\""
62
+ done
63
+ out+='}'
64
+ printf '%s\n' "$out"
65
+ }
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Fingerprint guard (spec §12-(1)). Case-insensitive; a hit aborts the session.
69
+ # Three tiers, because "vendor name" and "ordinary English word" overlap:
70
+ #
71
+ # 1. Unambiguous vendor/product tokens — matched as bare SUBSTRINGS, with no
72
+ # word boundary at all: codex, omx, gpt, claude, gemini, antigravity,
73
+ # anthropic, openai (+ the Korean transliterations this repo's reviewers
74
+ # actually write, R000). Substring — not word-bounded — because a trailing
75
+ # boundary lets real identifications through: `(^|[^a-z])claude([^a-z]|$)`
76
+ # does NOT match "claudecode", and `gpt-oss` does not match "chatgpt".
77
+ # None of these appear inside an ordinary English word, so the boundary
78
+ # bought nothing and cost coverage.
79
+ # `agy` KEEPS both boundaries, unchanged: at three letters it is the one
80
+ # token short enough to plausibly land inside an unrelated word (stagy,
81
+ # cagy, a surname), and unlike the others it has no self-identification
82
+ # form to miss — a model does not call itself "agycode".
83
+ #
84
+ # 2. Model-family names that are ALSO ordinary English words — opus, sonnet,
85
+ # haiku, flash. A bare match would abort on legitimate review prose
86
+ # ("magnum opus", "sonnet-length prose", "flash memory"), so these are
87
+ # matched only in the two shapes that actually identify a model:
88
+ # (a) version-adjacent — "Opus 4.8", "sonnet-5", "flash 2.0"
89
+ # (b) abutting non-ASCII text — "Sonnet 관점에서". This repo reviews in
90
+ # Korean (R000); the English idioms above are ASCII-internal
91
+ # collocations, whereas a bare model word touching Hangul is a
92
+ # self-reference dropped into Korean prose.
93
+ # Accepted false positive: Korean-English mixed technical terms such as
94
+ # "flash 메모리" trip rule (b). Accepted false negative: a bare model word
95
+ # inside English prose with neither a version nor adjacent Hangul
96
+ # ("Sonnet would argue") is regex-indistinguishable from "sonnet-length"
97
+ # and passes. Over-blocking is preferred to under-blocking here — a false
98
+ # positive aborts loudly and recoverably, a miss breaks anonymity silently.
99
+ #
100
+ # 3. Sealed-path forms (SEALED/, /mapping/, raw/round-) — unchanged.
101
+ #
102
+ # NOTE: tests/unit/skills/agora-scripts.test.ts parses this literal and feeds it
103
+ # to `new RegExp(...)`, so the syntax must stay valid in BOTH POSIX ERE and JS:
104
+ # no POSIX bracket classes ([[:space:]]) — plain ranges only.
105
+ # ---------------------------------------------------------------------------
106
+ AGORA_BANNED_PATTERNS='codex|omx|gpt|claude|gemini|antigravity|anthropic|openai|클로드|제미나이|지피티|앤트로픽|오픈에이아이|(^|[^a-z])agy([^a-z]|$)|(opus|sonnet|haiku|flash)[ ._-]?[0-9]|(opus|sonnet|haiku|flash)[ ._-]*[^ -~]|SEALED/|/mapping/|raw/round-'
107
+
108
+ assert_no_fingerprint() {
109
+ local file="$1"
110
+ if LC_ALL=C command grep -Eiq "$AGORA_BANNED_PATTERNS" "$file"; then
111
+ printf 'AGORA_FINGERPRINT_DETECTED: %s\n' "$file" >&2
112
+ return 1
113
+ fi
114
+ return 0
115
+ }
116
+
117
+ # ---------------------------------------------------------------------------
118
+ # validate_response <file> — spec §5 contract. Exit 0 when the contract holds.
119
+ #
120
+ # Every text field is checked with `filled`, not `length > 0`. The byte-count
121
+ # form rejected "" but ACCEPTED " " (measured: a three-space rationale, and
122
+ # equally a whitespace-only claim/evidence/impact/counter/id, passed every
123
+ # clause here). The spec's requirement is CONTENT, and the gap is not
124
+ # cosmetic — a blank-but-present response is counted as a valid reviewer by
125
+ # the two-reviewer floor below, so it can be the difference between "round
126
+ # aborted, too few reviewers" and a round that reaches the judge, who then
127
+ # weighs an opinion that says nothing. The empty string was already rejected;
128
+ # a string of spaces says exactly as much.
129
+ #
130
+ # Applied uniformly across all six text fields rather than to `rationale`
131
+ # alone: they are one clause of one contract written in one idiom, and a
132
+ # split would leave `counter: " "` accepted while `counter: ""` is
133
+ # rejected — for the field the reviewer prompt singles out as one that
134
+ # "cannot be an empty string" (agora.sh's build_reviewer_prompt).
135
+ #
136
+ # `test("[^[:space:]]")` = "carries at least one non-whitespace character".
137
+ # Character-class, not a trim-then-measure, so it costs one pass and covers
138
+ # the whitespace this repo's Korean reviewers can actually emit — measured
139
+ # to reject U+3000 IDEOGRAPHIC SPACE and U+00A0 NBSP as well as ASCII space,
140
+ # tab and newline. The `type == "string"` conjunct stays in front of `test`
141
+ # on purpose: jq's `and` short-circuits, so a null/absent/number field is
142
+ # reported as a contract violation instead of erroring out of the whole
143
+ # filter (which >/dev/null 2>&1 would render indistinguishable from a
144
+ # clean rejection).
145
+ #
146
+ # Rejection is NOT an abort — the caller treats a failing response as a
147
+ # missing vendor (see build_bundle), so the two-reviewer floor decides
148
+ # whether the round survives.
149
+ # ---------------------------------------------------------------------------
150
+ validate_response() {
151
+ jq -e '
152
+ def filled: type == "string" and test("[^[:space:]]");
153
+ (type == "object")
154
+ and (.overall | IN("BUILD","BUILD_WITH_CHANGES","REDESIGN","ABANDON"))
155
+ and (.rationale | filled)
156
+ and (.findings | type == "array")
157
+ and (.findings | all(
158
+ (.id | filled)
159
+ and (.severity | IN("CRITICAL","HIGH","MEDIUM","LOW"))
160
+ and (.claim | filled)
161
+ and (.evidence | filled)
162
+ and (.impact | filled)
163
+ and (.counter | filled)
164
+ and (.verdict | IN("KEEP","MODIFY","REJECT"))
165
+ ))
166
+ ' "$1" >/dev/null 2>&1
167
+ }
168
+
169
+ # ---------------------------------------------------------------------------
170
+ # normalize_response <file> — keep only the spec §5 whitelist (REQ-7).
171
+ # ---------------------------------------------------------------------------
172
+ normalize_response() {
173
+ jq -c '{
174
+ findings: [ .findings[] | {id, severity, claim, evidence, impact, counter, verdict} ],
175
+ overall: .overall,
176
+ rationale: .rationale
177
+ }' "$1"
178
+ }
179
+
180
+ # ---------------------------------------------------------------------------
181
+ # relabel_prior <session_dir> <current_round> <prior_round> [map_cur_path] — spec §6.
182
+ # vendor = map[M]⁻¹(label_M); label_N = map[N]⁻¹(vendor)
183
+ #
184
+ # [map_cur_path] overrides where the CURRENT round's mapping is read from; it
185
+ # defaults to the sealed on-disk path, but build_bundle passes its staged
186
+ # (not-yet-sealed) working copy instead, because relabel_prior runs before the
187
+ # fingerprint guard has cleared the current round (spec §12-(1) — see C1).
188
+ #
189
+ # Exit codes distinguish two failure classes so the caller can react correctly:
190
+ # 1 = prior-round data does not exist yet (normal — e.g. round 1 has no round 0)
191
+ # 2 = prior-round data EXISTS but failed to parse (integrity problem — the
192
+ # caller must abort loudly rather than silently drop the round).
193
+ # build_bundle surfaces this to ITS caller as 65 (EX_DATAERR), keeping it
194
+ # distinguishable from 1, which build_bundle reserves for the fingerprint
195
+ # abort.
196
+ # ---------------------------------------------------------------------------
197
+ relabel_prior() {
198
+ local dir="$1" cur="$2" prior="$3" map_cur_path="${4:-$dir/SEALED/mapping/round-$cur.json}"
199
+ local map_prior="$dir/SEALED/mapping/round-$prior.json"
200
+ local bundle_prior="$dir/anon/round-$prior.json"
201
+ local verdict_prior="$dir/verdict/round-$prior.json"
202
+
203
+ [ -f "$map_prior" ] && [ -f "$bundle_prior" ] && [ -f "$map_cur_path" ] || return 1
204
+
205
+ local mp mc bp
206
+ mp=$(jq -c '.map' "$map_prior" 2>/dev/null) || mp=''
207
+ if [ -z "$mp" ] || [ "$mp" = 'null' ]; then
208
+ printf 'anonymize.sh: INTEGRITY: round-%s mapping is unreadable or malformed (%s)\n' \
209
+ "$prior" "$map_prior" >&2
210
+ return 2
211
+ fi
212
+
213
+ mc=$(jq -c '.map' "$map_cur_path" 2>/dev/null) || mc=''
214
+ if [ -z "$mc" ] || [ "$mc" = 'null' ]; then
215
+ printf 'anonymize.sh: INTEGRITY: current-round mapping is unreadable or malformed (%s)\n' \
216
+ "$map_cur_path" >&2
217
+ return 2
218
+ fi
219
+
220
+ bp=$(jq -c '.' "$bundle_prior" 2>/dev/null) || bp=''
221
+ if [ -z "$bp" ]; then
222
+ printf 'anonymize.sh: INTEGRITY: round-%s bundle is unreadable or malformed (%s)\n' \
223
+ "$prior" "$bundle_prior" >&2
224
+ return 2
225
+ fi
226
+
227
+ local verdict_json='{"verdict":"","draft":""}'
228
+ if [ -f "$verdict_prior" ]; then
229
+ local vd
230
+ vd=$(jq -c '{verdict: (.verdict // ""), draft: (.draft // "")}' "$verdict_prior" 2>/dev/null) || vd=''
231
+ if [ -z "$vd" ]; then
232
+ printf 'anonymize.sh: INTEGRITY: round-%s verdict is unreadable or malformed (%s)\n' \
233
+ "$prior" "$verdict_prior" >&2
234
+ return 2
235
+ fi
236
+ verdict_json="$vd"
237
+ fi
238
+
239
+ jq -c -n \
240
+ --argjson mp "$mp" \
241
+ --argjson mc "$mc" \
242
+ --argjson bp "$bp" \
243
+ --argjson vd "$verdict_json" \
244
+ --argjson rn "$prior" '
245
+ ($mc | to_entries | map({key: .value, value: .key}) | from_entries) as $curByVendor
246
+ | {
247
+ round: $rn,
248
+ reviewers: [
249
+ $bp.reviewers[]
250
+ | ($mp[.label]) as $vendor
251
+ | select($vendor != null and $curByVendor[$vendor] != null)
252
+ | { label: $curByVendor[$vendor], overall: .overall, rationale: .rationale }
253
+ ] | sort_by(.label),
254
+ verdict: $vd.verdict,
255
+ draft: $vd.draft
256
+ }
257
+ '
258
+ }
259
+
260
+ # ---------------------------------------------------------------------------
261
+ # build_bundle — normalize → shuffle → label → seal mapping → emit anon bundle.
262
+ # ---------------------------------------------------------------------------
263
+ build_bundle() {
264
+ local dir='' round='' seed='' topic='' attachments='[]' agenda='[]'
265
+ while [ "$#" -gt 0 ]; do
266
+ case "$1" in
267
+ --session-dir) dir="$2"; shift 2 ;;
268
+ --round) round="$2"; shift 2 ;;
269
+ --seed) seed="$2"; shift 2 ;;
270
+ --topic) topic="$2"; shift 2 ;;
271
+ --attachments) attachments="$2"; shift 2 ;;
272
+ --agenda) agenda="$2"; shift 2 ;;
273
+ *) printf 'anonymize.sh: unknown build option %s\n' "$1" >&2; return 64 ;;
274
+ esac
275
+ done
276
+ [ -n "$dir" ] && [ -n "$round" ] && [ -n "$seed" ] || {
277
+ printf 'anonymize.sh: --session-dir, --round and --seed are required\n' >&2
278
+ return 64
279
+ }
280
+
281
+ local raw_dir="$dir/SEALED/raw/round-$round"
282
+ local work; work=$(mktemp -d)
283
+ # shellcheck disable=SC2064
284
+ trap "rm -rf '$work'" RETURN
285
+
286
+ local present=()
287
+ local slug id
288
+ for slug in claude omx agy; do
289
+ local raw="$raw_dir/$slug.json"
290
+ [ -f "$raw" ] || continue
291
+ validate_response "$raw" || {
292
+ printf 'anonymize.sh: %s failed the schema contract, treating as missing\n' "$slug" >&2
293
+ continue
294
+ }
295
+ id=$(vendor_id "$slug")
296
+ normalize_response "$raw" > "$work/$slug.json"
297
+ present+=("$id")
298
+ done
299
+
300
+ # Spec §11: a single opinion is not a consensus process. reviewers.sh already
301
+ # aborts with exit 3 once two vendors fail to RESPOND, but that gate cannot
302
+ # see the schema check above — a response that arrives and then fails
303
+ # validate_response is counted as missing HERE, after reviewers.sh has passed.
304
+ # Without the same floor on this side, 1 valid + 2 schema-violating responses
305
+ # produce a one-reviewer bundle and the judge ends up ruling "consensus" on a
306
+ # single opinion. Same threshold, same exit code (3) as reviewers.sh, so the
307
+ # caller (agora.sh propagates rc as-is) and the operator-facing meaning
308
+ # "round aborted: too few reviewers" stay identical regardless of which of the
309
+ # two gates fired. 3 is otherwise unused in this script (64/65 are this
310
+ # script's own usage and malformed-data codes — see build_bundle's own arg
311
+ # parsing above for 64, and the integrity-failure return below for 65; 1 is
312
+ # the fingerprint abort). 66/EX_NOINPUT is part of the same skill-wide
313
+ # sysexits-style convention (judge.sh and agora.sh both return it for a
314
+ # missing input file/dir) but this script has no missing-input case of its
315
+ # own to return it from.
316
+ [ "${#present[@]}" -ge 2 ] || {
317
+ printf 'anonymize.sh: round %s has %s valid reviewer response(s); 2 or more are required (spec §11)\n' \
318
+ "$round" "${#present[@]}" >&2
319
+ return 3
320
+ }
321
+
322
+ local map_json
323
+ map_json=$(shuffle_labels "$seed" "${present[@]}")
324
+
325
+ # ---------------------------------------------------------------------------
326
+ # Stage everything under $work. Nothing is written to a trust-boundary path
327
+ # (SEALED/mapping/ or anon/) until the fingerprint guard below clears the
328
+ # assembled bundle (spec §12-(1)). Write-then-delete leaves a window where a
329
+ # leaked bundle — or an orphaned sealed mapping with no matching bundle — is
330
+ # a real file at a path Task 4/5 reviewers/judge (or the round loop) can read.
331
+ # ---------------------------------------------------------------------------
332
+ local map_work="$work/mapping.json"
333
+ jq -n --argjson r "$round" --arg s "$seed" --argjson m "$map_json" \
334
+ '{round: $r, seed: $s, map: $m}' > "$map_work"
335
+
336
+ # reviewers[] — always sorted by label (spec §6).
337
+ local reviewers='[]'
338
+ local label
339
+ for label in A B C; do
340
+ id=$(printf '%s' "$map_json" | jq -r --arg l "$label" '.[$l] // empty')
341
+ [ -n "$id" ] || continue
342
+ slug=$(vendor_slug "$id")
343
+ reviewers=$(jq -c --arg l "$label" --slurpfile body "$work/$slug.json" --argjson acc "$reviewers" \
344
+ -n '$acc + [{label: $l, findings: $body[0].findings, overall: $body[0].overall, rationale: $body[0].rationale}]')
345
+ done
346
+
347
+ # prior_rounds[] — the two most recent rounds only (spec §11). The CURRENT
348
+ # round's mapping is read from the staged $map_work, not from SEALED/, since
349
+ # it has not been sealed yet at this point in the pipeline.
350
+ local priors='[]' p
351
+ for p in $(seq $(( round - 2 > 1 ? round - 2 : 1 )) $(( round - 1 ))); do
352
+ [ "$p" -ge 1 ] || continue
353
+ local entry relabel_err="$work/relabel-err-$p.log"
354
+ if entry=$(relabel_prior "$dir" "$round" "$p" "$map_work" 2>"$relabel_err"); then
355
+ priors=$(jq -c --argjson acc "$priors" --argjson e "$entry" -n '$acc + [$e]')
356
+ else
357
+ local rc=$?
358
+ if [ "$rc" -eq 2 ]; then
359
+ # Prior-round files exist but failed to parse: an integrity problem,
360
+ # not an absent round. Fail loud instead of silently dropping the
361
+ # round — the judge must never be blind to a round without a signal.
362
+ # 65 (EX_DATAERR), matching this script's existing use of 65 for
363
+ # malformed vendor data — deliberately NOT 1, which is the fingerprint
364
+ # abort. Both are hard stops, but "the bundle would have leaked a vendor
365
+ # identity" and "the previous round's sealed data is corrupt" demand
366
+ # different operator responses, and collapsing them into one code left
367
+ # the single most security-relevant stop indistinguishable from a
368
+ # data-integrity stop.
369
+ cat "$relabel_err" >&2
370
+ return 65
371
+ fi
372
+ printf 'anonymize.sh: no prior-round data for round %s, skipping\n' "$p" >&2
373
+ fi
374
+ done
375
+
376
+ local bundle_work="$work/bundle.json"
377
+ jq -n \
378
+ --argjson r "$round" \
379
+ --arg t "$topic" \
380
+ --argjson att "$attachments" \
381
+ --argjson ag "$agenda" \
382
+ --argjson rv "$reviewers" \
383
+ --argjson pr "$priors" \
384
+ '{round: $r, topic: $t, attachments: $att, agenda: $ag, reviewers: $rv, prior_rounds: $pr}' \
385
+ > "$bundle_work"
386
+
387
+ # ---------------------------------------------------------------------------
388
+ # Fingerprint guard scope. Two classes of text reach a LATER round's judge and
389
+ # must therefore be scanned:
390
+ # - REVIEWER-authored: current-round `.reviewers` and
391
+ # `.prior_rounds[].reviewers`
392
+ # - JUDGE-authored: `.prior_rounds[].draft` / `.prior_rounds[].verdict`.
393
+ # The judge is the anonymization SUBJECT, not an anonymized party (spec
394
+ # §8) — but relabel_prior carries its draft/verdict INTO the next round's
395
+ # bundle, where a fresh judge reads it. An attribution the judge wrote
396
+ # ("A는 Claude 계열로 보인다") would otherwise be handed to its successor
397
+ # completely unchecked, which is precisely the leak this guard exists to
398
+ # stop. Being the subject exempts the judge from being anonymized, not
399
+ # from anonymizing others.
400
+ #
401
+ # OPERATOR-authored text stays out of scope: topic/agenda/attachments are the
402
+ # discussion subject ("should we adopt Gemini" is not a leak). The judge may
403
+ # legitimately quote those — verbatim or in fragments — so before scanning,
404
+ # judge-authored fields are reduced to the words that are NOT part of the
405
+ # operator's own vocabulary. Without that, adding draft/verdict to the scope
406
+ # would abort every round whose topic merely names a vendor.
407
+ #
408
+ # The filter works on whole WORDS (tokenize → drop operator words → rejoin),
409
+ # never on substrings. Substring removal would be an evasion channel in
410
+ # reverse: an innocuous operator word like "mini" would carve "gemini" apart
411
+ # and blind the guard. Word filtering can only ever leave MORE text to scan,
412
+ # so it cannot hide a fingerprint the operator did not already introduce.
413
+ # `/`, `-`, `.` and `_` are word characters here, so path forms stay intact
414
+ # (SEALED/mapping/round-1 must remain one token for the path patterns to fire)
415
+ # and version forms ("Opus 4.8") keep their spacing after the rejoin.
416
+ # Residual (deliberate, errs toward blocking): an operator word carrying a
417
+ # Korean particle in the judge's text ("제미나이는" vs the topic's "제미나이")
418
+ # is a different token, so it is NOT exempted and still aborts.
419
+ #
420
+ # Reviewer text is deliberately NOT filtered: reviewers are anonymized parties
421
+ # and echoing the topic back is already treated as a leak today.
422
+ # ---------------------------------------------------------------------------
423
+ local vendor_derived="$work/vendor-derived.json"
424
+ jq -c --arg t "$topic" --argjson ag "$agenda" --argjson att "$attachments" '
425
+ def toks: [scan("[^ \t\n\r,;:!?()\\[\\]{}\"]+")];
426
+ def scrub($ops):
427
+ [ toks[] | . as $w | select(($ops | index($w | ascii_downcase)) == null) ] | join(" ");
428
+ ( ([$t] + [$ag | .. | strings] + [$att | .. | strings])
429
+ | map(toks) | add | map(ascii_downcase) | unique ) as $ops
430
+ | {
431
+ reviewers,
432
+ prior: [
433
+ .prior_rounds[]?
434
+ | {
435
+ reviewers,
436
+ draft: ((.draft // "") | scrub($ops)),
437
+ verdict: ((.verdict // "") | scrub($ops))
438
+ }
439
+ ]
440
+ }
441
+ ' "$bundle_work" > "$vendor_derived"
442
+
443
+ # Exit 1 is reserved for THIS abort — the anonymity break (see the prior-round
444
+ # integrity path above, which returns 65 so the caller can tell "the bundle
445
+ # would have leaked" apart from "the prior round's data is corrupt").
446
+ if ! assert_no_fingerprint "$vendor_derived"; then
447
+ return 1
448
+ fi
449
+
450
+ mkdir -p "$dir/SEALED/mapping" "$dir/anon"
451
+ mv "$map_work" "$dir/SEALED/mapping/round-$round.json"
452
+ mv "$bundle_work" "$dir/anon/round-$round.json"
453
+ return 0
454
+ }
455
+
456
+ main() {
457
+ local mode="${1:---help}"
458
+ case "$mode" in
459
+ --shuffle)
460
+ shift
461
+ local seed="$1"; shift
462
+ shuffle_labels "$seed" "$@"
463
+ ;;
464
+ --shuffle-many)
465
+ shift
466
+ local count="$1"; shift
467
+ local k
468
+ for (( k = 1; k <= count; k++ )); do
469
+ shuffle_labels "agora-shuffle-$k" "$@"
470
+ done
471
+ ;;
472
+ --build)
473
+ shift
474
+ build_bundle "$@"
475
+ ;;
476
+ --help | -h)
477
+ cat <<'USAGE'
478
+ Usage:
479
+ anonymize.sh --shuffle <seed> <vendor_id>...
480
+ anonymize.sh --shuffle-many <count> <vendor_id>...
481
+ anonymize.sh --build --session-dir <dir> --round <N> --seed <seed> \
482
+ --topic <str> --attachments <json-array> --agenda <json-array>
483
+ USAGE
484
+ ;;
485
+ *)
486
+ printf 'anonymize.sh: unknown option %s\n' "$mode" >&2
487
+ return 64
488
+ ;;
489
+ esac
490
+ }
491
+
492
+ main "$@"