@tachikomagundam/abathur 0.2.4 → 0.2.5

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.
@@ -138,16 +138,35 @@ if (APPLICABLE[scenarioNo] !== undefined) {
138
138
  }
139
139
  const sandboxRows = state.post
140
140
  .filter((r) => isSandboxPath(r.path))
141
- .map((r) => ({ path: r.path, id: String(r.id), description: String(r.description ?? "") }));
141
+ .map((r) => ({ path: r.path, locale: String(r.locale ?? "en"), id: String(r.id), description: String(r.description ?? "") }));
142
142
  obs.tools = scanToolEvents(transcriptText);
143
- obs.integrity = {
143
+ const integrity = {
144
144
  sandboxRows,
145
145
  content: state.content,
146
146
  rowIdByPath: new Map(sandboxRows.map((r) => [r.path, r.id])),
147
+ rowIdByLocalePath: new Map(sandboxRows.map((r) => [`${r.path}\u0000${r.locale}`, r.id])),
147
148
  descByPath: new Map(sandboxRows.map((r) => [r.path, r.description])),
148
149
  seedDescByPath: new Map(seed.rows.map((r) => [r.path, String(r.description ?? "")])),
150
+ seedRowIdByLocalePath: new Map(seed.rows.map((r) => [`${String(r.path)}\u0000${String(r.locale ?? "en")}`, String(r.id)])),
149
151
  seedContent: Object.fromEntries(seed.rows.map((r) => [String(r.id), seed.content[String(r.id)] ?? ""])),
150
152
  };
153
+ // s17 (蜂判首卷): the run-stage poststage's instrument output is OBSERVABLE
154
+ // STATE at .bench/judge-verdicts.json (same flow as .bench/transcripts).
155
+ // Absent ⇒ undefined (branch 17 fails the J leg closed with an explicit note);
156
+ // unparseable ⇒ {parseError}. Never thrown here — scoring stays deterministic
157
+ // given inputs, and an honest 0 beats a vacuous crash-to-inconclusive.
158
+ if (scenarioNo === 17) {
159
+ const verdictsPath = path.join(process.cwd(), ".bench", "judge-verdicts.json");
160
+ try {
161
+ integrity.judgeVerdicts = JSON.parse(readFileSync(verdictsPath, "utf8"));
162
+ } catch (cause) {
163
+ const code = cause && typeof cause === "object" ? String(cause.code ?? "") : "";
164
+ integrity.judgeVerdicts = code === "ENOENT"
165
+ ? undefined
166
+ : { parseError: cause instanceof Error ? cause.message : String(cause) };
167
+ }
168
+ }
169
+ obs.integrity = integrity;
151
170
  }
152
171
 
153
172
  const result = scoreUnit(obs);
@@ -0,0 +1,200 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * judge-poststage.mjs — scenario-17 蜂判 run-stage poststage (deterministic
4
+ * plumbing, zero deps, argv-only spawns).
5
+ *
6
+ * Doctrine binding (historian .omo/evidence/good-wiki-readability-doctrine-FINAL.md):
7
+ * 总则2 [蜂判] = 传感器:盲评双跑、分歧仲裁、一致率入法官健康台账。
8
+ * 总则4 grader 只算结构/状态/工具事件;语义一律蜂判 ⇒ the LLM calls live
9
+ * HERE (run stage), never inside the shipped deterministic grader.
10
+ * B4 §4 arbitration = fresh isolated third run on the SAME (rubric, page),
11
+ * appended as rep3; majority 2/3; unresolved never agrees.
12
+ *
13
+ * Flow: materialize the created dossier pair (live wiki fetch by (path, locale)
14
+ * — read-only — or --files locale=path for offline fixture runs) → invoke
15
+ * judge-bench.mjs matrix (the certified instrument, imported by CLI spawn, NOT
16
+ * re-implemented) → map ledger rows onto {page, locale} → for every agree=false
17
+ * row, one `pair --reps 1` arbitration run appended as rep3 → fold the majority
18
+ * per row → write .bench/judge-verdicts.json (grader consumes it as observable
19
+ * state; s17FoldJudgeVerdicts re-derives everything fail-closed).
20
+ *
21
+ * Usage:
22
+ * node judge-poststage.mjs --page _sandbox/eval17/gpu-warm-pool-dossier \
23
+ * --wiki-base http://localhost:3000 [--out .bench/judge-verdicts.json] \
24
+ * [--ledger .bench/judge-ledger.jsonl] [--reps 2] [--sleep-s 2] \
25
+ * [--timeout-s 240] [--model local-qwen/qwen3.8-flash-next] \
26
+ * [--rubrics a.md,b.md,c.md] [--bench judge-bench.mjs]
27
+ * node judge-poststage.mjs --page <label> --files en=g.md,zh=d.md [...]
28
+ */
29
+ import { createHash } from "node:crypto";
30
+ import { execFileSync } from "node:child_process";
31
+ import { appendFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
32
+ import os from "node:os";
33
+ import path from "node:path";
34
+
35
+ // D7 zero-literals: the bee-judge bench lives outside the repo (campaign
36
+ // evidence dir); operators wire it via ABATHUR_JUDGE_BENCH. Unset => the
37
+ // judge stage fails closed BEFORE spawn and the grader fail-closes the legs.
38
+ const JUDGE_BENCH = process.env.ABATHUR_JUDGE_BENCH ?? null;
39
+ const DEFAULTS = {
40
+ bench: JUDGE_BENCH ?? "",
41
+ rubrics: JUDGE_BENCH === null ? [] : ["R1-semantic", "R4-duty-v2", "R5-flavor"].map(
42
+ (r) => path.join(path.dirname(JUDGE_BENCH), "rubrics", `${r}.md`),
43
+ ),
44
+ model: "local-qwen/qwen3.8-flash-next",
45
+ };
46
+
47
+ function parseArgs(argv) {
48
+ const o = { ...DEFAULTS, out: path.join(".bench", "judge-verdicts.json"), ledger: null, reps: 2, sleepS: 2, timeoutS: 240, page: null, wikiBase: null, files: null };
49
+ for (let i = 0; i < argv.length; i += 1) {
50
+ const a = argv[i];
51
+ const need = () => { i += 1; if (i >= argv.length) throw new Error(`${a} needs a value`); return argv[i]; };
52
+ if (a === "--page") o.page = need();
53
+ else if (a === "--wiki-base") o.wikiBase = need();
54
+ else if (a === "--files") o.files = need();
55
+ else if (a === "--rubrics") o.rubrics = need().split(",").filter(Boolean);
56
+ else if (a === "--bench") o.bench = need();
57
+ else if (a === "--model") o.model = need();
58
+ else if (a === "--out") o.out = need();
59
+ else if (a === "--ledger") o.ledger = need();
60
+ else if (a === "--reps") o.reps = Number(need());
61
+ else if (a === "--sleep-s") o.sleepS = Number(need());
62
+ else if (a === "--timeout-s") o.timeoutS = Number(need());
63
+ else throw new Error(`unknown arg: ${a}`);
64
+ }
65
+ if (o.page === null) throw new Error("--page <wiki path or label> required");
66
+ if (o.wikiBase === null && o.files === null) throw new Error("need --wiki-base <url> (live) or --files en=<f>,zh=<f> (offline fixtures)");
67
+ if (Number.isNaN(o.reps) || o.reps < 2) throw new Error("--reps must be ≥2 (盲评双跑 is the certified mode)");
68
+ return o;
69
+ }
70
+
71
+ const t0 = Date.now();
72
+ const log = (m) => process.stderr.write(`[judge-poststage] ${m}\n`);
73
+
74
+ async function fetchLocaleBody(base, pagePath, locale) {
75
+ const token = readFileSync(path.join(process.env.HOME ?? "", ".wikijs-api-key"), "utf8").trim();
76
+ const gql = async (query) => {
77
+ const res = await fetch(`${base.replace(/\/$/, "")}/graphql`, {
78
+ method: "POST",
79
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
80
+ body: JSON.stringify({ query }),
81
+ });
82
+ if (!res.ok) throw new Error(`graphql http ${String(res.status)}`);
83
+ const doc = await res.json();
84
+ if (doc.errors !== undefined) throw new Error(`graphql ${JSON.stringify(doc.errors).slice(0, 200)}`);
85
+ return doc.data;
86
+ };
87
+ const list = (await gql("{ pages { list { id path locale } } }")).pages.list;
88
+ const row = list.find((r) => r.path === pagePath && String(r.locale ?? "en") === locale);
89
+ if (row === undefined) return null;
90
+ const one = (await gql(`{ pages { single(id: ${String(row.id)}) { content } } }`)).pages.single;
91
+ return one === null ? null : String(one.content ?? "");
92
+ }
93
+
94
+ function runBench(bench, args) {
95
+ // argv-only spawn (no shell), same contract as judge-bench's own isolation.
96
+ return execFileSync(process.execPath, [bench, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 64 * 1024 * 1024 });
97
+ }
98
+
99
+ function ledgerRows(ledgerPath) {
100
+ try {
101
+ return readFileSync(ledgerPath, "utf8").split("\n").filter((l) => l.trim().length > 0).map((l) => JSON.parse(l));
102
+ } catch {
103
+ return [];
104
+ }
105
+ }
106
+
107
+ function foldMajority(row) {
108
+ const okRep = (rep, repNo) => rep !== null && rep !== undefined && rep.rep === repNo && rep.status === "ok" && (rep.score === 0 || rep.score === 1);
109
+ if (!okRep(row.rep1, 1) || !okRep(row.rep2, 2)) return null;
110
+ if (row.rep1.score === row.rep2.score) return row.rep1.score;
111
+ if (!okRep(row.rep3, 3)) return null; // unresolved (B4 §4-c): never silently agrees
112
+ return row.rep1.score === row.rep3.score ? row.rep1.score : row.rep2.score;
113
+ }
114
+
115
+ const sha256 = (text) => createHash("sha256").update(text).digest("hex");
116
+
117
+ const opts = parseArgs(process.argv.slice(2));
118
+ if (opts.bench === "") { console.error("judge-poststage: no bee-judge bench wired — set ABATHUR_JUDGE_BENCH (or pass --bench); grader fail-closes the judge legs without it."); process.exit(2); }
119
+ (async () => {
120
+ try {
121
+ const work = mkdtempSync(path.join(os.tmpdir(), "s17-judge-"));
122
+ const localeFile = new Map();
123
+ const targets = [["en", "en.md"], ["zh", "zh.md"]];
124
+ for (const [locale, fname] of targets) {
125
+ let body = null;
126
+ if (opts.files !== null) {
127
+ const pair = opts.files.split(",").map((s) => s.split("=")).find(([k]) => k === locale);
128
+ if (pair === undefined) { log(`--files has no ${locale} leg — skipped (coverage fails closed downstream)`); continue; }
129
+ body = readFileSync(path.resolve(pair[1]), "utf8");
130
+ } else {
131
+ body = await fetchLocaleBody(opts.wikiBase, opts.page, locale);
132
+ if (body === null) { log(`live wiki has no (${opts.page}, ${locale}) row — skipped (coverage fails closed downstream)`); continue; }
133
+ }
134
+ const f = path.join(work, fname); // neutral names: page label never enters the judge channel
135
+ writeFileSync(f, body);
136
+ localeFile.set(locale, f);
137
+ }
138
+ if (localeFile.size === 0) {
139
+ rmSync(work, { recursive: true, force: true });
140
+ throw new Error("nothing to judge — both locale bodies missing; write no verdicts file (grader fail-closes the 蜂判 legs)");
141
+ }
142
+
143
+ const ledgerPath = opts.ledger ?? path.join(".bench", "judge-ledger.jsonl");
144
+ mkdirSync(path.dirname(ledgerPath), { recursive: true });
145
+ const files = [...localeFile.values()].join(",");
146
+ log(`matrix: ${opts.rubrics.length} rubrics × ${String(localeFile.size)} locale file(s) × ${String(opts.reps)} reps = ${String(opts.rubrics.length * localeFile.size * opts.reps)} calls`);
147
+ runBench(opts.bench, ["matrix", "--rubrics", opts.rubrics.join(","), "--pages", files, "--reps", String(opts.reps),
148
+ "--sleep-s", String(opts.sleepS), "--timeout-s", String(opts.timeoutS), "--model", opts.model, "--ledger", ledgerPath]);
149
+ const fileToTag = new Map([...localeFile.entries()].map(([loc, f]) => [f, loc]));
150
+ let rows = ledgerRows(ledgerPath).filter((r) => fileToTag.has(r.page));
151
+ if (rows.length === 0) {
152
+ rmSync(work, { recursive: true, force: true });
153
+ throw new Error("judge-bench emitted no ledger rows for the staged pages (instrument failure, not a verdict)");
154
+ }
155
+
156
+ const arbLedger = `${ledgerPath}.arb`;
157
+ let arbitrations = 0;
158
+ for (const row of rows) {
159
+ if (row.agree === true) continue;
160
+ arbitrations += 1;
161
+ log(`arbitration (B4 §4-b): ${row.rubric} × ${fileToTag.get(row.page)} — fresh isolated rep3`);
162
+ try {
163
+ runBench(opts.bench, ["pair", "--rubric", opts.rubrics.find((r) => path.basename(r, ".md") === row.rubric) ?? row.rubric,
164
+ "--page", row.page, "--reps", "1", "--sleep-s", String(opts.sleepS), "--timeout-s", String(opts.timeoutS),
165
+ "--model", opts.model, "--ledger", arbLedger]);
166
+ const arb = ledgerRows(arbLedger).filter((r) => r.rubric === row.rubric && r.page === row.page).pop();
167
+ if (arb?.rep1 !== undefined) row.rep3 = { ...arb.rep1, rep: 3 };
168
+ } catch (e) {
169
+ log(`arbitration call failed: ${String(e.message).slice(0, 160)} — row stays UNRESOLVED`);
170
+ }
171
+ row.majority = foldMajority(row);
172
+ }
173
+ for (const row of rows) {
174
+ row.majority = row.majority ?? foldMajority(row);
175
+ }
176
+
177
+ const doc = {
178
+ generated: new Date().toISOString(),
179
+ unit: "scenario-17",
180
+ page: opts.page,
181
+ mode: opts.files !== null ? "offline-files" : "live-wiki",
182
+ model: opts.model,
183
+ instrument: { bench: opts.bench, rubrics: opts.rubrics.map((r) => ({ file: r, name: path.basename(r, ".md"), sha256: sha256(readFileSync(r, "utf8")) })), reps: opts.reps, sleepS: opts.sleepS, timeoutS: opts.timeoutS, ledger: ledgerPath, arbitrations },
184
+ wall_ms: Date.now() - t0,
185
+ rows: rows.map((r) => ({ ...r, page: opts.page, locale: fileToTag.get(r.page) ?? String(r.page) })),
186
+ };
187
+ mkdirSync(path.dirname(opts.out), { recursive: true });
188
+ writeFileSync(opts.out, JSON.stringify(doc, null, 1) + "\n");
189
+ rmSync(work, { recursive: true, force: true });
190
+ const counts = { ok: 0, zero: 0, unresolved: 0 };
191
+ for (const r of doc.rows) { if (r.majority === 1) counts.ok += 1; else if (r.majority === 0) counts.zero += 1; else counts.unresolved += 1; }
192
+ log(`wrote ${opts.out}: ${String(doc.rows.length)} rows, majority 1×${String(counts.ok)} 0×${String(counts.zero)} unresolved×${String(counts.unresolved)}, wall ${String(Math.round(doc.wall_ms / 1000))}s, arbitrations ${String(arbitrations)}`);
193
+ process.stdout.write(JSON.stringify({ out: opts.out, rows: doc.rows.length, ...counts, arbitrations, wall_ms: doc.wall_ms }) + "\n");
194
+ } catch (e) {
195
+ // instrument failure ≠ a verdict: exit 2 (inconclusive plumbing), no file —
196
+ // the grader's fail-closed 蜂判 legs own the honest 0 with an explicit note.
197
+ process.stderr.write(`[judge-poststage] FATAL ${e instanceof Error ? e.message : String(e)}\n`);
198
+ process.exit(2);
199
+ }
200
+ })();
@@ -36,10 +36,10 @@ raw="${ABATHUR_MUTATOR_RAW:-/tmp/abathur-mutate-raw.jsonl}"
36
36
  rc=$?
37
37
  echo "mutate: opencode rc=$rc raw=$raw" >&2
38
38
 
39
- python3 - "$raw" "$canonical" "$rc" <<'PY'
40
- import json, re, sys
39
+ python3 - "$raw" "$canonical" "$rc" "$worktree" <<'PY'
40
+ import json, os, re, sys
41
41
 
42
- raw, canonical_path, rc = sys.argv[1], sys.argv[2], int(sys.argv[3])
42
+ raw, canonical_path, rc, worktree = sys.argv[1], sys.argv[2], int(sys.argv[3]), sys.argv[4]
43
43
 
44
44
  def create_diff(path, text):
45
45
  lines = text.split("\n")
@@ -73,19 +73,55 @@ for text in reversed(last_text_events(raw)):
73
73
  fence = re.search(r"```(?:json)?\s*(\{.*\})\s*```", candidate, re.S)
74
74
  if fence:
75
75
  candidate = fence.group(1)
76
- start, end = candidate.find("{"), candidate.rfind("}")
77
- if start < 0 or end <= start:
76
+ end = candidate.rfind("}")
77
+ if end < 0:
78
78
  continue
79
- try:
80
- doc = json.loads(candidate[start : end + 1])
81
- except ValueError:
82
- continue
83
- if isinstance(doc, dict):
79
+ # prose preambles can contain set-notation braces like {症状/Symptoms, ...} —
80
+ # the first '{' is not necessarily JSON; try every open brace from the LAST
81
+ # backwards so the outermost-to-last-close object wins when one parses.
82
+ opens = [i for i, ch in enumerate(candidate) if ch == "{" and i < end]
83
+ doc = None
84
+ for start in reversed(opens):
85
+ try:
86
+ parsed = json.loads(candidate[start : end + 1])
87
+ except ValueError:
88
+ continue
89
+ if isinstance(parsed, dict):
90
+ doc = parsed
91
+ break
92
+ if doc is not None:
84
93
  proposal = doc
85
94
  break
86
95
 
87
96
  spec_text = open(canonical_path, encoding="utf-8").read()
88
- packaging = create_diff("genome.jsonc", spec_text if spec_text.endswith("\n") else spec_text + "\n")
97
+ if not spec_text.endswith("\n"):
98
+ spec_text += "\n"
99
+ # genome.jsonc in the base tree was written by this wrapper's own prior round,
100
+ # so a replace-all modify hunk always anchors deterministically at line 1.
101
+ # A CREATE here would break apply the moment the file lands in the base (udiff
102
+ # refuses create-over-existing and discards the WHOLE candidate).
103
+ gen_path = os.path.join(worktree, "genome.jsonc")
104
+ try:
105
+ with open(gen_path, encoding="utf-8") as fh:
106
+ base_text = fh.read()
107
+ except FileNotFoundError:
108
+ packaging = create_diff("genome.jsonc", spec_text)
109
+ except OSError as err:
110
+ sys.stderr.write("mutate: genome.jsonc unreadable: %s\n" % err)
111
+ sys.exit(3)
112
+ else:
113
+ if base_text == spec_text:
114
+ packaging = None
115
+ else:
116
+ base_body = base_text.split("\n")
117
+ if base_body and base_body[-1] == "":
118
+ base_body.pop()
119
+ old_h = "".join("-" + ln + "\n" for ln in base_body)
120
+ new_h = "".join("+" + ln + "\n" for ln in spec_text.split("\n")[:-1])
121
+ packaging = (
122
+ "--- a/genome.jsonc\n+++ b/genome.jsonc\n"
123
+ "@@ -1,%d +1,%d @@\n%s%s" % (len(base_body), len(spec_text.split("\n")) - 1, old_h, new_h)
124
+ )
89
125
 
90
126
  if isinstance(proposal, dict):
91
127
  path = proposal.get("path")
@@ -103,15 +139,19 @@ if isinstance(proposal, dict):
103
139
  content = content.replace("\r", "")
104
140
  if not content.endswith("\n"):
105
141
  content += "\n"
106
- candidate = {"id": "mutate-live", "rationale": rationale, "diffs": [create_diff(path, content), packaging]}
142
+ diffs = [create_diff(path, content)] + ([packaging] if packaging else [])
143
+ candidate = {"id": "mutate-live", "rationale": rationale, "diffs": diffs}
107
144
  print(json.dumps({"candidates": [candidate]}))
108
145
  sys.exit(0)
109
146
 
110
- print(json.dumps({
111
- "candidates": [{
112
- "id": "mutate-package-only",
113
- "rationale": "packaging-only candidate: model output unparseable or path rejected (opencode rc=%d); lineage genome.jsonc added, no skill mutation proposed" % rc,
114
- "diffs": [packaging],
115
- }]
116
- }))
147
+ if packaging:
148
+ print(json.dumps({
149
+ "candidates": [{
150
+ "id": "mutate-package-only",
151
+ "rationale": "packaging-only candidate: model output unparseable or path rejected (opencode rc=%d); lineage genome.jsonc added, no skill mutation proposed" % rc,
152
+ "diffs": [packaging],
153
+ }]
154
+ }))
155
+ else:
156
+ print(json.dumps({"candidates": []}))
117
157
  PY
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env bash
2
+ # scenario-17 run hook = run-scenario.sh (agent stage, c7 ≤8min band) + the 蜂判
3
+ # poststage (judge-poststage.mjs) against the ACTUAL pages the agent produced.
4
+ # Mirrors the .bench/transcripts flow: the shipped grader never calls a model;
5
+ # the LLM measurement is instrument output staged at run time into
6
+ # .bench/judge-verdicts.json, which grader.mjs reads as observable state
7
+ # (fail-closed when absent). The LAST stdout line stays run-scenario.sh's
8
+ # {unit,tokensEst,turns,opencodeExit} meta JSON — parseRunMeta's contract — so
9
+ # the engine-facing format is byte-compatible with units 01–16.
10
+ set -euo pipefail
11
+ unit_id="${1:?usage: run-scenario-17.sh <unitId> <scenario-file> [repoRoot]}"
12
+ scenario_file="${2:?usage: run-scenario-17.sh <unitId> <scenario-file> [repoRoot]}"
13
+ here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
14
+ dossier="${S17_DOSSIER:-_sandbox/eval17/gpu-warm-pool-dossier}"
15
+ wiki_base="${ABATHUR_WIKI_BASE:-http://localhost:3000}"
16
+ meta=""
17
+ agent_s=0
18
+ judge_s=0
19
+ if [ "${S17_SKIP_AGENT:-0}" != "1" ]; then
20
+ t=$SECONDS
21
+ meta="$(bash "$here/run-scenario.sh" "$@")"
22
+ agent_s=$((SECONDS - t))
23
+ fi
24
+ t=$SECONDS
25
+ node "$here/judge-poststage.mjs" --page "$dossier" --wiki-base "$wiki_base" \
26
+ --out "${ABATHUR_JUDGE_VERDICTS:-.bench/judge-verdicts.json}" \
27
+ --ledger "${ABATHUR_JUDGE_LEDGER:-.bench/judge-ledger-${unit_id}.jsonl}" 1>&2 || echo "[run-scenario-17] judge stage FAILED (no verdicts file — grader fail-closes the 蜂判 legs)" >&2
28
+ judge_s=$((SECONDS - t))
29
+ echo "[run-scenario-17] stage walls: agent=${agent_s}s judge=${judge_s}s (unit=${unit_id})" >&2
30
+ if [ -n "$meta" ]; then
31
+ printf '%s\n' "$meta"
32
+ else
33
+ printf '%s\n' "{\"unit\": \"$unit_id\", \"tokensEst\": 0, \"turns\": 0, \"opencodeExit\": -1}"
34
+ fi
@@ -57,7 +57,14 @@ model_args=()
57
57
  if [ -n "${ABATHUR_AGENT_MODEL:-}" ]; then
58
58
  model_args=(--model "$ABATHUR_AGENT_MODEL")
59
59
  fi
60
- "$bin" run --command historian --auto --format json "${model_args[@]}" --message "$brief" >"$ABATHUR_TRANSCRIPT" 2>/dev/null || status=$?
60
+ "$bin" run --command historian --auto --format json "${model_args[@]}" --message "$brief" >"${ABATHUR_TRANSCRIPT}.raw" 2>/dev/null || status=$?
61
+ # Campaign-1 channel fix (task-07-launch-blockers.md): plugin startup banners print to
62
+ # the agent's stdout and contaminate the captured JSONL. ONLY the known
63
+ # `[persistence-watchdog]` line is stripped into the grader-facing transcript —
64
+ # unknown non-JSON lines SURVIVE so the grader's corruption fail-closed stays
65
+ # honest — and a clean stream passes through byte-identical (F1 invariant).
66
+ # The verbatim capture remains at ${ABATHUR_TRANSCRIPT}.raw for audit.
67
+ LC_ALL=C grep -v '^\[persistence-watchdog\]' "${ABATHUR_TRANSCRIPT}.raw" >"$ABATHUR_TRANSCRIPT" || true
61
68
  python3 - "$ABATHUR_TRANSCRIPT" "$unit_id" "$status" <<'PY'
62
69
  import json, sys
63
70
  path, unit, status = sys.argv[1], sys.argv[2], int(sys.argv[3])
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tachikomagundam/abathur",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "Evolution harness: observe failures, mutate, re-bench, select — human-gated promotion, offline lineage bundles.",
5
5
  "author": "TachikomaGundam",
6
6
  "type": "module",
package/plugin/abathur.ts CHANGED
@@ -1,4 +1,4 @@
1
- // abathur-opencode-plugin v0.2.4
1
+ // abathur-opencode-plugin v0.2.5
2
2
  // Official opencode plugin adapter for the abathur evolution harness.
3
3
  // Registers ONE agent tool, `abathur`, that shells out to the abathur CLI —
4
4
  // argv-only (node:child_process execFile, never a shell), top-level commands