Skip to content

fix(eval): make opencode NDJSON output parseable + gate only latest eval round - #21

Merged
JeiKeiLim merged 102 commits into
mainfrom
fix/opencode-output-and-blocking-finding-resume
Aug 7, 2026
Merged

fix(eval): make opencode NDJSON output parseable + gate only latest eval round#21
JeiKeiLim merged 102 commits into
mainfrom
fix/opencode-output-and-blocking-finding-resume

Conversation

@JeiKeiLim

@JeiKeiLim JeiKeiLim commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Summary

Two bugs discovered while investigating a production run. Both produced the same symptom — a report-only parent stuck in blocked_on_finding with no path forward — and were misdiagnosed in the report as a broken auto-resume gate.

Bug 1: opencode critic verdicts were unparseable (root cause of most "auto-resume never fires")

OpenCodeAdapter returned the raw NDJSON event stream from opencode run --format json as AgentResponse.output. extractRubricJson cannot parse NDJSON — it tries JSON.parse on the whole stream and falls back to slicing between the first { and last } (which spans multiple events and is never valid JSON). Result: every opencode critic's verdict parsed as NULL, so the blocking-finding auto-resume gate correctly-but-permanently refused to unblock.

Verified against the production DB (1102 opencode eval jobs): 0/1102 parseable, vs claude-code 379/477 and codex 289/487 parseable-to-PASS. The 5 successful auto-resumes in the same DB all used claude-code.

Fix: the adapter now collapses the NDJSON stream into its text event parts (all assistant text events, including intermediate reasoning + the trailing verdict; note claude --print returns only the final message, so multi-turn sessions differ), with a raw-stdout fallback and truncated-tail tolerance. Verified against the stored run: 12/14 previously-unreadable verdicts now parse (the 2 remaining genuinely never emitted a rubric — visible for the first time via tenet_job_result).

Bug 2: stale critic rows from earlier eval rounds poisoned the gate

When a child dev job is retried, the orchestrator re-fires tenet_start_eval, which creates new critic rows but leaves prior rounds' rows in place. getEvalsForSource returns all rounds, and the gate required EVERY sibling to be completed + passed:true. A failed critic in round 1 poisons rounds 2 and 3 forever (e2e-1 in the run: round-1 interaction_e2e FAIL(false) blocked a fully-green round 3).

Fix: tenet_start_eval now stamps every critic in one dispatch with a shared eval_round id; a re-fire gets a fresh id. The resume gate keys on the newest complete round — every critic in a round evaluated the same source state, so requiring the whole round to pass avoids mixing verdicts across code revisions (the "green gate, still wrong code" failure). The gate runs whenever any stamped round exists; unstamped siblings (ad-hoc re-fires, legacy pre-stamp evals) become singleton rounds, so a NEWER unstamped critic forces the gate to wait for a fresh stamped round (fail-closed) instead of being invisible — a red ad-hoc re-fire cannot be ignored while the parent unblocks on an older round's stale green. Older rounds stay in the DB (history preserved) but no longer count.

Rubric extraction hardening (shared parser)

extractRubricJson moved to a shared module (src/core/rubric.ts) used by both the resume gate and tenet_get_status, so the two consumers of the same stored critic output can't drift apart. The parser is a single rightmost-object walk:

  • walks { positions from the end (the preamble mandates the verdict at the END), parses each to its matching }, and returns the rightmost top-level object with a boolean passed key, preferring one that also carries a stage key (tool-result echoes rarely carry stage, so a failing critic that pastes a passing tool result after its verdict can't false-green the gate)
  • rejects objects nested inside a valid JSON object or array (a finding or tool echo is never the verdict)
  • recovers from unbalanced braces in prose (a stray { before the verdict no longer strands the parent)
  • tenet_get_status uses the same parser, so latest_e2e_status survives prose braces
  • validated against the production DB: a golden test of 12 real critic outputs (including a case the old parser got wrong — an unmatched quote in prose false-rejected a valid verdict)

Tests

  • NDJSON collapse: verdict extraction, truncated tail, raw fallback, stream-order join
  • Rubric parser: fenced-echo, echoed-verdict, unbalanced-brace, bracket-depth, stage-preference, custom-critic shape
  • Round gate: C3 (stale failed critic doesn't block a green round), C4 (regression), C5 (round 2 missing a stage doesn't mix with round 1 — the discriminator), C6 (sequential chaining), C7 (job-level failed critic in newest round stays blocked), C8 (ad-hoc unstamped critic can't disable the gate), C9 (legacy unstamped critic can't unblock)
  • Status surfacing: D4 (prose braces still surface layer2_status)
  • typecheck, lint, and all 301 tests pass

JeiKeiLim and others added 30 commits June 18, 2026 08:39
feat: Tenet document lifecycle (.tenet/ project/, runs/, archive/)
Co-Authored-By: Claude <noreply@anthropic.com>
- init --upgrade prompts Y/N before the destructive legacy-doc move; the new
  --migrate-legacy flag opts in non-interactively. -y/--yes deliberately does
  not auto-migrate — the destructive action names itself.
- previewLegacyMigration (non-mutating) drives the prompt and the skip/instruction
  messaging; migrateLegacyDocuments is gated behind explicit consent at every layer.
- New src/cli/star-nudge.ts: interactive init/upgrade ask to star the repo only
  when gh can't already confirm a star. A decline defers (re-asks next interactive
  run); an actual star (gh-confirmed, or accept + best-effort gh PUT) suppresses
  per-project via .tenet/.state/config.json under star_nudge. TENET_NO_STAR_NUDGE
  opts out entirely.
- CLI-only — never fires from the autonomous skill boot loop; skipped when
  non-interactive (no TTY / --yes).

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
feat: consent gate for legacy migration + star-repo nudge (v26.6.2)
…it-tracked state DB

- context bootstrap populates project/design-components/ when a visual/UI
  surface is detected, and flags (not silently skips) an empty dir in a
  clearly-frontend project (note.md #1)
- visual phase makes design-components/ a MUST-inspect when non-empty,
  reinforced in the run design-delta checklist (note.md #3)
- tenet init / --upgrade detects an already-tracked tenet.db/-wal/-shm and
  warns with the exact `git rm --cached --ignore-unmatch` command
  (detect+instruct only, never auto-runs); also appends .tenet/.state/ to the
  repo-root .gitignore as defense-in-depth (note.md #4)
- 06-evaluation.md: eval records default to the run journal, not .tenet/knowledge/
- export statePaths() from state-store for reuse; add 7 init tests

Co-Authored-By: Claude <noreply@anthropic.com>
ESLint was never wired into this repo (not a devDep, no config), so `make lint`
and `make check` could not run. Set up the tooling and enforce it:

- add eslint, typescript-eslint, @eslint/js devDeps; eslint.config.mjs flat
  config (recommended rules + _-prefix unused-var convention)
- remove one stale unused `beforeEach` import surfaced by lint
- new ci.yml: typecheck + lint + test + build on every PR/push to main
- publish.yml: add a Lint step to the pre-publish gate; drop the stale
  "lint is skipped" comment

Co-Authored-By: Claude <noreply@anthropic.com>
…safety

- Versioning: publish.yml now runs typecheck + lint + tests + build (was
  "typecheck + tests + build"); add that ci.yml enforces the gate on PRs
  and pushes to main
- CLI: document the init/--upgrade git-safety check (tracked-DB warning +
  root-.gitignore defense-in-depth)

Co-Authored-By: Claude <noreply@anthropic.com>
feat: state-DB git-safety, design-component enforcement, eslint + CI gate
Co-Authored-By: Claude <noreply@anthropic.com>
Steer messages could be added and read but never retired — updateSteerStatus
existed in the DB layer with no MCP tool calling it — so the inbox grew
without bound (observed 400k+ unresolved) and agent self-notes drowned out
user directives.

- tenet_update_steer: retire by id or sweep agent-context in bulk; user steers
  and directives retire only by explicit id (standing rules persist)
- tenet_process_steer: user steers returned in full, agent steers capped to a
  recent window (default 50, agent-tunable), with total_unresolved + truncated
  so truncation is visible rather than silent
- state-store: getSteerInbox / countUnprocessedSteers / updateSteersStatus /
  sweepAgentContextSteers replace the unbounded getUnprocessedSteers and the
  single-id updateSteerStatus
- tenet status shows the user/agent split; health-check uses the cheap count
- skill hygiene: loop reads user steers first, retires consumed context, retires
  directives only when clearly done, logs agent self-notes as context

Co-Authored-By: Claude <noreply@anthropic.com>
feat(steer): self-maintaining inbox + user/agent separation
`.tenet/project/**` is read by every run but never re-scanned, so once it
drifts, every run starts on stale context — and nothing flagged it. The skill
already told jobs to "write the proposed update to the journal and mention it
in the final report", but that prescription was too vague to act on, nothing
collected it, and nothing applied accepted updates.

The gap was a missing skill step, not a missing capability — doctrine proposals
are documents the agent writes natively, and apply dispatches a tracked job via
the existing tenet_start_job. So this adds no MCP tool and no DB change.

- 05-execution-loop.md: structured doctrine-drift note convention (file,
  current_claim, observed_reality, proposed_change); new "Run Completion —
  Doctrine Drift Review" step consolidates drift notes into per-document
  proposals appended to .tenet/runs/<run>/doctrine-proposals.md (append-only,
  survives compaction/session loss, never blocks); apply path via an existing
  dev job with allow_project_doctrine_edits + bootstrap re-gate
- 00-context-bootstrap.md: closes the "belongs to lifecycle maintenance"
  pointer — the maintenance job re-runs this gate
- 06-evaluation.md: a job with allow_project_doctrine_edits editing
  .tenet/project/** is NOT a scope_conflict (so the apply path can't be
  wrongly failed)
- SKILL.md: Doctrine Maintenance section + phase-map line
- CLAUDE.md: doctrine-proposals.md as a per-run artifact; maintenance line

Co-Authored-By: Claude <noreply@anthropic.com>
Doctrine lifecycle: run-end drift review + durable proposals (no new tool)
Verified doc/code drift surfaced across docs-review rounds. Docs/skills
only; no source changes.

- README, CLAUDE.md, planning/06, planning/10, planning/14: correct the
  MCP tool count to 19 and show the full adapter commands
  (claude --print --output-format json, opencode run --format json,
  codex exec --sandbox workspace-write).
- README: add the tenet_update_steer row to the MCP tools table.
- release-runbook: lint is fully automated (eslint ^10.5.0 is a
  devDependency; ci.yml and publish.yml both run `pnpm run lint`) — drop
  the stale "not automated" bullet and add lint to the publish.yml step
  list.
- CLAUDE.md: reword "three pre-approval configs" to "three agent-config
  surfaces ... driven by one source-of-truth tool-name list" (the list
  below it has four items).
- planning/10: add a banner mapping the proposed names
  tenet_request_remediation -> tenet_report_blocking_finding and
  blocked_remediation_required -> blocked_on_finding to what shipped.
- planning/14: add a supersession banner noting agile mode is implemented
  (phases 02/04/07) and the pre-lifecycle layout is historical.
- skill files (SKILL.md, phases/01, phases/02): align research confidence
  tags with the live enum ([scanned-not-verified] / [decision-only])
  instead of the non-existent [research-verified] / [research-inconclusive].

Co-Authored-By: Claude <noreply@anthropic.com>
docs: align authoritative docs and skills with current code
Co-Authored-By: Claude <noreply@anthropic.com>
The eval gate was hardwired to exactly 3 critics. The critic set is now a
project artifact: .tenet/critics.json, read live at every eval (no restart,
no DB change). The 3 built-ins (code / test / interaction-e2e) are enabled
by default; any can be disabled and custom critics appended with prompts
under .tenet/critics/*.md. Phase 1 (enable/disable) and Phase 2 (custom
critics) ship together as one roster model.

- src/core/critic-roster.ts: roster parser (resolveRoster / loadCriticRoster)
  with safe fallback to the 3 built-ins on a missing or invalid file.
- src/mcp/tools/tenet-start-eval.ts: roster-driven dispatch; N-critic
  sequential chaining in roster order; variable-length jobs[] return shape;
  stamps expected_eval_stages onto every dispatched critic.
- src/core/job-manager.ts: the blocking-finding resume gate reads
  expected_eval_stages dynamically (defaults to the 3 built-ins for rows
  predating the stamp) so disabling a critic no longer strands a blocked
  report-only parent.
- src/cli/init.ts: scaffold .tenet/critics.json + .tenet/critics/.
- skills/tenet/critics.md: on-demand critic-designer doc (not a numbered
  loop phase) so a user asks Tenet to author a repo-specific critic instead
  of hand-writing prompts; enforces the {passed, stage, findings:[{category}]}
  output contract.
- Tests: migrate named-ID destructuring to jobs[]; add roster
  fallback / disable / custom / skip / N-chain cases plus dynamic-gate
  resume tests (disabled critic + custom critic).
- Docs/skills: "three critics" -> "configured critics" across phase
  06/05/02/04, SKILL.md, README, CLAUDE.md; annotate (don't rewrite) the
  frozen body of planning/10.

No new MCP tool, no DB change, no new JobType (custom critics reuse
critic_eval / playwright_eval).

Co-Authored-By: Claude <noreply@anthropic.com>
…l row

Add a "Configurable Critics" subsection (roster format, disable a built-in,
add a custom critic, the mandatory output contract, pointer to the designer
doc) so the front-door README is self-sufficient instead of only naming the
file. Also fix the MCP tools table row for tenet_start_eval, which still
described the old hardcoded 3-critic dispatch.

Co-Authored-By: Claude <noreply@anthropic.com>
feat(eval): configurable critic roster via .tenet/critics.json
Co-Authored-By: Claude <noreply@anthropic.com>
…arity

The interaction-e2e critic already handled non-UI surfaces (CLI/API/library
path added in b260078); it never skipped on non-UI projects. So the ask was a
framing + parity gap, not a missing feature. This closes the real gaps:

- Preamble now leads with an agent-brain QA principle for ALL surfaces, with
  browser demoted to one peer branch. CLI/API/library branches get real
  exploratory depth (invalid/edge/unicode inputs, error paths, undocumented
  flags, chained workflows, exit-code/stderr + response-status semantics) -
  parity with the browser branch.
- Docs/README/critic-designer stop signaling "browser-only" and no longer tell
  users to disable the critic for CLI projects (that threw away the CLI e2e
  they actually wanted).
- 06-evaluation.md Stage 5 + honesty section reframed; non-browser
  not_applicable is "browser Layer 2 didn't apply," not "unverified."

Internal identifiers (playwright_eval job type/id/stage,
latest_playwright_layer2_status field, *_args_playwright_eval config keys,
output contract) stay stable for back-compat with just-shipped
.tenet/critics.json. No schema/DB/agent-config ripple.

Co-Authored-By: Claude <noreply@anthropic.com>
feat(eval): interaction-e2e critic gets CLI/API/library exploratory parity
The interaction-e2e critic is surface-agnostic (browser via Playwright MCP,
CLI/API/library via shell) but its stored identifier was still `playwright_eval`
- misleading and a future-reader trap. Rename it to `interaction_e2e`
everywhere and eradicate the old term from stored data.

- JobType / built-in id / stage / DEFAULT_EVAL_STAGES: playwright_eval -> interaction_e2e
- Status field: latest_playwright_layer2_status -> latest_e2e_status
- Config keys + CLI flags: *_args_playwright_eval -> *_args_interaction_e2e
  (and agent_override_playwright_eval -> agent_override_interaction_e2e)
- DB schema 1->2 migration rewrites jobs.type, expected_eval_stages in params,
  and the config keys on `tenet init --upgrade`. No structural change - pure
  value rewrites (jobs.type is free TEXT, no CHECK constraint).
- Roster resolver keeps a single documented legacy-id alias so old
  user-authored .tenet/critics.json files still resolve.

Unchanged (no "playwright" in them / out of scope): the layer2_status output
field + its values, the surface enum, and the Playwright MCP integration
itself (browser tooling still used for browser surfaces).

Co-Authored-By: Claude <noreply@anthropic.com>
refactor(eval): rename playwright_eval -> interaction_e2e (eradicate via migration)
Co-Authored-By: Claude <noreply@anthropic.com>
The rename (PR #10) migrated the DB but not a project's .tenet/critics.json
file, so upgrading users kept seeing `playwright_eval` in the file they open -
even though the roster resolver aliased it correctly. The alias makes it
functionally fine, but the stale name is exactly the "wait, is this broken?"
trap a user hits when they inspect the file.

Add a targeted, idempotent rewrite on `tenet init --upgrade`: replace
`playwright_eval` -> `interaction_e2e` in .tenet/critics.json (covers the
built-in id and any custom critic's job_type), preserving order, enabled flags,
customs, and formatting. The roster alias stays as defense-in-depth for
hand-edited or never-upgraded files.

Co-Authored-By: Claude <noreply@anthropic.com>
fix(init): rewrite legacy playwright_eval id in critics.json on upgrade
#12 doctrine-drift → proposals: the dev-job preamble was a vague paraphrase
("write to journal or final report") that dropped the drift-note contract the
run-end review filters on, so drift notes landed in design.md and
doctrine-proposals.md stayed empty. The preamble now carries the actual contract
(tenet_update_knowledge type=journal, title="doctrine drift: <file>", structured
findings) plus a `### doctrine-drift:` marker for run docs; the run-end review
scans journal + run docs (marker + freeform), deduped by doctrine_file.

#17 stale-job blindness: tenet_get_status returned only counts + the one running
job's id, so the agent could not see or cancel stale pending jobs (tenet_cancel_job
needs an id it could not obtain). Add view="queue" (+ optional include_blocked)
returning pending/running jobs with id/type/status/name/age_ms/stale; stale derives
only from the existing heartbeat bar (pending has no staleness bar — judged by
age_ms). Default call unchanged; no new tool, no DB change.

#20 critic context-limit false-pass: a critic exit with no valid rubric JSON
(context-limit/error) was not explicitly treated as not-passed. The orchestrator
now retries as-is, splits into reduced-scope critics after 2 consecutive
context-limits, and never accepts a context-limited result as a pass. The core
resume-gate already refused to unblock on unparseable output; a regression test
locks that invariant.

Co-Authored-By: Claude <noreply@anthropic.com>
Resolve the three findings from the doc/code consistency review (r1):

- skills/tenet-diagnose: --codex-args-playwright-eval → --codex-args-interaction-e2e.
  The flag was renamed in 26.6.6 (playwright_eval → interaction_e2e); the diagnose
  skill still documented the old, now-broken flag.
- tests/README: "five targets" → "five canaries, plus an e2e-all runner". The bullet
  list had six entries (five canaries + the run-all runner), contradicting "five".
- docs/e2e-runbook + tests/README: retire the "Playwright eval" stage label in favor
  of "interaction-e2e" (the stage was renamed in 26.6.6).

Doc-only; no code or behavior change.

Co-Authored-By: Claude <noreply@anthropic.com>
@JeiKeiLim

Copy link
Copy Markdown
Owner Author

Full version of the follow-up review (head 5514dd9) — the Slack delivery of this got length-truncated, so posting the complete text here for the record.


I've read the final state at head SHA 5514dd9 (the branch-name raw fetch briefly served a stale copy of the adapter — the SHA-pinned fetches confirm the exported, guarded final version). No terminal in that review session, so I couldn't clone and run the 271 tests myself — I'll say that plainly and lean on the diffs, the fixture, and the test summaries I could pull.


PR #21 review — fix(eval): opencode NDJSON parseable + gate only latest eval round

Verdict: approve with nits. Both root causes are real, both fixes sit at the correct layer, and the tests target the actual failure modes. Also: 0/1102 parseable opencode verdicts is a beautiful number — by which I mean horrifying.

Bug 1 — extractTextPartsFromNdjson (src/adapters/opencode-adapter.ts)

  • Normalizing at the adapter boundary to match the claude --print shape is the right call. Teaching extractRubricJson to speak NDJSON would have welded the parser to a second vendor's format.
  • Like: schema-pin comment, null → raw-stdout fallback, truncated-tail tolerance, unknown casts + runtime guards. And the degradation direction is fail-safe: shape change → NULL verdict → gate blocks. It never wrongly unblocks.
  • Design note: you join all text events across the whole session, not just the final message. The verdict survives via extractRubricJson's {-to-} slice, but output now carries mid-run chatter ("Running the zero-findings recheck...") into job_result/worker-facing surfaces. If that noise matters, collapse only the last message group (text events after the final step_start/before step_finish reason:"stop").
  • Inherited fragility you're now amplifying: extractRubricJson slices first { → last }. More joined prose = higher odds an earlier text event contains braces (code snippets!), making that slice span invalid JSON → NULL verdict → same stall, new costume. Follow-up worth doing while you're in this seam: scan for the last balanced {...} object instead of first/last slice. Not a blocker — claude has the same hole — but this PR is standing right next to it.

Bug 2 — gate (src/core/job-manager.ts)

  • Per-stage-newest fixes the poisoning correctly; resolveExpectedEvalStages reading the newest roster stamp is the right authoritative source. History preserved, gate semantics fixed. Good.
  • The >= tie-break's correctness depends on evalSiblings iteration order. "Later-seen wins" only holds if getEvalsForSource returns rows deterministically — if unordered, equal-ms ties are a coin flip. Sort by createdAt ascending (secondary: id) before the loop, and the comment's promise becomes actually true.
  • new Map() is Map<any, any> — still untyped at head. Map<string, Job> catches the '' bucket's mistakes at compile time.
  • Speaking of the '' bucket: any sibling without eval_stage collapses into one anonymous stage, newest wins. Previously all counted. Verify nothing legitimately fires eval without eval_stage, or this silently weakens the gate for stage-less jobs.
  • The design concern in the thread stands and is the one real hole: per-stage newest can mix verdicts across source revisions — round-3 code_critic PASS + round-2 test_critic PASS = a composite verdict no single round ever produced, unblocking the parent on code the round-2 critic never saw. Strictly better than forever-blocked, but the round-id stamp + "newest round with complete coverage" follow-up is the right shape. Don't let it rot on the board — this is exactly the "green gate, still wrong code" incident generator.

Tests

  • C3 with the maxUses: 1 trick is exactly how you write that regression deterministically. Red-verified per description, green in CI.
  • Stream-order join, truncated tail, non-event JSON lines, raw fallback — good collapse coverage. The fixture now has two text events, which closes the earlier single-event gap.
  • Nit: the E2E rubric test mirrors extractRubricJson inline instead of importing it. Mirrors drift. Export the real function and import it, or lean on the C-series integration path that exercises the real parser end-to-end.
  • Missing one cheap case: a text event after the verdict that contains braces → pins down that the {-} slice still lands on the verdict.

Couldn't verify: no terminal in that session — didn't run the 271 tests or clone the branch; relying on green CI + the documented C3 red/green run. Reviewed against head 5514dd9d1 via SHA-pinned raw fetches.

Ship it after the Map typing + tie-break sort. Put the round-id stamp on the follow-up board, and keep the brace-slice fragility on your radar. Nice root-cause work — this is what "blocked_on_finding with no path forward" actually was.

…n test

- Export extractRubricJson from job-manager so the adapter end-to-end test
  imports the real parser instead of an inline mirror (mirrors drift).
- New test: a text event after the verdict containing braces must not break
  extractRubricJson's first-{ to last-} slice — pins the fragility the
  follow-up review flagged.
@JeiKeiLim

Copy link
Copy Markdown
Owner Author

Follow-up review (head 5514dd9) addressed in ffd4cdb:

Fixed:

  1. Mirror driftextractRubricJson is now exported from job-manager.ts and the end-to-end test imports the real parser instead of an inline mirror.
  2. Brace-slice fragility test — new test: a text event after the verdict containing braces (src/foo.ts etc.) must not break the {-} slice; asserts the verdict still parses. Pinned as a regression test rather than just a radar note.

Verified against the code (no change needed):

  1. Map typing — already new Map<string, Job>() at job-manager.ts:1046; the '' bucket is excluded by the expectedStages.has(stage) filter that runs before dedup.
  2. Tie-break determinismgetEvalsForSource is ORDER BY created_at ASC (state-store.ts:903), so iteration order is deterministic and the >= tie-break's "later-seen wins" promise holds. No sort needed.

Agreed, tracked as follow-up (not in this PR):

  • Round-id stamp so the gate can key on "newest round with complete coverage" instead of per-stage mixing — the one real design hole.
  • The {-} slice fragility in extractRubricJson itself (last-balanced-object scan) — now covered by a regression test, but the deeper fix is a separate change.

JeiKeiLim and others added 23 commits August 4, 2026 10:41
…tage mixing

tenet_start_eval now stamps every critic in one dispatch with a shared
eval_round UUID. The blocking-finding resume gate keys on the NEWEST round
only: it finds the newest round by max createdAt, reads that round's own
expected_eval_stages stamp, and requires every stage in it to be present +
completed + passed — all from critics sharing that round id. No mixing
across code revisions: a round-1 test_critic PASS can never combine with a
round-2 code_critic PASS.

If any sibling predates the stamp (existing DBs), the gate falls back to
per-stage-newest so old stuck parents still recover via the adapter fix.

C4 (startEval-stamped): round 1 fails → blocked, round 2 all pass → resumes.
C5 (manual stamp): round 1 code_critic FAIL + test_critic PASS, round 2
code_critic PASS + no test_critic → stays blocked. Verified red against
per-stage (which unblocks = cross-round mixing on different code states).
Replaces extractRubricJson's first-{ to last-} slice (which spanned multiple
objects or prose and returned null whenever any earlier text contained
braces) with a brace-matching scan that returns the rightmost JSON object
carrying a boolean passed key — the shape every critic preamble mandates.

Robust against: prose quoting code (try { } isn't valid JSON), balanced
non-verdict objects ({"mode": "strict"} has no passed key), and trailing
notes after the verdict. Verified against the production run: e2e-4's 6/7
critics now parse PASS (qa_explorer genuinely has no verdict — NULL is
correct), e2e-1 round 3 fully green except the same no-verdict critic.

Tests: brace test now uses real braces (was parentheses — passed for the
wrong reason); new tests for the passed-key discriminator and the
no-verdict NULL case.
Review round 2 found a real MAJOR: findRightmostPassedObject could pick a
nested or trailing passed-bearing object (custom critics embed assertions
like {"assertions": [{"passed": true}]}; tool results quoted in prose
carry nested passed keys). Both directions were wrong: a failing verdict
followed by a nested passed:true false-greened the gate; a passing verdict
followed by a nested passed:false stranded it.

The scan now counts only TOP-LEVEL objects (stack empty after the pop).
The production custom-critic shape is a single top-level object, so its
nested assertions are still contained and it parses correctly. Verified
against repro cases t1/t2 plus the production shape.

Also: whole-string JSON path now requires a boolean passed key (was
returning any object/array); round-selection tie uses >= for same-ms
determinism; allStamped requires non-empty eval_round so a ''-stamped
critic routes to the per-stage fallback instead of vanishing; spawn-error
path now collapses output like the other paths.

New tests: nested passed never overrides top-level verdict (t1/t2),
custom-critic nested-assertions shape.
…rent resumes after last critic)

Covers the untested sequential path: startEval with eval_parallel_safe=false
chains test_critic and interaction_e2e as pending behind code_critic
(parentJobId set, eval_round stamped, not running yet), and the parent only
resumes after the whole chained round completes. Round 1 with a failing
code_critic keeps the parent blocked.
- Shared rubric module (src/core/rubric.ts): drop the fenced-first fast path
  (a fenced tool echo before the verdict could false-green the gate), prefer
  staged verdicts over stage-less echoes, and recover from unbalanced braces
  in prose so a stray { no longer strands the parent.
- Round gate: run whenever any stamped round exists (was: every sibling
  stamped) so an ad-hoc/legacy unstamped critic can't fall back to the
  cross-round-mixing per-stage path.
- tenet_get_status: use the shared parser so latest_e2e_status survives prose
  braces.
- Tests: rubric unit cases (fenced-echo, echo, unbalanced brace, stage
  preference), C7/C8/C9 round-gate regressions, D4 status regression.

Co-Authored-By: Claude <noreply@anthropic.com>
…n unbalanced input

- scanTopLevel now tracks [] alongside {} so an object wrapped in a top-level
  array ([{"passed": true}]) is never treated as a verdict — the whole-string
  fast path already rejected arrays, the scan now agrees.
- The unbalanced-brace recovery only runs when the scan left the stack
  non-empty. Previously it fired on any balanced output with no top-level
  verdict and could pick up a nested passed object (false-strand/false-green).

Co-Authored-By: Claude <noreply@anthropic.com>
…nce; singleton rounds for unstamped critics

Round-2 review found the brace-recovery fallback could false-green the gate:
- It sliced each { to the FIRST } (not the matching one), so a verdict with
  nested objects in findings was sliced unterminated and stranded the parent.
- It had no stage-preference or top-level check, so a passing tool echo after
  a failing verdict (with a stray { in prose) was returned as the verdict and
  unblocked the parent on a false-green.
Now the recovery walks { positions from the end, parses each to its matching }
(bracket/string-aware), and applies the same accept/prefer semantics as the
strict scan.

Also closes the unstamped blind spot: unstamped siblings (ad-hoc tenet_start_job
re-fires, legacy pre-stamp evals) become singleton rounds keyed by job id, so a
NEWER unstamped critic forces the gate to wait for a fresh stamped round
(fail-closed) instead of being invisible while the parent unblocks on an older
round's stale green.

Tests: recovery cases (echo, nested findings, finding-carries-passed), NDJSON
non-string part.text guard, C10 (newer unstamped RED critic keeps the parent
blocked — verified red against the old skip behavior).

Co-Authored-By: Claude <noreply@anthropic.com>
…ion, gate empty-stamp fail-open

Loop-round review found three more rubric-parser holes and one gate edge:
- A stage-less passing echo BEFORE a stray { short-circuited the recovery
  (best ?? recovery), so a failing verdict hidden by the stray brace was
  ignored and the gate false-greened. Now the recovery always runs when the
  stack is unbalanced and its staged verdict wins over the strict scan's echo.
- The recovery walk used lastIndexOf('{', i-1), which clamps -1 to 0 — a { at
  position 0 looped forever once the walk no longer broke on stray braces.
  Rewritten as an explicit while loop with an i===0 break.
- A trailing stray { after the verdict broke the walk before reaching the
  verdict (false-strand); the walk now skips strays and continues.
- findRightmostTopLevelObject (tenet_get_status) accepted any top-level object,
  so a valid-JSON tool echo after the e2e verdict falsified/dropped
  layer2_status; it now prefers staged objects like the gate parser.
- Round gate: a malformed expected_eval_stages stamp filtering to an empty set
  made both gate loops pass trivially (fail-open); now falls back to
  DEFAULT_EVAL_STAGES.

Tests: echo-before-stray-brace, trailing-stray-brace, get_status echo
(rubric.test.ts); C11 malformed-stamp fail-open (integration.test.ts, verified
red against the old behavior).

Co-Authored-By: Claude <noreply@anthropic.com>
…cancelled paths; sync docs

Loop-round review found:
- recoverFromUnbalancedBraces walked { right-to-left but overwrote
  bestPreferred/bestAny on every accepted object, so the LEFTMOST staged
  object won — inverting the rightmost-verdict intent. With two staged
  objects and a stray brace, a passing echo before the verdict false-greened
  the gate (or a stale failing verdict false-stranded it). Now only the first
  accepted object per class (the rightmost) is kept.
- findMatchingClose's string-state handling (escaped quotes, unterminated
  strings) was correct but untested — added recovery tests pinning it.
- A cancelled critic in the newest round was untested — added C12 (parent
  stays blocked).
- Stale docs: the in-code comment and PR body said unstamped siblings are
  "ignored", but the shipped code makes them fail-closed singletons. Both
  updated to describe the actual behavior.
- Removed a duplicate findRightmostTopLevelObject describe block in
  rubric.test.ts (leftover from an earlier edit).

Co-Authored-By: Claude <noreply@anthropic.com>
… gate parser

Loop-round review found the recovery accepted nested and array-wrapped objects:
- A nested object inside the real verdict carrying passed+stage won over the
  verdict (false-green); an array-wrapped staged echo won; a stage-less verdict
  followed by a nested passed:true echo lost. The recovery now applies
  isTopLevelish: it rejects objects inside arrays or inside another VALID JSON
  object, but accepts the verdict behind a stray prose brace (whose enclosing
  slice is not valid JSON).
- A stray { balanced by a stray } after the verdict left the stack balanced so
  the recovery never ran (false-strand). The gate now runs the recovery
  whenever the strict scan found nothing, not only when the stack is
  unbalanced.
- tenet_get_status used a different accept predicate (findRightmostTopLevelObject)
  than the gate, so the two consumers could select different objects from the
  same output. It now uses extractRubricJson — the e2e verdict carries both
  passed and layer2_status, so the consumers cannot drift apart.
- PR body: corrected the claim that the NDJSON collapse matches claude --print
  shape (collapse joins all text parts; claude returns only the final message).

Tests: nested passed+stage, array-wrapped staged echo, stage-less + nested
echo, balanced stray pair (rubric.test.ts); D5 unbalanced-brace status path
(integration.test.ts).

Co-Authored-By: Claude <noreply@anthropic.com>
…nds can't self-stamp; drop dead parser

Loop-round review found:
- isTopLevelish only handled braceDepth 0/1, so a verdict behind TWO+ stray
  braces (e.g. a truncated `if (x) { if (y) {` snippet) was rejected and a
  passing echo before it false-greened the gate. Rewritten with a stack: the
  object is top-level-ish unless its INNERMOST enclosing brace forms a valid
  JSON object (a nested finding/echo) or it sits inside an array.
- An unstamped singleton (ad-hoc re-fire) could carry its own
  expected_eval_stages: ['code_critic'] stamp and satisfy the gate on one
  critic's verdict. Unstamped rounds now always require the full
  DEFAULT_EVAL_STAGES — only stamped rounds' stamps are trusted.
- findRightmostTopLevelObject was dead in production (tenet_get_status uses
  extractRubricJson) but its tests gave false confidence. Removed it; the
  status-surface tests now exercise extractRubricJson, including the
  fail-closed non-passed-verdict case.

Tests: two-stray-brace + echo, two-stray-brace no echo (rubric.test.ts);
C13 self-stamping singleton (integration.test.ts, verified red against the old
behavior).

Co-Authored-By: Claude <noreply@anthropic.com>
…-echo limitation

Loop-round review found:
- isTopLevelish rejected any object with an unclosed [ before it, so a stray [
  in prose (truncated list/code) stranded a valid verdict. It now mirrors the
  brace logic: the object is top-level-ish unless its innermost enclosing
  bracket forms a valid array.
- The per-stage fallback gate was fail-open to ad-hoc re-fires: a green
  single-critic re-fire created long after the round masked a red original
  (latestByStage picks it as the newest for its stage), and a self-serving
  single-stage expected_eval_stages stamp satisfied the gate on one critic.
  Added two guards: the newest critic per stage must be created within
  COHORT_WINDOW_MS of the completing critic (a full re-evaluation dispatches
  synchronously), and a single-stage "round" is a partial re-evaluation.
- Documented a known limitation: a staged echo (quoted prior verdict) after a
  failing verdict wins — the parser cannot distinguish a real verdict from a
  quoted one by shape; the preamble mandates the verdict at the end.

Tests: stray-[ recovery, staged-echo limitation (rubric.test.ts); C14 cohort
guard, C15 self-serving stamp (integration.test.ts, both verified red without
the guards).

Co-Authored-By: Claude <noreply@anthropic.com>
…onsensus stamp

Loop-round review found:
- findRightmostPassedObject short-circuited `if (best && !unbalanced)`, so a
  stage-less passing echo + a balanced stray brace pair hid a staged failing
  verdict (false-green). The recovery now runs whenever best is not a staged
  top-level verdict.
- The per-stage fallback trusted the NEWEST sibling's expected_eval_stages
  stamp, so a self-serving partial stamp on an ad-hoc re-fire could exclude a
  red stage and unblock on a partial re-evaluation. It now uses the stamp
  shared by the MOST siblings (the roster at dispatch) — a legitimate dispatch
  stamps every critic identically, so disabled built-ins and custom critics
  still work, while a single self-serving stamp cannot shrink the roster.
- The COHORT_WINDOW_MS blind spot for re-fires created shortly after the round
  was narrowed (5s -> 1s) and documented as a heuristic.

Tests: echo + balanced pair (rubric.test.ts); C16 self-serving 2-stage stamp
(verified red against newest-sibling stamp-trusting), C17 unstamped-older-than-
newest-round (integration.test.ts).

Co-Authored-By: Claude <noreply@anthropic.com>
…n-round consensus; pin tie-break

Loop-round review found:
- isTopLevelish never checked whether the candidate's { sits inside a string,
  so a string-quoted JSON object (a quoted tool result or prior verdict) was
  accepted as a real verdict and could false-green the gate. Now rejects
  objects whose opening brace is inside an unclosed string.
- The per-stage fallback's presentStages.size < 2 guard (added earlier to block
  self-serving single-stage stamps) stranded legitimate 1-critic rosters in
  all-legacy DBs — a regression vs main and inconsistent with the round gate,
  which has no minimum. Removed; a single-critic roster now unblocks (the
  self-serving single-critic case is indistinguishable by shape and shared by
  both gates).
- The round gate trusted a STAMPED singleton round's own expected_eval_stages,
  so a forged re-fire (eval_round + self-serving stamp) could bypass the
  whole-round invariant. A stamped singleton now uses the consensus stamp
  across all siblings (the roster at dispatch); multi-critic rounds still trust
  their own stamp.
- The >= tie-break in newest-round selection was unpinned by any test. Added
  C18, which forces a same-ms createdAt tie via DB rewrite and asserts the
  newer round wins (verified red against >).
- Fixed the stale resolveExpectedEvalStages JSDoc (said "newest round's stamp
  is authoritative"; the implementation uses the consensus).

Tests: string-quoted object (rubric.test.ts); C18 tie-break (integration.test.ts).

Co-Authored-By: Claude <noreply@anthropic.com>
…nstamped-originals consensus

Loop-round review found my round-7 singleton fix was broken and two more holes:
- The stamped-singleton consensus was dead code: `currentRound.length > 1` made
  stamp undefined for a stamped singleton, so expectedStages fell back to
  DEFAULT_EVAL_STAGES (3 stages) and a legitimate 1-critic roster stranded
  forever. Now a stamped singleton uses the consensus stamp across all
  siblings (the roster at dispatch); unstamped rounds still use DEFAULT.
- mergeStrictAndRecovered returned `strictBest ?? recovered` when neither
  object is staged, so a stage-less echo before a stray brace masked the
  rightmost stage-less verdict (false-green/strand). The recovery's rightmost
  result now wins (the "verdict at the END" preamble).
- resolveExpectedEvalStages let a single partial-stamp ad-hoc re-fire become
  the consensus when the originals were unstamped (legacy shape), excluding a
  red stage. A stamp shared by only ONE sibling while others are unstamped now
  falls back to DEFAULT_EVAL_STAGES.
- Documented the forged multi-critic round limitation (defense-in-depth) and
  corrected the overstated "cannot drift apart" comment in tenet_get_status
  (the parser is unified; job selection still differs).

Tests: stage-less echo + stray brace (rubric.test.ts); C19 stamped 1-critic
round unblocks, C20 unstamped-originals + partial-stamp (integration.test.ts,
both verified red against the old behavior).

Co-Authored-By: Claude <noreply@anthropic.com>
…ster; per-stage status-guard test

Loop-round review found:
- isTopLevelish treated a TRUNCATED enclosing JSON object (a context-limit kill
  cut it mid-JSON) as a stray prose brace, so a nested passing object inside it
  was accepted as the verdict (false-green). Now distinguishes a truncated JSON
  object (a quoted key followed by a colon) from a stray prose brace.
- The stamped-singleton consensus (added to block forged singletons) stranded a
  legitimate roster shrink: a 1-critic dispatch after a larger round was
  outvoted by the older round's consensus and never unblocked. A stamped round
  now trusts its OWN stamp (the current roster), honoring roster changes; the
  forged-singleton attack (deliberate forgery via tenet_start_job) is documented
  as a defense-in-depth limitation. Removed a leftover verifier probe test that
  asserted the opposite trade-off.
- The per-stage fallback's status guard (s.status !== 'completed') was untested
  with a non-completed critic. Added C22 (a job-level failed critic with stored
  passing output cannot unblock).
- Documented the mid-round re-fire cohort-window limitation (fail-closed,
  recoverable).

Tests: truncated-enclosing (rubric.test.ts); C21 roster shrink, C22 per-stage
status guard (integration.test.ts, both verified red against the old behavior).

Co-Authored-By: Claude <noreply@anthropic.com>
…t and adapter divergence

Loop-round review found:
- isTopLevelish's bracket branch returned true for a TRUNCATED array without
  consulting the brace stack, so a nested passing object inside a valid brace
  object within the truncated array was accepted as the verdict (false-green).
  The bracket branch now falls through to the brace check when the bracket is
  truncated or its slice is prose, so the object is still rejected if nested
  inside a valid brace object.
- Documented the balanced-stray-pair short-circuit limitation (a later verdict
  inside a balanced pair is ignored when a staged verdict was found — the
  trade-off that protects against quoted prior verdicts).
- Documented the opencode adapter's error-path divergence (collapse on all
  paths vs claude/codex raw stdout on failure — observability only).
- Corrected the tenet_get_status comment to mention the per-stage fallback.

Tests: truncated-array, balanced-pair limitation (rubric.test.ts).

Co-Authored-By: Claude <noreply@anthropic.com>
…ritic cell

Loop-round review found two test gaps:
- The opencode NDJSON collapse on the timeout / non-zero-exit / spawn-error
  paths (a documented divergence from claude/codex) was pinned by no test —
  every adapter test used exit code 0. Added tests for all three failure
  paths.
- The per-stage fallback's status guard was tested with a job-level FAILED
  critic (C22) and the round gate with a cancelled one (C12), but the
  per-stage + cancelled combination was the missing cell. Added C23 (a
  cancelled critic in the fallback keeps the parent blocked — the output
  check also catches it, since cancelJob stores no output).

Co-Authored-By: Claude <noreply@anthropic.com>
Loop-round review found two fail-open holes:
- resolveExpectedEvalStages adopted a partial expected_eval_stages stamp
  whenever it was shared by 2+ siblings, even when all the originals were
  unstamped legacy critics — two ad-hoc re-fires carrying the same partial
  stamp shrank the roster and excluded a red stage. The stamp is now adopted
  only when shared by a MAJORITY of the total siblings (or all are stamped).
- The round gate keyed on each round's MAX createdAt, so an unstamped ad-hoc
  critic created BETWEEN a stamped round's critics (after the round started,
  before its last critic) was assigned to an older singleton round and never
  forced the gate to wait — the parent unblocked on the round's stale green.
  The gate now keys on the round's START (min createdAt), so a newer ad-hoc
  evaluation is never invisible.

Tests: C24 (2+ partial-stamp re-fires), C25 (ad-hoc between a round's critics)
— both verified red against the old behavior.

Co-Authored-By: Claude <noreply@anthropic.com>
…ex; stale docs

Loop-round review found:
- isTopLevelish fell through to the brace check when the enclosing bracket was
  truncated, so an object DIRECTLY inside a truncated top-level array was
  treated as top-level-ish (false-green). Now rejects objects whose enclosing
  truncated array starts with { (a stray [ in prose still recovers).
- looksLikeJsonObject's regex failed on keys with escaped quotes, so a
  truncated enclosing object with such a key was misclassified as a stray
  prose brace (false-green). The regex now handles escaped characters.
- Fixed stale docs: the round-gate header still said max-createdAt selection
  (the code keys on the round's start), and the recovery JSDoc still claimed it
  only runs on an unbalanced stack.
- Added a test for the inString guard (quoted verdict inside an unclosed
  string), the only untested branch of isTopLevelish.

Tests: truncated-array direct object, escaped-quote key, inString guard
(rubric.test.ts).

Co-Authored-By: Claude <noreply@anthropic.com>
Extract 12 real critic outputs from the production DB with their
ground-truth verdicts (established with a simple rightmost-object walk). This
is the regression baseline for the planned parser simplification: it ensures
any refactor behaves the same on REAL data, not just synthetic fixtures.

11/12 pass with the current parser. 5248d039 is intentionally RED — the
current parser returns null because prose before the verdict contains an
unmatched double quote that confuses the string-state walk; the simpler
rightmost-walk handles it. This documents the bug the simplification fixes.

Co-Authored-By: Claude <noreply@anthropic.com>
Per the meta-review: the scan+merge+recovery three-way design (373 lines) was
disproportionate to the actual problem. The high-value core is a single
rightmost-object walk with matching-close + stage-preference + a top-level
check that rejects objects nested inside valid JSON containers. Removed
isTopLevelish's truncated-container/escaped-quote/inString machinery,
looksLikeJsonObject, mergeStrictAndRecovered, and the separate recovery pass.

This fixes a real production regression the old string-state walk caused: an
unmatched quote in prose (e.g. 'uv tool install "mkdocs-material') false-
rejected a valid verdict. The simpler parser handles it — verified against all
1659 production critic outputs (1465 parsed vs 1464, zero disagreements) and
the new golden test (12/12).

Accepted trade-offs (documented + tested): objects inside truncated containers
and JSON quoted in strings are now accepted — neither shape observed in
production, and the old guards caused the unmatched-quote regression. The
balanced-pair false-negative the two-pass short-circuit produced is gone.

Tests: rubric units 38 -> 35 (5 contrived pruned/consolidated), golden 12/12.

Co-Authored-By: Claude <noreply@anthropic.com>
…ontent

The golden fixture originally contained real critic outputs from a production
DB, which exposed company-specific project details (project names, file paths,
internal tooling). Replaced with rephrased, generic content that preserves the
parser-testing shapes (prose + verdict, fenced, tool echoes, truncated,
unmatched-quote-in-prose, raw NDJSON) and the same ground-truth verdicts. The
sensitive fixture was purged from git history.

Co-Authored-By: Claude <noreply@anthropic.com>
@JeiKeiLim
JeiKeiLim force-pushed the fix/opencode-output-and-blocking-finding-resume branch from 33da959 to 914ca61 Compare August 7, 2026 04:27
Resolve the PR conflict: main moved forward (75 commits) and changed the same
files the PR touches. Kept the PR's feature work (round-id stamping, consensus
roster, shared parser, C-series tests) where both changed the same lines, and
preserved main's auto-merged changes elsewhere. All 353 tests pass.

Co-Authored-By: Claude <noreply@anthropic.com>
@JeiKeiLim
JeiKeiLim merged commit 13a9590 into main Aug 7, 2026
1 check passed
@JeiKeiLim
JeiKeiLim deleted the fix/opencode-output-and-blocking-finding-resume branch August 7, 2026 07:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant