Skip to content

[Air #1474] Render gate: anchor the agy prompt marker to the cursor row and marker palette - #1491

Open
mohidmakhdoomi wants to merge 10 commits into
mainfrom
builder/air-1474
Open

[Air #1474] Render gate: anchor the agy prompt marker to the cursor row and marker palette#1491
mohidmakhdoomi wants to merge 10 commits into
mainfrom
builder/air-1474

Conversation

@mohidmakhdoomi

Copy link
Copy Markdown
Collaborator

Implements #1474.

What was actually wrong

AGY_MARKER = /^> / (gate-profiles.ts:72) treated any line starting with > as agy's
composer prompt, and findMarkerRow is last-match-wins. The issue called this "the weakest link"
in an empirically-derived profile. Measured against a real, authenticated agy 1.1.13 under a
PTY at 110×32, it is worse than a theoretical looseness — non-composer > rows are routine:

real screen last > row the old marker picked that row's fg cursor row the actual composer
idle (accept-edits) 11 palette 12 11 11 ✅
idle (no-hint mode) none — bare > never matched /^> / palette 12 11 11 ❌
draft 24 palette 12 24 24 ✅
slash menu (/) 13 — the menu's selection cursor palette 12 11 11 ❌
trust dialog 8 — the selected option palette 12 12 (off-row) none ❌
settled after an answer 20 palette 12 20 20 ✅
torn mid-repaint frame 10 — the transcript echo of the sent turn palette 4 varies absent ❌

Three findings drove the design:

  1. agy echoes every submitted turn into the transcript as a > line (SGR 34;1 → palette 4).
    Non-composer > rows accumulate one per conversation turn.
  2. The slash menu's selection cursor is also > , also palette-12, and renders BELOW the
    composer
    — so it won the marker scan outright. A color anchor alone does not separate
    these; the cursor row does.
  3. In agy's no-hint mode the composer is a bare >, which right-trims to ">" and never
    matched /^> / — so the gate held every message to an agy in that mode forever. A
    pre-existing false-HOLD, fixed here, since the issue's ask is that the marker identify the
    actual composer.

Markdown blockquotes turned out to render as , not > — so the issue's "quoted output" risk
arrives via the turn echo rather than via blockquotes.

Honest scope of the defect. I could not reproduce an actual false-CLEAN from the captures: on
every mis-bounded frame the wrong region still happened to contain counted text, so the verdict
landed busy anyway. What is demonstrated is that the classifier bounds the wrong region on
real screens
, and that getting busy out of a wrong region is luck rather than a guarantee. The
corruption risk is latent, not observed — stated plainly rather than overclaimed.

The change

Two optional, per-app GateProfile anchors — profile data, in keeping with the spike's
constraint 9 — set only on agy, both measured:

  • markerRequiresCursorRow: true — the marker row must hold the buffer cursor. Only the live
    input row does; menu rows, dialog options and transcript echoes never do.
  • markerFgPalette: 12 — the marker glyph's own color, which separates the composer from the
    palette-4 turn echo.

Plus the marker separator relaxed to /^>(\s|$)/ for the bare-> mode. claude/codex set neither
anchor and are byte-for-byte unaffected (pinned by a test).

Every failure direction is toward HOLD: a row that fails an anchor is simply not a marker, so
drift yields no-composer-marker → held and re-checked, never a false clean. Sustained holds
already escalate through the mailbox liveness telemetry (recordStreak).

Verdict changes on the real captures

fixture before after
agy-idle.clean clean clean
agy-draft.busy busy / user-text busy / user-text
agy-menu.busy busy / no-region-end (marker was the menu item — right verdict, wrong reason) busy / user-text (right region: the / typed in the composer)
agy-trust.busy busy / no-region-end (incidental — no rule under the option) busy / no-composer-marker (honest: there is no composer)
agy-torn-echo.busy busy / no-region-end (marker was the palette-4 echo) busy / no-composer-marker
agy-turn-echo.clean clean clean
agy-baremarker.clean busy / no-composer-marker (false HOLD) clean

Cost of the tightening, measured

Sweeping every byte-prefix of a real stream showed 70 CLEAN frames before vs 9 after — alarming
until you notice byte-prefix sampling cuts mid-repaint, which production never does. Sampling
the way production actually classifies (wall-clock, against a live agy driven through boot → idle
→ streaming → settled, classifying the mirror every 200 ms) the two profiles are identical:

idle       samples=  30   OLD clean=100%   NEW clean=100%
streaming  samples= 205   OLD clean= 98%   NEW clean= 98%
settled    samples=  40   OLD clean=100%   NEW clean=100%

No measurable false-HOLD cost, and the bare-> mode goes from permanently held to deliverable.

Fixtures

The issue asked for captured real-agy fixtures across states, and agy is authenticated in this
environment, so the three synthesized Phase-3 fixtures are replaced by seven real captures
(idle / bare-marker / draft / menu / trust / turn-echo / torn frame). agy's banner embeds the
account email and session cwd; both are replaced with same-length placeholders so the rendered
screen stays byte-for-byte equivalent — no attribute is retouched. Each file is 1.7–8.7 KB, so
they are committed plain rather than gzipped (gzip is used for the multi-hundred-KB claude
replays). The torn-frame fixture is real bytes cut mid-repaint, which is the tear shape #1361
documents for the adopt seed.

Tests

55 pass in render-gate.test.ts. New coverage: per-fixture verdicts for all seven states; the
reason for each (a mis-bounded region that returns busy is not the same as a correctly-bounded
one); synthetic branch tests for each anchor independently, including the palette-12 dialog option
with the cursor on it — the one shape the anchors cannot reject, which pins that the occupancy
count still catches it; and a guard that claude/codex are unaffected by cursor position.

porch check 1474: build ✓ (13.1s), tests ✓ (28.7s).

Notes for the reviewer

  • The cursor-row anchor assumes buffer.cursorY is viewport-relative, the same convention
    isGhostCursorCell already relies on. True whenever viewportY === baseY (no manual scrollback),
    which holds on both gate paths.
  • The one shape neither anchor rejects is a dialog that parks the cursor on a palette-12 > option.
    The trust dialog does not (measured), and the occupancy count plus the region-end guard both still
    catch it — but it is the seam to watch if agy adds dialogs.

🤖 Generated with Claude Code

mohidmakhdoomi and others added 5 commits August 17, 2026 19:44
… and marker palette

`AGY_MARKER = /^> /` treated any `> `-prefixed line as agy's composer. Measured against
real agy 1.1.13 under a PTY, that is not a hypothetical looseness: agy echoes every
submitted turn into the transcript as `> <message>` (palette-4), its slash-menu selection
cursor is also `> ` in palette-12 and renders BELOW the composer, and the trust dialog's
selected option is `> Yes, I trust this folder`. Since `findMarkerRow` is last-match-wins,
the menu item and the turn echo won the marker scan over the composer — so the classifier
bounded and scanned the wrong region on ordinary screens.

Adds two optional, per-app `GateProfile` anchors, both set only on agy and both measured:
`markerRequiresCursorRow` (the marker row must hold the cursor — only the live input row
does) and `markerFgPalette` (the marker glyph's own color, 12, which separates the composer
from the palette-4 turn echo). Also relaxes the marker separator to `\s|$`: agy's no-hint
mode renders the empty composer as a bare `>`, which `/^> /` never matched, so the gate
held every message to an agy in that mode forever.

Replaces the three synthesized agy fixtures with seven real, sanitized captures
(idle / bare-marker / draft / menu / trust / turn-echo / torn mid-repaint frame).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…er thread

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mohidmakhdoomi

Copy link
Copy Markdown
Collaborator Author

Test status, stated precisely

porch check 1474: build ✓ (13.5s), unit tests ✓ (28.6s) — 55 pass in render-gate.test.ts.

The e2e_tests gate check is a no-op here. AIR defines it as npm run test:e2e 2>&1 || echo 'e2e tests skipped (not configured)' and the repo root has no test:e2e script, so it reports ✓ in 0.1s having executed nothing. It is marked optional in the protocol, so that is intended — but it is not evidence, so I ran the real suite:

pnpm --filter @cluesmith/codev test:e2e
Test Files  1 failed | 19 passed | 5 skipped (25)
     Tests  3 failed | 171 passed | 21 skipped (195)

All 3 failures are in tower-api.e2e.test.tsPOST /api/terminals returning 500 where 201 is expected.

They are pre-existing and unrelated to this PR. Verified rather than asserted: I reverted both changed source files to the branch base (141b493), rebuilt, and re-ran that file — identical 3 failures. Files restored afterwards (all committed, so lossless; working tree verified clean against HEAD, unit tests re-run green).

I did not skip or annotate them as flaky, because they are neither mine nor intermittent — they reproduce consistently. Flagging for a maintainer instead. Plausibly environmental: this machine is running four-plus concurrent builder sessions plus Tower, and all three failures are PTY-spawn-through-the-API.

@mohidmakhdoomi

Copy link
Copy Markdown
Collaborator Author

Architect integration review — 3-way CMAP (risk tier: High — render-gate classifier, false-CLEAN direction)

Verdicts: gemini APPROVE · codex APPROVE · claude COMMENT ("merge it, with follow-ups") — all HIGH confidence, zero blocking correctness issues. Architect read concurs: the two positive-evidence anchors (markerRequiresCursorRow, markerFgPalette) fit the existing per-app-profile seam, every failure direction lands on no-composer-marker → HOLD, and claude/codex profiles are untouched. The real-capture measurement table is the load-bearing artifact, and the PR's refusal to overclaim (mis-bounding demonstrated, false-CLEAN latent; no-hint 'held forever' rests on live observation) is noted and appreciated.

Independently verified across the reviews: viewport-relative cursorY is sound on the production gate (viewportY === baseY for SessionScreen); the tightening's failure direction is observable (no-composer-marker feeds isClassifierStuck → escalation at streak 10); fixture sanitization is complete (no emails/paths survive); the bare-> relaxation is gated behind both anchors so it cannot loosen anything — and it fixes a real pre-existing total delivery outage in agy's no-hint mode.

Requested before the gate (one item)

  1. Add an agy case to the production-mirror-path suite (render-gate.test.ts:364). These are the first anchors that depend on cursor state, the one dimension where the transient path and the SessionScreen mirror path could conceivably diverge — a parity test (same fixture, same verdict via both paths) closes the only untested seam between what the tests prove and what production runs.

Follow-ups (this PR or noted for later — builder's call, say which)

  • arch.md:1795 still describes the agy gate as a placeholderFgPalette rule; the definition of "marker present" changed for agy. Two-line touch or explicit MAINTAIN deferral.
  • Commit the capture/sanitization harness (Spec 1313 precedent: codev/spir-1313-captures/*.mjs) so re-measuring against a future agy doesn't start from the PR description.
  • Cosmetic: required-fixtures test title still says "idle/draft/trust" while requiring seven.
  • Keep the dual 4-bit/256-color palette-12 matching — it is deliberate, not redundant (real fixtures use [94m, synthetic [38;5;12m).

For the maintainer

Parked for maintainer approval + merge; we are not maintainers.

…low-ups

CMAP requested item: the agy anchors are the first classifier input that depends on
CURSOR STATE, and the cursor is the one dimension where the transient replay path and the
long-lived SessionScreen mirror could diverge. Adds a per-fixture parity case to the
production-mirror-path suite — same bytes through both paths, same verdict — fed twice,
once in production-sized chunks and once in 7-byte chunks that deliberately split the
cursor-positioning CSI across feed() calls, since a mis-parsed cursor is now a verdict
change rather than a cosmetic one.

Follow-ups taken in-PR rather than deferred:
- arch.md: "marker present" now means more than a text match for agy — records the two
  anchors and their fail-toward-HOLD direction.
- Commits the capture + sanitization harness (codev/air-1474-captures/) so re-measuring
  against a future agy starts from a script, not the PR description. Verified by
  re-deriving four committed fixtures byte-identically from the raw captures.
- Fixes the sanitizer's leak check, which reported `/home/` on every run because the
  placeholder path itself starts with it — a check that always fires checks nothing. Now
  matches paths that are not the placeholder, and refuses to write on a leak.
- Cosmetic: required-fixtures test title listed three agy states while requiring seven.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mohidmakhdoomi

Copy link
Copy Markdown
Collaborator Author

CMAP items addressed — all four taken in-PR, none deferred

Requested before the gate ✅

agy case in the production-mirror-path suite (render-gate.test.ts). Agreed on the reasoning: these anchors are the first classifier input that depends on cursor state, so the transient-vs-mirror seam is newly load-bearing. Added a parity case per agy fixture (7 of them) rather than one representative — same bytes through both paths, toEqual on the whole verdict, plus an assertion that the transient path's own verdict matches the filename, so the parity check can't pass by both paths being wrong together.

Each fixture is fed twice: once in production-sized 64 KiB chunks, and once in 7-byte chunks that deliberately split escape sequences across feed() calls. That second pass is the one with teeth here — the cursor-positioning CSI (ESC[<row>;<col>H, and the relative ESC[2A/ESC[2C agy actually emits) is exactly what a torn chunk boundary would corrupt, and a mis-parsed cursor is now a verdict change rather than a cosmetic one. Both pass. 62 tests in the file; full package suite green (4876 passed / 48 skipped).

Follow-ups — taken, not deferred

  • arch.md ✅ — the agy entry now records that "marker present" is more than a text match, names both anchors, and states the fail-toward-no-composer-marker → HOLD direction.
  • Capture/sanitization harness ✅ — committed at codev/air-1474-captures/ (capture script, sanitizer, README covering auth prerequisite, per-state recipes, the 110×32 geometry constraint, and how the torn fixture is derived). Verified rather than assumed: re-ran the committed sanitizer against the raw captures and re-derived four committed fixtures byte-identically.
  • Cosmetic test title ✅ — now lists all seven agy states.
  • Dual 4-bit/256-color palette matching — kept as-is, per your note that it is deliberate.

One thing the harness work turned up

The sanitizer's leak check was self-defeating: it tested for the bare prefixes /home/ and /tmp/, but the replacement path is /home/agent/project, so it reported a leak on every successful run — including on the fixtures already in this PR. A check that always fires checks nothing, and I'd rather not ship that as the documented safety step. It now matches paths that are not the placeholder, and refuses to write the fixture if anything survives. Re-verified against all four captures: leaks=[], output byte-identical.

To be clear about what this did and didn't mean: the committed fixtures were always clean — I verified that independently with a direct grep before the first push, and the byte-identical re-derivation confirms it again. The defect was in the check, not in the fixtures.

Ready for the pr gate.

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.

Render gate: tighten the loose agy prompt marker (AGY_MARKER = /^> /)

1 participant