oh-my-customcode 1.1.48 → 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.
Files changed (36) hide show
  1. package/README.md +7 -6
  2. package/dist/cli/index.js +1 -1
  3. package/dist/index.js +1 -1
  4. package/package.json +1 -1
  5. package/templates/.claude/agents/agora-runner.md +114 -0
  6. package/templates/.claude/hooks/scripts/agent-teams-advisor.sh +6 -1
  7. package/templates/.claude/hooks/scripts/r007-r008-drift-advisor.sh +41 -3
  8. package/templates/.claude/hooks/scripts/session-env-check.sh +29 -3
  9. package/templates/.claude/rules/MAY-optimization.md +4 -0
  10. package/templates/.claude/rules/MUST-agent-design.md +2 -0
  11. package/templates/.claude/rules/MUST-completion-verification.md +25 -0
  12. package/templates/.claude/rules/MUST-enforcement-policy.md +3 -3
  13. package/templates/.claude/rules/MUST-orchestrator-coordination.md +39 -0
  14. package/templates/.claude/rules/MUST-parallel-execution.md +56 -7
  15. package/templates/.claude/rules/MUST-sync-verification.md +16 -0
  16. package/templates/.claude/rules/MUST-tool-identification.md +22 -4
  17. package/templates/.claude/skills/agora/SKILL.md +325 -0
  18. package/templates/.claude/skills/agora/scripts/agora.sh +761 -0
  19. package/templates/.claude/skills/agora/scripts/anonymize.sh +492 -0
  20. package/templates/.claude/skills/agora/scripts/judge.sh +427 -0
  21. package/templates/.claude/skills/agora/scripts/response-schema.json +26 -0
  22. package/templates/.claude/skills/agora/scripts/reviewers.sh +312 -0
  23. package/templates/.claude/skills/agora/scripts/verdict-schema.json +34 -0
  24. package/templates/.claude/skills/hada-scout/SKILL.md +1 -1
  25. package/templates/.claude/skills/help/SKILL.md +1 -1
  26. package/templates/.claude/skills/pipeline/workflows/auto-dev.yaml +21 -3
  27. package/templates/.claude/skills/sauron-watch/SKILL.md +1 -1
  28. package/templates/.claude/skills/status/SKILL.md +3 -3
  29. package/templates/.claude/skills/token-efficiency-audit/SKILL.md +1 -1
  30. package/templates/CLAUDE.md +3 -3
  31. package/templates/CLAUDE.md.en +3 -3
  32. package/templates/CLAUDE.md.ko +3 -3
  33. package/templates/README.md +5 -5
  34. package/templates/guides/agent-eval/README.md +1 -1
  35. package/templates/manifest.json +3 -3
  36. package/templates/workflows/auto-dev.yaml +21 -3
@@ -0,0 +1,761 @@
1
+ #!/usr/bin/env bash
2
+ # agora.sh — entry point, round loop, session state, stop decision.
3
+ # Spec: docs/superpowers/plans/2026-08-15-agora-anonymous-consensus-design.md
4
+ #
5
+ # -e is intentionally NOT set: run_round's exit code (including reviewers.sh
6
+ # /anonymize.sh/judge.sh propagated failures) must be explicitly captured and
7
+ # returned by its callers (start_session's loop, main()'s --round dispatch),
8
+ # not silently abort the whole interpreter mid-function on the first failing
9
+ # command substitution inside run_round/init_session/generate_report.
10
+ set -uo pipefail
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # decide_stop — PURE function (spec §9).
14
+ # Reads a state.json document on stdin, writes exactly one of
15
+ # CONSENSUS | STALLED | MAX_ROUNDS | USER | CONTINUE on stdout.
16
+ # Touches neither the filesystem nor the network so bun test can call it directly.
17
+ # ---------------------------------------------------------------------------
18
+ decide_stop() {
19
+ jq -r '
20
+ def last_round: (.history | length) as $n | if $n == 0 then null else .history[$n - 1] end;
21
+ def quiet($i):
22
+ ($i > 0)
23
+ and (.history[$i].new_findings == 0)
24
+ and (.history[$i].max_severity == .history[$i - 1].max_severity);
25
+
26
+ (.history | length) as $n
27
+ | if (.stop == "USER") then "USER"
28
+ elif ($n > 0
29
+ and (last_round.consensus == "UNANIMOUS")
30
+ and (last_round.verdict == "BUILD" or last_round.verdict == "BUILD_WITH_CHANGES"))
31
+ then "CONSENSUS"
32
+ elif ($n >= 3 and quiet($n - 1) and quiet($n - 2)) then "STALLED"
33
+ elif ($n > 0 and (.round >= .max_rounds)) then "MAX_ROUNDS"
34
+ else "CONTINUE"
35
+ end
36
+ '
37
+ }
38
+ # --- end decide_stop ---
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # stop_vocabulary — the set of codes decide_stop can actually emit, READ OUT OF
42
+ # decide_stop's own body rather than restated here.
43
+ #
44
+ # A hardcoded copy would drift the moment a stop condition is added, renamed or
45
+ # removed, and the drift would be SILENT in the worst direction: --set-stop
46
+ # would keep accepting a code decide_stop no longer produces (so report.md
47
+ # names a reason the engine cannot reach), or reject one it does (so a legit
48
+ # stop cannot be recorded at all). Reading the literals from the function body
49
+ # makes decide_stop the single source of truth for the vocabulary, exactly as
50
+ # it is for the decision itself.
51
+ #
52
+ # Matches only the literals in EMITTING position (`then "X"` / `else "X"`), not
53
+ # every uppercase string in the body — the guard expressions also mention
54
+ # verdict/consensus values (UNANIMOUS, BUILD, BUILD_WITH_CHANGES) which are not
55
+ # stop codes.
56
+ # ---------------------------------------------------------------------------
57
+ stop_vocabulary() {
58
+ declare -f decide_stop \
59
+ | grep -oE '(then|else)[[:space:]]+"[A-Z_]+"' \
60
+ | grep -oE '"[A-Z_]+"' \
61
+ | tr -d '"' \
62
+ | sort -u
63
+ }
64
+
65
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
66
+ REVIEWERS_SH="$SCRIPT_DIR/reviewers.sh"
67
+ ANONYMIZE_SH="$SCRIPT_DIR/anonymize.sh"
68
+ JUDGE_SH="$SCRIPT_DIR/judge.sh"
69
+
70
+ AGORA_OUTPUT_ROOT="${AGORA_OUTPUT_ROOT:-.claude/outputs/sessions}"
71
+
72
+ topic_slug() {
73
+ printf '%s' "$1" | tr -cs '[:alnum:]' '-' | cut -c1-32 | sed 's/-*$//'
74
+ }
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # resolve_abs_dir <dir> — normalizes a session directory to an ABSOLUTE path.
78
+ # The Produces contract requires --start's stdout to carry the session dir's
79
+ # absolute path; AGORA_OUTPUT_ROOT defaults to a RELATIVE path
80
+ # (.claude/outputs/sessions), and agora-runner (Task 8, spec §12) may invoke
81
+ # --round/--gate/--report from a different cwd than the one --start ran in —
82
+ # a relative path threaded through would silently resolve against the wrong
83
+ # cwd there. Every entry point that receives or produces a session dir
84
+ # normalizes it here rather than trusting the caller to have already done so.
85
+ # ---------------------------------------------------------------------------
86
+ resolve_abs_dir() {
87
+ local dir="$1"
88
+ ( cd "$dir" 2>/dev/null && pwd ) || {
89
+ printf 'agora.sh: session dir not found: %s\n' "$dir" >&2
90
+ return 66
91
+ }
92
+ }
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # update_state_json <session_dir> <context> <jq-arg>... — the ONE guarded way
96
+ # this script rewrites state.json.
97
+ #
98
+ # `set -e` is deliberately off in this file (see the header), so the obvious
99
+ # spelling of this operation —
100
+ # jq ... "$dir/state.json" > "$tmp" && mv "$tmp" "$dir/state.json"
101
+ # followed by anything at all — swallows its own failure: a broken jq snaps the
102
+ # && chain and execution simply falls through to the next statement, which in
103
+ # run_round was `return 0`. That reported SUCCESS for a round whose result was
104
+ # never recorded, and every consumer downstream then read a stale document
105
+ # (decide_stop reads .round, so MAX_ROUNDS/STALLED could never fire again; and
106
+ # generate_report reads verdict/round-<stale>.json, so report.md came out
107
+ # empty). state.json is the session's only durable record, so every writer
108
+ # funnels through here rather than repeating — and re-fumbling — the guard.
109
+ #
110
+ # <context> is a short caller tag that names WHICH write failed, since the four
111
+ # callers fail for different reasons and the operator needs to tell them apart.
112
+ #
113
+ # Exit 73 (EX_CANTCREAT, "output file cannot be created") on any failure: the
114
+ # session artifact the caller is about to read could not be written. It is
115
+ # distinct from every code already in use across this skill — 64 usage,
116
+ # 65 vendor id, 66 missing input, 68 judge config, 4 judge CLI, 3 too few
117
+ # reviewers, 2 anonymize mapping, 124 timeout — and all four callers want the
118
+ # orchestrator to react identically: stop, do not consume the artifact, do not
119
+ # re-run the round (the vendors are already billed).
120
+ # ---------------------------------------------------------------------------
121
+ update_state_json() {
122
+ local dir="$1" ctx="$2"; shift 2
123
+
124
+ local tmp
125
+ tmp=$(mktemp) || {
126
+ printf '[agora] %s: could not create a staging file for state.json\n' "$ctx" >&2
127
+ return 73
128
+ }
129
+ # anonymize.sh's convention: the staging file must not outlive this function
130
+ # on ANY path. Without it every failed update leaks a 0-byte file into
131
+ # $TMPDIR (measured), because the `mv` that would have consumed it never ran.
132
+ trap "rm -f '$tmp'" RETURN
133
+
134
+ local rc=0
135
+ jq "$@" "$dir/state.json" > "$tmp" \
136
+ && mv "$tmp" "$dir/state.json" \
137
+ && chmod 644 "$dir/state.json" \
138
+ || rc=$?
139
+
140
+ if [ "$rc" -ne 0 ]; then
141
+ printf '[agora] %s: state.json update FAILED (rc=%s) — %s/state.json is unchanged\n' \
142
+ "$ctx" "$rc" "$dir" >&2
143
+ return 73
144
+ fi
145
+ return 0
146
+ }
147
+
148
+ # ---------------------------------------------------------------------------
149
+ # init_session <topic> <max_rounds> <mode> — creates the session artifact
150
+ # tree (spec §4) and the initial state.json, prints the session dir on
151
+ # stdout. Env overrides: AGORA_SESSION_EPOCH (determinism for tests/audit),
152
+ # AGORA_OUTPUT_ROOT (artifact root, default .claude/outputs/sessions per R006).
153
+ # ---------------------------------------------------------------------------
154
+ init_session() {
155
+ local topic="$1" max_rounds="$2" mode="$3"
156
+ local epoch="${AGORA_SESSION_EPOCH:-$(date +%s)}"
157
+ local day; day=$(date -u +%Y-%m-%d)
158
+ local hms; hms=$(date -u +%H%M%S)
159
+ local dir="$AGORA_OUTPUT_ROOT/$day/agora-$(topic_slug "$topic")-$hms"
160
+
161
+ mkdir -p "$dir/SEALED/raw" "$dir/SEALED/mapping" "$dir/anon" "$dir/verdict"
162
+ dir=$(resolve_abs_dir "$dir") || return 66
163
+ jq -n --argjson mr "$max_rounds" --arg m "$mode" --arg t "$topic" --arg e "$epoch" \
164
+ '{round: 0, max_rounds: $mr, mode: $m, topic: $t, epoch: $e, attachments: [], history: [], stop: null}' \
165
+ > "$dir/state.json"
166
+ chmod 644 "$dir/state.json"
167
+ printf '%s' "$dir"
168
+ }
169
+
170
+ # ---------------------------------------------------------------------------
171
+ # build_reviewer_prompt <session_dir> <round> <out_file> [agenda_json] —
172
+ # spec §10: R1 is a blank-slate independent review (topic + attachments
173
+ # only, no agenda, no prior_rounds, no draft); R2+ injects the previous
174
+ # round's agenda, draft and an anonymized summary of the previous round's
175
+ # opinions.
176
+ #
177
+ # agenda_json is the CALLER's already-combined agenda (judge agenda + any
178
+ # --extra-agenda from the gate's `e` option, spec §10) — this function does
179
+ # NOT re-derive it from verdict/round-N.json itself, so there is exactly one
180
+ # place (run_round) that decides what "this round's agenda" means; a second
181
+ # independent derivation here would drift the moment --extra-agenda exists.
182
+ # ---------------------------------------------------------------------------
183
+ build_reviewer_prompt() {
184
+ local dir="$1" round="$2" out="$3" agenda_json="${4:-[]}"
185
+ local topic; topic=$(jq -r '.topic' "$dir/state.json")
186
+ local attachments; attachments=$(jq -r '.attachments[]?' "$dir/state.json")
187
+
188
+ {
189
+ printf '당신은 익명 상호검증 리뷰어입니다.\n\n'
190
+ printf '주제: %s\n\n' "$topic"
191
+ if [ -n "$attachments" ]; then
192
+ printf '첨부 문서:\n'
193
+ printf '%s\n' "$attachments" | while IFS= read -r a; do
194
+ # An unreadable attachment used to emit its `--- <path> ---` header
195
+ # and then nothing, with no diagnostic anywhere. That is not a silent
196
+ # skip, it is a silent LIE: a heading with nothing under it reads to
197
+ # the reviewer as "this document is empty", not "this document could
198
+ # not be read" — and the two lead to opposite conclusions about the
199
+ # design under review. All three vendors are billed for the round
200
+ # either way, so the wrong reading is paid for at full price.
201
+ #
202
+ # Marked in the prompt AND reported on stderr, because the two
203
+ # channels reach different audiences and neither covers the other:
204
+ # the marker reaches the reviewers, who are the ones actually misled,
205
+ # and it persists in SEALED/raw/round-N.prompt.txt as the durable
206
+ # record of what was really sent; stderr reaches the operator, whose
207
+ # typo it usually is, but scrolls past unseen in an unattended run.
208
+ #
209
+ # Deliberately NOT an abort, unlike start_session's exit 73 when the
210
+ # attachment LIST cannot be recorded. That failure is total (no
211
+ # attachment survives the dropped write, so the round would review
212
+ # nothing) and lands before any vendor runs. This one is per-file and
213
+ # partial: the other attachments are intact, and build_reviewer_prompt
214
+ # runs on EVERY round, so a document moved or deleted mid-session
215
+ # would kill a session at round 4 that has already paid for three
216
+ # rounds of reviewers and judges. Once the reviewer is no longer
217
+ # misinformed, whether a partial document set is still worth reviewing
218
+ # is an operator judgement — made at the gate, with the warning in
219
+ # hand — not an invariant worth destroying a paid-up session over.
220
+ if [ -f "$a" ] && [ -r "$a" ]; then
221
+ printf -- '--- %s ---\n' "$a"
222
+ cat "$a"
223
+ else
224
+ printf -- '--- %s (읽을 수 없어 본문을 싣지 못했습니다 — 내용이 비어 있다는 뜻이 아닙니다) ---\n' "$a"
225
+ printf '[agora] attachment could not be read; its body is NOT in the reviewer prompt: %s\n' "$a" >&2
226
+ fi
227
+ done
228
+ printf '\n'
229
+ fi
230
+ # spec §10: round 1 is the only genuinely independent review — no frame injected.
231
+ if [ "$round" -gt 1 ]; then
232
+ local prev=$(( round - 1 ))
233
+ printf '직전 라운드 의제:\n'
234
+ jq -r '.[]? | " - " + .' <<< "$agenda_json"
235
+ printf '\n직전 라운드 통합 초안:\n%s\n\n' "$(jq -r '.draft // ""' "$dir/verdict/round-$prev.json")"
236
+ printf '직전 라운드 익명 의견 요약:\n%s\n\n' "$(jq -c '.reviewers | map({label, overall})' "$dir/anon/round-$prev.json")"
237
+ fi
238
+ printf '아래 JSON 스키마를 정확히 따르는 JSON 객체 하나만 출력하십시오.\n'
239
+ printf '"counter" 는 자기 주장에 대한 반론이며 빈 문자열이 될 수 없습니다.\n\n'
240
+ cat "$SCRIPT_DIR/response-schema.json"
241
+ } > "$out"
242
+ }
243
+
244
+ # ---------------------------------------------------------------------------
245
+ # run_round --session-dir <dir> --round <N> [--extra-agenda <json-array>] —
246
+ # the agora-runner delegation unit (spec §12: "라운드 1개 = 위임 1건"). Drives
247
+ # reviewers.sh → anonymize.sh → judge.sh for exactly one round, then updates
248
+ # state.json.
249
+ #
250
+ # judge.sh's exit 68 is a CONFIGURATION error (its own verdict-schema.json is
251
+ # unreadable) — NOT a vendor/CLI failure like exit 4 (every rotation model
252
+ # failed). It is consumed here as an immediate hard stop with its own
253
+ # diagnostic line and is never retried: retrying would fail identically all
254
+ # three times (the schema file does not become readable by trying again) and
255
+ # would only burn wall-clock. Every other non-zero code from
256
+ # reviewers.sh/anonymize.sh/judge.sh is propagated as-is.
257
+ #
258
+ # No path here is ever exported — reviewers.sh/anonymize.sh/judge.sh receive
259
+ # every path they need as an explicit CLI flag, never via inherited
260
+ # environment, so judge.sh's "no route back to who said what" claim (spec §4)
261
+ # is not undermined by this loop leaking a sealed-path env var to it.
262
+ #
263
+ # --extra-agenda is the gate's `e` option (spec §10): user agenda items are
264
+ # APPENDED after the judge's own agenda, never overwriting it — the judge's
265
+ # REQ-6 agenda-setting authority must survive a user append. Concatenation
266
+ # happens ONCE here and the combined array is threaded into BOTH the
267
+ # reviewer prompt (build_reviewer_prompt) and anonymize.sh's --agenda (which
268
+ # becomes what the judge itself reads next round) — a single source of truth
269
+ # for "this round's agenda", not two independent re-derivations that could
270
+ # drift from each other.
271
+ # ---------------------------------------------------------------------------
272
+ run_round() {
273
+ local dir='' round='' extra_agenda='[]'
274
+ while [ "$#" -gt 0 ]; do
275
+ case "$1" in
276
+ --session-dir) dir="$2"; shift 2 ;;
277
+ --round) round="$2"; shift 2 ;;
278
+ --extra-agenda) extra_agenda="$2"; shift 2 ;;
279
+ *) printf 'agora.sh: unknown round option %s\n' "$1" >&2; return 64 ;;
280
+ esac
281
+ done
282
+ [ -n "$dir" ] && [ -n "$round" ] || { printf 'agora.sh: --session-dir and --round required\n' >&2; return 64; }
283
+
284
+ # --extra-agenda must be a JSON array BEFORE any reviewer/vendor is
285
+ # invoked. Left unvalidated, a malformed value (the gate's `e` option is
286
+ # free text assembled by the caller into JSON — one stray quote breaks it)
287
+ # makes the `jq -c --argjson extra ... '. + $extra'` combine below fail,
288
+ # collapsing agenda to an empty string; build_reviewer_prompt then silently
289
+ # renders a blank agenda section, all three vendors still get called (and
290
+ # billed), and only anonymize.sh's later `--agenda ''` failure surfaces the
291
+ # problem — after the cost was already paid. Fail fast here instead.
292
+ if ! jq -e 'type == "array"' >/dev/null 2>&1 <<< "$extra_agenda"; then
293
+ printf 'agora.sh: --extra-agenda must be a JSON array, got: %s\n' "$extra_agenda" >&2
294
+ return 64
295
+ fi
296
+
297
+ dir=$(resolve_abs_dir "$dir") || return 66
298
+
299
+ local round_start; round_start=$(date +%s)
300
+
301
+ local epoch; epoch=$(jq -r '.epoch' "$dir/state.json")
302
+ local topic; topic=$(jq -r '.topic' "$dir/state.json")
303
+ local attachments; attachments=$(jq -c '.attachments' "$dir/state.json")
304
+ local agenda='[]'
305
+ if [ "$round" -gt 1 ]; then
306
+ agenda=$(jq -c '.agenda // []' "$dir/verdict/round-$(( round - 1 )).json")
307
+ fi
308
+ agenda=$(jq -c --argjson extra "$extra_agenda" '. + $extra' <<< "$agenda")
309
+
310
+ local prompt_file="$dir/SEALED/raw/round-$round.prompt.txt"
311
+ mkdir -p "$dir/SEALED/raw/round-$round"
312
+ build_reviewer_prompt "$dir" "$round" "$prompt_file" "$agenda"
313
+
314
+ bash "$REVIEWERS_SH" --run --session-dir "$dir" --round "$round" --prompt-file "$prompt_file" || return $?
315
+
316
+ bash "$ANONYMIZE_SH" --build \
317
+ --session-dir "$dir" --round "$round" --seed "agora-$epoch-r$round" \
318
+ --topic "$topic" --attachments "$attachments" --agenda "$agenda" || return $?
319
+
320
+ bash "$JUDGE_SH" --run \
321
+ --anon-file "$dir/anon/round-$round.json" \
322
+ --out-file "$dir/verdict/round-$round.json" \
323
+ --round "$round"
324
+ local judge_rc=$?
325
+ if [ "$judge_rc" -eq 68 ]; then
326
+ printf '[agora] judge.sh reported a configuration error (verdict schema unreadable) for round %s — aborting, not retrying\n' \
327
+ "$round" >&2
328
+ return 68
329
+ elif [ "$judge_rc" -ne 0 ]; then
330
+ return "$judge_rc"
331
+ fi
332
+
333
+ local round_end; round_end=$(date +%s)
334
+ local elapsed=$(( round_end - round_start ))
335
+
336
+ local v="$dir/verdict/round-$round.json"
337
+ local max_sev
338
+ max_sev=$(jq -r '
339
+ ([.unresolved[]?.severity] | map({CRITICAL:4, HIGH:3, MEDIUM:2, LOW:1}[.] // 0) | max // 0) as $m
340
+ | if $m == 4 then "CRITICAL" elif $m == 3 then "HIGH"
341
+ elif $m == 2 then "MEDIUM" elif $m == 1 then "LOW" else "NONE" end' "$v")
342
+
343
+ # By the time we get here the three vendors AND the judge have already run
344
+ # and been billed, so a silently-dropped write is the most expensive failure
345
+ # in this script — see update_state_json for why the bare && chain that used
346
+ # to live here reported success for a round it never recorded.
347
+ #
348
+ # new_findings is the realistic trigger: it reaches --argjson as raw model
349
+ # output, so a judge that answers "three" instead of 3 breaks the write.
350
+ # judge.sh's validate_verdict now type-checks this field against
351
+ # verdict-schema.json before any verdict file is written, so a wrong-typed
352
+ # value should not reach here at all — this guard is the layer BEHIND that
353
+ # one, and it stays because it covers the two ways that check stops
354
+ # applying: verdict-schema.json loosening new_findings' declared type (the
355
+ # check is schema-driven, so it would silently stop testing it without any
356
+ # code change), and a verdict file arriving by some route other than
357
+ # judge.sh --run. It is extracted first purely so the failure diagnostic
358
+ # can quote the offending value.
359
+ local nf; nf=$(jq -r '.new_findings' "$v")
360
+
361
+ update_state_json "$dir" "round $round" \
362
+ --argjson r "$round" \
363
+ --arg verdict "$(jq -r '.verdict' "$v")" \
364
+ --arg consensus "$(jq -r '.consensus' "$v")" \
365
+ --argjson nf "$nf" \
366
+ --arg sev "$max_sev" \
367
+ --argjson elapsed "$elapsed" \
368
+ '.round = $r
369
+ | .history += [{round: $r, verdict: $verdict, consensus: $consensus,
370
+ new_findings: $nf, max_severity: $sev, tokens: 0, elapsed_secs: $elapsed}]' \
371
+ || {
372
+ printf '[agora] round %s ran but was NOT recorded (reviewers and judge are already billed). Most likely cause: %s carries a non-numeric "new_findings" (observed: %s). Re-running this round would bill the vendors again.\n' \
373
+ "$round" "$v" "$nf" >&2
374
+ return 73
375
+ }
376
+ return 0
377
+ }
378
+
379
+ # ---------------------------------------------------------------------------
380
+ # gate_display <session_dir> <round> — spec §10 gate block. Labels only,
381
+ # vendors are never named here (they surface for the first time in
382
+ # report.md — spec §4's single anonymity-lifting point). Also surfaces the
383
+ # round's wall-clock duration: judge rotation alone is a worst case of
384
+ # ~15 minutes (3 slots x AGORA_TIMEOUT_SECS default 300s), on top of the
385
+ # reviewer fan-out, so the user should not be left blind to how long a round
386
+ # actually took even though this script does not enforce a round-level
387
+ # timeout policy of its own.
388
+ # ---------------------------------------------------------------------------
389
+ gate_display() {
390
+ local dir="$1" round="$2"
391
+ dir=$(resolve_abs_dir "$dir") || return 66
392
+ local v="$dir/verdict/round-$round.json"
393
+ local a="$dir/anon/round-$round.json"
394
+ local max_rounds; max_rounds=$(jq -r '.max_rounds' "$dir/state.json")
395
+ local elapsed
396
+ elapsed=$(jq -r --argjson r "$round" '.history[] | select(.round == $r) | (.elapsed_secs // 0)' "$dir/state.json")
397
+
398
+ printf -- '─── Agora Round %s/%s ───────────────────────────────\n' "$round" "$max_rounds"
399
+ printf '심판: (모델 로테이션 #%s)\n' "$(( (round - 1) % 3 + 1 ))"
400
+ printf 'Consensus: %-16s Verdict: %s\n' "$(jq -r '.consensus' "$v")" "$(jq -r '.verdict' "$v")"
401
+ printf '리뷰어: %s\n' "$(jq -r '[.reviewers[] | .label + " " + .overall] | join(" · ")' "$a")"
402
+ printf '신규 지적: %s건 최고 심각도: %s\n' \
403
+ "$(jq -r '.new_findings' "$v")" \
404
+ "$(jq -r --argjson r "$round" '.history[] | select(.round == $r) | .max_severity' "$dir/state.json")"
405
+ printf '라운드 소요: %s초 (참고: 심판 로테이션 3슬롯 순차 재시도 시 최악 약 15분)\n' "$elapsed"
406
+ printf '\n해소됨(%s)\n' "$(jq -r '.resolved | length' "$v")"
407
+ jq -r '.resolved[]? | " " + .id + " " + .resolution' "$v"
408
+ printf '\n미해소(%s)\n' "$(jq -r '.unresolved | length' "$v")"
409
+ jq -r '.unresolved[]? | " " + .id + " [" + .severity + "] " + .positions' "$v"
410
+ printf '\n다음 라운드 의제\n'
411
+ jq -r '.agenda[]? | " - " + .' "$v"
412
+ printf '\n[c] 계속 [s] 중단하고 보고서 [e] 의제 추가 후 계속\n'
413
+ }
414
+
415
+ # ---------------------------------------------------------------------------
416
+ # record_stop <session_dir> <code> — writes .stop, the field report.md prints
417
+ # as "종료 사유".
418
+ #
419
+ # Without this, .stop had no reachable writer on the gated path at all, and
420
+ # gated is the PRIMARY path (spec §12: one round = one agora-runner
421
+ # delegation; --auto is the discouraged, token-expensive alternative). The two
422
+ # existing writers were both inside start_session, which in gated mode returns
423
+ # after round 1 — so every session the orchestrator drove to a normal
424
+ # CONSENSUS / STALLED / MAX_ROUNDS ending, and every user `s` at the gate,
425
+ # landed on generate_report's `.stop // "UNKNOWN"` fallback and reported
426
+ # 종료 사유: UNKNOWN. decide_stop could NAME the ending; nothing could record it.
427
+ #
428
+ # The accepted vocabulary comes from stop_vocabulary(), i.e. out of
429
+ # decide_stop's own body — never a second hardcoded list here.
430
+ #
431
+ # CONTINUE is rejected on purpose even though decide_stop emits it: it is the
432
+ # "no stop happened" sentinel, so persisting it into .stop would assert an
433
+ # ending that did not occur, and report.md would print 종료 사유: CONTINUE for a
434
+ # session that is still open. A caller that sees CONTINUE should run another
435
+ # round, not record it.
436
+ #
437
+ # Deliberately session-scoped (no --round): .stop describes HOW the session
438
+ # ended, .round already records WHERE. Writing is idempotent, so a re-issued
439
+ # code is harmless.
440
+ # ---------------------------------------------------------------------------
441
+ record_stop() {
442
+ local dir="$1" code="$2"
443
+ local vocab; vocab=$(stop_vocabulary)
444
+ local settable; settable=$(grep -vx 'CONTINUE' <<< "$vocab")
445
+
446
+ if [ "$code" = 'CONTINUE' ]; then
447
+ printf 'agora.sh: CONTINUE means the session has NOT stopped — it cannot be recorded as .stop. Run another round instead.\n' >&2
448
+ return 64
449
+ fi
450
+ if ! grep -qx -- "$code" <<< "$vocab"; then
451
+ printf 'agora.sh: unknown stop code %s — accepted: %s\n' \
452
+ "$code" "$(printf '%s' "$settable" | tr '\n' ' ')" >&2
453
+ return 64
454
+ fi
455
+
456
+ dir=$(resolve_abs_dir "$dir") || return 66
457
+ update_state_json "$dir" "stop=$code" --arg s "$code" '.stop = $s' \
458
+ || {
459
+ printf '[agora] the session ending (%s) could not be recorded — report.md would say UNKNOWN, so do not generate it yet\n' \
460
+ "$code" >&2
461
+ return 73
462
+ }
463
+ return 0
464
+ }
465
+
466
+ # ---------------------------------------------------------------------------
467
+ # generate_report <session_dir> — spec §4: the ONE place anonymity is
468
+ # lifted. Reads SEALED/mapping/round-N.json (never read by any other
469
+ # function in this script apart from report generation). Staged to a temp
470
+ # file and moved into place only once fully assembled, so a mid-write
471
+ # failure never leaves a partial report.md at the final path.
472
+ # ---------------------------------------------------------------------------
473
+ generate_report() {
474
+ local dir="$1"
475
+ dir=$(resolve_abs_dir "$dir") || return 66
476
+ local out="$dir/report.md"
477
+ local stop; stop=$(jq -r '.stop // "UNKNOWN"' "$dir/state.json")
478
+ local last; last=$(jq -r '.round' "$dir/state.json")
479
+
480
+ local tmp
481
+ tmp=$(mktemp) || {
482
+ printf '[agora] report: could not create a staging file for report.md\n' >&2
483
+ return 73
484
+ }
485
+ trap "rm -f '$tmp'" RETURN
486
+
487
+ {
488
+ printf '# Agora 합의 보고서\n\n'
489
+ printf -- '- 주제: %s\n' "$(jq -r '.topic' "$dir/state.json")"
490
+ printf -- '- 종료 사유: %s\n' "$stop"
491
+ printf -- '- 총 라운드: %s\n\n' "$last"
492
+ if [ "$stop" = "MAX_ROUNDS" ]; then
493
+ printf '> **합의 없음 · 분기 결정 필요** — 상한 도달로 종료되었습니다.\n\n'
494
+ elif [ "$stop" = "STALLED" ]; then
495
+ printf '> **정체로 조기 종료** — 잔존 쟁점을 그대로 보고하며 결론을 강제하지 않습니다.\n\n'
496
+ fi
497
+
498
+ printf '## 최종 통합 초안\n\n%s\n\n' "$(jq -r '.draft // ""' "$dir/verdict/round-$last.json")"
499
+
500
+ printf '## 라운드별 참여자 (익명 해제)\n\n'
501
+ local n
502
+ for (( n = 1; n <= last; n++ )); do
503
+ printf '### 라운드 %s\n\n' "$n"
504
+ jq -r '.map | to_entries[] | "- " + .key + " → " + .value' "$dir/SEALED/mapping/round-$n.json"
505
+ printf '\n'
506
+ done
507
+
508
+ printf '## 잔존 쟁점\n\n'
509
+ jq -r '.unresolved[]? | "- " + .id + " [" + .severity + "] " + .positions' "$dir/verdict/round-$last.json"
510
+ printf '\n'
511
+ } > "$tmp"
512
+
513
+ # The report is the session's deliverable and the ONE artifact that lifts
514
+ # anonymity — a dropped move here left the caller believing a report existed
515
+ # at "$out" when nothing had been written, which is exactly the class of
516
+ # silent failure that made the round-state bug expensive. Exit 73 for the
517
+ # same reason as update_state_json: the output file could not be created.
518
+ local rc=0
519
+ mv "$tmp" "$out" && chmod 644 "$out" || rc=$?
520
+ if [ "$rc" -ne 0 ]; then
521
+ printf '[agora] report: could not write %s (rc=%s) — no report was produced; the session dir may be read-only\n' \
522
+ "$out" "$rc" >&2
523
+ return 73
524
+ fi
525
+ return 0
526
+ }
527
+
528
+ # ---------------------------------------------------------------------------
529
+ # start_session <topic> [--attach <path>]... [--max-rounds <N>] [--auto]
530
+ # Creates the session, then drives the round loop:
531
+ # - mode auto: runs rounds until decide_stop() says stop, then reports.
532
+ # - mode gated: runs exactly ONE round, prints the gate, and returns — the
533
+ # orchestrator drives subsequent rounds via `--round <N> --session-dir`.
534
+ # ---------------------------------------------------------------------------
535
+ start_session() {
536
+ local topic='' max_rounds=5 mode='gated'
537
+ local attachments=()
538
+ topic="$1"; shift
539
+ while [ "$#" -gt 0 ]; do
540
+ case "$1" in
541
+ --attach) attachments+=("$2"); shift 2 ;;
542
+ --max-rounds) max_rounds="$2"; shift 2 ;;
543
+ --auto) mode='auto'; shift ;;
544
+ *) printf 'agora.sh: unknown start option %s\n' "$1" >&2; return 64 ;;
545
+ esac
546
+ done
547
+
548
+ # Declared first, assigned second, on purpose: `local dir=$(init_session ...)`
549
+ # would mask init_session's exit status behind `local`'s own (always 0), so a
550
+ # session dir that could not be created would sail on as an empty string and
551
+ # surface later as a baffling error against "/state.json".
552
+ local dir
553
+ dir=$(init_session "$topic" "$max_rounds" "$mode") || return $?
554
+ if [ "${#attachments[@]}" -gt 0 ]; then
555
+ local att_json; att_json=$(printf '%s\n' "${attachments[@]}" | jq -R . | jq -sc .)
556
+ # Hard-fail rather than proceed: build_reviewer_prompt reads .attachments
557
+ # from state.json, so a dropped write here does not merely lose metadata —
558
+ # it silently sends all three vendors a review WITHOUT the documents the
559
+ # user attached, and bills the round for reviewing the wrong material.
560
+ # Better to abort before any vendor is invoked.
561
+ update_state_json "$dir" 'attachments' --argjson a "$att_json" '.attachments = $a' \
562
+ || {
563
+ printf '[agora] attachments were not recorded — aborting before any round runs, since reviewers would otherwise be billed for a review with no attachments\n' >&2
564
+ return 73
565
+ }
566
+ fi
567
+
568
+ local round=1 stop='CONTINUE' rc
569
+ while [ "$round" -le "$max_rounds" ]; do
570
+ rc=0
571
+ run_round --session-dir "$dir" --round "$round" || rc=$?
572
+ [ "$rc" -eq 0 ] || return "$rc"
573
+
574
+ stop=$(decide_stop < "$dir/state.json")
575
+ if [ "$stop" != "CONTINUE" ]; then
576
+ # Same guard as every other .stop writer (record_stop): if this write is
577
+ # dropped, the loop still breaks and generate_report still runs — and the
578
+ # report would name the ending UNKNOWN while claiming to be complete.
579
+ update_state_json "$dir" "stop=$stop" --arg s "$stop" '.stop = $s' \
580
+ || {
581
+ printf '[agora] the session ended as %s but that could not be recorded — refusing to generate a report that would name the reason UNKNOWN\n' \
582
+ "$stop" >&2
583
+ return 73
584
+ }
585
+ break
586
+ fi
587
+
588
+ # In gated mode the orchestrator drives the gate; the script yields after one round.
589
+ #
590
+ # The gate goes to STDERR, not stdout: the Produces contract (see
591
+ # resolve_abs_dir) reserves --start's stdout for the session dir alone, and
592
+ # the documented `dir=$(agora.sh --start ...)` idiom captures stdout
593
+ # wholesale — with the gate on stdout, $dir came back as 17 lines of gate
594
+ # block with the path glued on the end (measured), i.e. not a usable path.
595
+ # stderr also keeps the block visible when --start is delegated to
596
+ # agora-runner, whose structured return value has no field to carry it.
597
+ # The standalone --gate subcommand keeps its block on STDOUT by contrast:
598
+ # rendering the gate is that subcommand's entire product, not a side
599
+ # channel next to a machine-read value.
600
+ if [ "$mode" = 'gated' ]; then
601
+ gate_display "$dir" "$round" >&2
602
+ printf '%s\n' "$dir"
603
+ return 0
604
+ fi
605
+ round=$(( round + 1 ))
606
+ done
607
+
608
+ # Propagate, do not swallow: printing the session dir and returning 0 after a
609
+ # failed report is the same "success reported for work that did not happen"
610
+ # shape as the round-state bug — the caller would go read a report.md that
611
+ # was never written.
612
+ generate_report "$dir" || return $?
613
+ printf '%s\n' "$dir"
614
+ return 0
615
+ }
616
+
617
+ main() {
618
+ case "${1:---help}" in
619
+ --decide-stop)
620
+ decide_stop
621
+ ;;
622
+ --start)
623
+ shift
624
+ start_session "$@"
625
+ ;;
626
+ --round)
627
+ # CLI syntax is documented (Produces contract) as
628
+ # `agora.sh --round <N> --session-dir <dir>`: the round number is the
629
+ # very next token after --round, not a `--round <N>` flag pair at this
630
+ # level. Reconstruct it into the --round/--session-dir flag pair
631
+ # run_round's own parser expects, in either order the caller supplied
632
+ # the remaining flags.
633
+ shift
634
+ local round_n="${1:-}"; shift || true
635
+ run_round --round "$round_n" "$@"
636
+ ;;
637
+ --gate)
638
+ shift
639
+ local gd='' gr=''
640
+ while [ "$#" -gt 0 ]; do
641
+ case "$1" in
642
+ --session-dir) gd="$2"; shift 2 ;;
643
+ --round) gr="$2"; shift 2 ;;
644
+ *) printf 'agora.sh: unknown gate option %s\n' "$1" >&2; return 64 ;;
645
+ esac
646
+ done
647
+ [ -n "$gd" ] && [ -n "$gr" ] || {
648
+ printf 'agora.sh: --gate requires --session-dir and --round\n' >&2
649
+ return 64
650
+ }
651
+ gate_display "$gd" "$gr"
652
+ ;;
653
+ --set-stop)
654
+ # `--set-stop <CODE> --session-dir <dir>` mirrors --round's shape: the
655
+ # value is the token right after the subcommand, not a flag pair.
656
+ shift
657
+ local sc="${1:-}"
658
+ case "$sc" in
659
+ ''|--*)
660
+ printf 'agora.sh: --set-stop requires a stop code, e.g. --set-stop CONSENSUS --session-dir <dir>\n' >&2
661
+ return 64
662
+ ;;
663
+ esac
664
+ shift
665
+ local sd=''
666
+ while [ "$#" -gt 0 ]; do
667
+ case "$1" in
668
+ --session-dir) sd="$2"; shift 2 ;;
669
+ *) printf 'agora.sh: unknown set-stop option %s\n' "$1" >&2; return 64 ;;
670
+ esac
671
+ done
672
+ [ -n "$sd" ] || {
673
+ printf 'agora.sh: --set-stop requires --session-dir\n' >&2
674
+ return 64
675
+ }
676
+ record_stop "$sd" "$sc"
677
+ ;;
678
+ --report)
679
+ shift
680
+ local rd=''
681
+ while [ "$#" -gt 0 ]; do
682
+ case "$1" in
683
+ --session-dir) rd="$2"; shift 2 ;;
684
+ *) printf 'agora.sh: unknown report option %s\n' "$1" >&2; return 64 ;;
685
+ esac
686
+ done
687
+ [ -n "$rd" ] || { printf 'agora.sh: --report requires --session-dir\n' >&2; return 64; }
688
+ generate_report "$rd"
689
+ ;;
690
+ --help | -h)
691
+ cat <<'USAGE'
692
+ Usage:
693
+ agora.sh --decide-stop Read state.json on stdin, print the stop code.
694
+ agora.sh --start "<topic>" [--attach <path>]... [--max-rounds <N>] [--auto]
695
+ Create a session and drive the round loop.
696
+ agora.sh --round <N> --session-dir <dir> [--extra-agenda <json-array>]
697
+ Run exactly one round (agora-runner delegation unit).
698
+ --extra-agenda is the gate's `e` option (spec §10):
699
+ it is APPENDED after the judge's own agenda, never
700
+ overwriting it.
701
+ agora.sh --gate --session-dir <dir> --round <N> Render the label-only gate block for a round.
702
+ agora.sh --set-stop <CODE> --session-dir <dir> Record HOW the session ended, into .stop.
703
+ <CODE> is one of decide_stop's own stop codes —
704
+ CONSENSUS, STALLED, MAX_ROUNDS, USER (the gate's
705
+ `s` choice). CONTINUE is rejected: it means the
706
+ session has NOT stopped. The accepted set is read
707
+ from decide_stop at runtime, and an invalid code
708
+ prints it.
709
+ agora.sh --report --session-dir <dir> Regenerate report.md from the sealed mapping.
710
+
711
+ Which entry points WRITE (delegate these to agora-runner, spec §12):
712
+ --start, --round, --set-stop, --report all write into the session dir.
713
+ --decide-stop and --gate are read-only. --set-stop in particular mutates
714
+ state.json, so the orchestrator does not run it directly.
715
+
716
+ Channel contract:
717
+ --start prints ONLY the session dir on stdout (`dir=$(agora.sh --start ...)`
718
+ is the intended idiom); its gate block goes to stderr. The standalone --gate
719
+ subcommand prints its block on stdout, since that block is its whole product.
720
+
721
+ Gated-mode contract (spec §12, "라운드 1개 = 위임 1건"):
722
+ --start WITHOUT --auto runs exactly ROUND 1 and returns — it does not loop
723
+ further within that one invocation. What it does next depends on round 1's
724
+ OWN stop decision (measured behavior, not caller-driven in this branch):
725
+ - If round 1 already satisfies a stop condition (CONSENSUS / STALLED /
726
+ MAX_ROUNDS / USER), --start writes .stop and generates report.md
727
+ ITSELF before returning — the gate is NOT rendered in this case.
728
+ - Otherwise --start renders the round-1 gate and returns WITHOUT writing
729
+ .stop or generating report.md.
730
+ Every round after round 1 is driven by the CALLER (agora-runner / the
731
+ orchestrator), one round per delegation, through the standalone --round
732
+ entry point:
733
+ 1. bash agora.sh --round <N> --session-dir <dir> Advance one round.
734
+ 2. bash agora.sh --decide-stop < state.json Check the stop code yourself.
735
+ 3. bash agora.sh --gate --session-dir <dir> --round <N>
736
+ Show the round's gate (skip if you already stopped).
737
+ 4. bash agora.sh --set-stop <CODE> --session-dir <dir>
738
+ REQUIRED whenever step 2 returned anything other
739
+ than CONTINUE: record that exact code before
740
+ reporting. Also used with USER when the user picks
741
+ `s` at the gate. .stop has no other writer on this
742
+ path, so skipping it makes report.md say
743
+ 종료 사유: UNKNOWN no matter how cleanly the
744
+ session actually ended.
745
+ 5. bash agora.sh --report --session-dir <dir> Generate report.md once you decide to stop.
746
+ --round on its own NEVER calls decide_stop, NEVER writes .stop, NEVER
747
+ renders a gate, and NEVER generates report.md — not even when <N> reaches
748
+ or exceeds max_rounds (--round does not check max_rounds at all). Those
749
+ steps above are the caller's responsibility for every round after round 1
750
+ in gated mode. (--start --auto performs all of this internally, across
751
+ every round, and needs none of the above.)
752
+ USAGE
753
+ ;;
754
+ *)
755
+ printf 'agora.sh: unknown option %s\n' "$1" >&2
756
+ return 64
757
+ ;;
758
+ esac
759
+ }
760
+
761
+ main "$@"