oh-my-customcode 1.1.47 → 1.1.49
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.
- package/README.md +7 -6
- package/dist/cli/index.js +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/templates/.claude/agents/agora-runner.md +114 -0
- package/templates/.claude/hooks/scripts/agent-teams-advisor.sh +6 -1
- package/templates/.claude/hooks/scripts/r007-r008-drift-advisor.sh +41 -3
- package/templates/.claude/hooks/scripts/session-env-check.sh +29 -3
- package/templates/.claude/rules/MAY-optimization.md +6 -0
- package/templates/.claude/rules/MUST-agent-design.md +2 -0
- package/templates/.claude/rules/MUST-completion-verification.md +33 -2
- package/templates/.claude/rules/MUST-enforcement-policy.md +3 -3
- package/templates/.claude/rules/MUST-intent-transparency.md +7 -2
- package/templates/.claude/rules/MUST-orchestrator-coordination.md +50 -5
- package/templates/.claude/rules/MUST-parallel-execution.md +56 -7
- package/templates/.claude/rules/MUST-sync-verification.md +21 -2
- package/templates/.claude/rules/MUST-tool-identification.md +22 -4
- package/templates/.claude/skills/agora/SKILL.md +325 -0
- package/templates/.claude/skills/agora/scripts/agora.sh +761 -0
- package/templates/.claude/skills/agora/scripts/anonymize.sh +492 -0
- package/templates/.claude/skills/agora/scripts/judge.sh +427 -0
- package/templates/.claude/skills/agora/scripts/response-schema.json +26 -0
- package/templates/.claude/skills/agora/scripts/reviewers.sh +312 -0
- package/templates/.claude/skills/agora/scripts/verdict-schema.json +34 -0
- package/templates/.claude/skills/hada-scout/SKILL.md +1 -1
- package/templates/.claude/skills/help/SKILL.md +1 -1
- package/templates/.claude/skills/pipeline/workflows/auto-dev.yaml +47 -6
- package/templates/.claude/skills/sauron-watch/SKILL.md +1 -1
- package/templates/.claude/skills/status/SKILL.md +3 -3
- package/templates/.claude/skills/token-efficiency-audit/SKILL.md +1 -1
- package/templates/.github/workflows/wiki-sync.yml +1 -1
- package/templates/CLAUDE.md +3 -3
- package/templates/CLAUDE.md.en +3 -3
- package/templates/CLAUDE.md.ko +3 -3
- package/templates/README.md +5 -5
- package/templates/guides/agent-eval/README.md +1 -1
- package/templates/manifest.json +3 -3
- package/templates/workflows/auto-dev.yaml +47 -6
|
@@ -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 "$@"
|