PR #770 — Teaching the Classifier to Check Its Work First

The goal at the end of the previous PR

Issue #720 documented a false-positive terminal-status incident: job-9460f9fc did extensive reads, edits, tests, and a commit, but synlynk still classified it permission_denied. Two of #720's three sub-projects had already shipped — the fail-closed empty-task guard, and the task-receipt protocol (PR #768) — leaving the actual classifier bug untouched. That work was re-filed standalone as issue #769, alongside two other sub-projects (scope enforcement and safe-caller docs) that remain unscoped. The goalpost at the end of #768 was: fix the root cause in _log_has_permission_denied_signature() itself.

What shipped in this PR

_log_has_permission_denied_signature() (synlynk/costs.py) has two detection paths: a phrase match against known denial strings, and a structured-event fallback that scans a job's JSON log lines in reverse. The bug lived in the second path — it returned True on the first denial-shaped event it found (status == "SUCCESS", empty response, num_turns <= 1) without checking whether any earlier event in the same log showed real activity. A harness can legitimately do substantial multi-turn work and then emit one trailing empty-response turn (a summary, a benign no-op); the old logic treated that final event's shape as sufficient proof of denial on its own.

Two layers of fix, per the approved design (docs/superpowers/specs/2026-08-07-permission-denied-classifier-fix-design.md):

Layer 1 — log-level corroboration (costs.py). New helpers _event_shows_real_activity() and _log_has_prior_activity_evidence() scan every line before the candidate denial-shaped event for a non-empty response on a SUCCESS event, or a tool_use block inside an assistant-typed message. If either is found, the classifier returns False — this event isn't proof of denial, it's a benign tail on a productive session. This is the only fix available to daemon_jobs reconciliation, which has no worktree_path to check git state against.

Layer 2 — git-state corroboration (jobs.py). At the two jobs-table reconciliation call sites (waitpid-reaped and dead-pid branches), git_state is already computed nearby. Both sites now reuse the existing _job_has_real_work_landed(git_state) helper as an independent second signal:

permission_denied = _log_has_permission_denied_signature(log_text)
if permission_denied and _job_has_real_work_landed(git_state):
    permission_denied = False

This catches jobs whose log-level signals are ambiguous but where a real commit or remote push proves the work happened. No new job status was introduced — a corroborated job simply falls through to normal exit-code/git-state status derivation, same as before the bug existed.

Dispatch, and a live instance of the bug it fixes

The plan (docs/superpowers/plans/2026-08-07-permission-denied-classifier-fix.md) was dispatched to Codex as a single job (job-f71bad21), covering both production files and three new test files end to end. It landed cleanly — 5 real commits, full test suite green (1686 passed at that point) — but the job's own final status came back permission_denied. Its last action, an MCP create_pull_request tool call, was auto-denied in its sandboxed dispatch environment, producing exactly the denial-shaped trailing event this PR corroborates against. Because the fix hadn't merged to main yet, the still-buggy classifier misjudged its own author.

Per the standing "never trust job status alone" discipline, the branch was verified directly (clean git log, pushed to origin/dispatch/codex/job-f71bad21) rather than treated as a failure, and PR #770 was opened by hand. The incident is documented in the PR body as a concrete before/after case for the fix.

Review and merge

Reviewed via the sanctioned COMMENT-review fallback (issue #423 — all dispatched agents share one GitHub identity, so gh pr review --approve fails as self-review on every dispatch-authored PR). synlynk pr check passed clean. A real merge conflict surfaced against tests/test_jobs.py where PR #768 (task-receipt protocol) had landed on main in the interim and inserted tests at the same anchor point — resolved by hand, preserving all five tests from both PRs, full suite re-verified (1705 passed) before the merge commit. Squash-merged as 2756395.

What this achieves toward #769 / #720

The classifier no longer treats a single ambiguous trailing event as sufficient proof of denial — it now requires the absence of corroborating evidence across the whole log, and independently across git state where available. Combined with the fail-closed empty-task guard and the task-receipt protocol already shipped from #720, dispatched-job status reporting is now meaningfully harder to fool in either direction (false denial, false success).

Next goalpost

#769 stays open. Two sub-projects remain unscoped: a SCOPE_VIOLATION job status enforced by comparing declared scope against actual changed files at completion (denying GitHub writes for design-only jobs that drift into code changes), and documentation for safe caller construction (passing task text as structured data rather than interpolated shell strings). Each needs its own brainstorm → spec → plan cycle before dispatch.