fix(eval): make opencode NDJSON output parseable + gate only latest eval round - #21
Conversation
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>
|
Full version of the follow-up review (head I've read the final state at head SHA PR #21 review — 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 —
Bug 2 — gate (
Tests
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 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.
|
Follow-up review (head Fixed:
Verified against the code (no change needed):
Agreed, tracked as follow-up (not in this PR):
|
…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>
33da959 to
914ca61
Compare
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>
Summary
Two bugs discovered while investigating a production run. Both produced the same symptom — a report-only parent stuck in
blocked_on_findingwith 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")
OpenCodeAdapterreturned the raw NDJSON event stream fromopencode run --format jsonasAgentResponse.output.extractRubricJsoncannot parse NDJSON — it triesJSON.parseon 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
textevent parts (all assistant text events, including intermediate reasoning + the trailing verdict; note claude--printreturns 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 viatenet_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.getEvalsForSourcereturns all rounds, and the gate required EVERY sibling to becompleted+passed:true. A failed critic in round 1 poisons rounds 2 and 3 forever (e2e-1 in the run: round-1interaction_e2eFAIL(false)blocked a fully-green round 3).Fix:
tenet_start_evalnow stamps every critic in one dispatch with a sharedeval_roundid; 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)
extractRubricJsonmoved to a shared module (src/core/rubric.ts) used by both the resume gate andtenet_get_status, so the two consumers of the same stored critic output can't drift apart. The parser is a single rightmost-object walk:{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 booleanpassedkey, preferring one that also carries astagekey (tool-result echoes rarely carrystage, so a failing critic that pastes a passing tool result after its verdict can't false-green the gate){before the verdict no longer strands the parent)tenet_get_statususes the same parser, solatest_e2e_statussurvives prose bracesTests
layer2_status)