diff --git a/SECURITY.md b/SECURITY.md index b8efb0ba..4f5f747b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1141,10 +1141,10 @@ separate browser consent. The explicit model off switch overrides both permissio The same SQLite store holds only that answer, model-attempt timestamps and, for 30 days, the random id of the job each attempt was charged to, never session ids or content. A transaction reserves -one of twelve reader-requested attempts in a rolling twenty-four hours before the model starts. Eligibility checks and a known missing CLI spend nothing; a failed +one of 100 reader-requested attempts in a rolling twenty-four hours before the model starts. Eligibility checks and a known missing CLI spend nothing; a failed or timed-out attempt keeps its reservation because the provider may already have spent capacity. Concurrent processes share the transaction bound. A denied or corrupt store fails closed. The page -reports the cap and the earliest time another attempt can be admitted. Turning permission off, +reports the cap, once it is spent, and the earliest time another attempt can be admitted. Turning permission off, back on, or forgetting it does not erase unexpired spend timestamps or refill the budget. The store is created owner-only; SQLite is required for this permission path. Deleting the file @@ -1187,7 +1187,7 @@ An answer saved before the split reads as the Codex answer it was. "Turn off rea `--forget` revoke every provider at once. So does an older build's Turn off, which knows only the legacy Codex row: triggers in the store's own schema clear every other provider's answer and every tool-output grant whenever that row is written as off, so a rollback and re-upgrade cannot bring a -Claude Code answer back. The twelve-attempt cap is shared between providers, so a +Claude Code answer back. The 100-attempt cap is shared between providers, so a second provider cannot double it, and a spent budget is reported as `daily-cap` ahead of a missing answer, whichever provider the status names. @@ -3716,7 +3716,7 @@ event ingress: the harm is a side effect on the operator's Codex balance rather about a session, and what holds it is the same-origin check and the refusal of a document navigation, neither of which a local process sends headers for. A local process can grant the remembered answer too, including the tool-output grant for the destination the board names, which -sends a Claude Code session's checks only to the reader's own configured vendor. The rolling cap of twelve attempts across tabs and processes bounds that +sends a Claude Code session's checks only to the reader's own configured vendor. The rolling cap of 100 attempts across tabs and processes bounds that exposure. One reading per session may be in flight, and the explicit model off switch refuses all calls. The route reads nothing back to the caller beyond the job it started or found running, which the payload diff --git a/compass/skills/compass/SKILL.md b/compass/skills/compass/SKILL.md index 38197061..9010a4c2 100644 --- a/compass/skills/compass/SKILL.md +++ b/compass/skills/compass/SKILL.md @@ -515,7 +515,8 @@ An older reading says coverage was not recorded. A tail starting inside the inte every intent row "may be in the part not read"; claims stay independent. On Claude Code an Analyze press reads the session's messages back to the window start where it can, and says where it stopped when it cannot. General scope and the -count-by-kind cutoff remain under What it read. Coverage holds only counts, time and closed state +count-by-kind cutoff remain under What the analysis read, which groups the model, your intent, the +session read and what is never read under their own headings. Coverage holds only counts, time and closed state tokens and changes no verdict. Each line reads "Departs; evidence #n", "Consistent with #n, as the tool reported; not inspected", "Consistent with what the agent said at #n; not a check" where it rests on the agent's messages alone, "Consistent with what the session said at #n; @@ -543,14 +544,15 @@ A withdrawn claim draws no row. A claim alone offers Steer back as a secondary e `You said "" at 14:02 (entry #12 in Compass's activity list); the record shows otherwise at 14:05 (entry #13 in Compass's activity list).` Short titles and bare URLs are not quoted in the correction. "Where the work went" groups the written files by folder. A current result offers no second run control. When your intent changed after it or new work arrived -since, the result gives that reason with one "Analyze again". The request count and daily cap stay -beside it, and -"Not accurate?" marks the reading with a token that is stored beside it and never sent or counted; -that reading no longer contributes to the level or Steer back until you clear the mark. A later +since, the result gives that reason with one "Analyze again". The request count stays beside it. +The shared budget is not shown until it is spent; then a banner holds the top of the session page, +naming the limit and the time the next analysis can run. "Mark as inaccurate" marks the reading with +a token that is stored beside it and never sent or counted, and says so beside the control; that +reading no longer sets the level or Steer back until you press Undo, and the level's slot says why +it is empty. A later direction alone offers no correction. A correction puts departures and failed checks first, dates a time from an earlier day, and asks for evidence where an outcome is not shown. Its button names -the triggering entry and age; the daily reading budget remains beside the control, and What it -read counts new work since the analysis. +the triggering entry and age, and What the analysis read counts new work since the analysis. The box offers Cancel, which stops the call and records a cancelled attempt that still counts, or one that spent nothing when it landed before anything was sent. A failed request says its result could not be confirmed and is never retried automatically. The counter reports recorded model requests, so a refusal before the model runs does not increase it. @@ -745,7 +747,7 @@ Paths 2 and 3 are complementary and can both be installed. Keep `Notification` o | `--frontend-dev-manifest PATH` | Contributor-only ticket for an owned Vite child, requiring a foreground server on `127.0.0.1`. Startup validates isolated fixture roots and a fresh child handshake; models, quota fetching and native actions are disabled. Diagnose/forget refuse this mode. Ordinary installed launches use the packaged page. | | `--claude-reading-model MODEL` | Select an explicit Claude Sonnet or Opus generation 5 or later ID for reader-requested Claude readings (default `claude-sonnet-5-5`). `claude-sonnet-5` is the baseline. Unavailable models fail without retrying another selection; aliases and lower or unknown families refuse before launch. This selection supplies no accuracy qualification. | | `--allow-tool-output` | Grant, at launch, sending a Claude Code reading's checks (the command, the result and the last 180 characters of output) and the paths it wrote, for the destination Compass names. The first press still asks for the words, and the disclosure says tool output was allowed when the dashboard started. `COMPASS_ALLOW_TOOL_OUTPUT=1` does the same, and `--no-observer-model` refuses it. The grant is kept like an Allow: Turn off readings and `--forget` clear it. | -| `--observer-model` / `--no-observer-model` | Offer optional Codex goal summaries, or refuse every model call for this run (refusal wins, including unasked checks). Goal summaries are off by default and retain their separate browser consent. On a goal-less Claude Code or Codex session the goal is drafted from the published first prompt (the latest where no first one with a time is published), and Analyze drift or Save intent adopts it. A session whose first prompt is a harness control such as `/clear` drafts nothing, says so, and never drafts a later prompt in its place. The goal field also offers the latest prompt without checking. Adopted goals say "from your prompt" and never enable unasked readings; editing the goal makes typed words. Reader-requested readings need no startup flag: the first Analyze drift presents its disclosure and Allow and analyze. The answer is remembered across tabs and restarts while the destination its disclosure named is unchanged, and asked for again once it moves; Turn off readings revokes it. They spend the capacity of the provider the disclosure names (Claude Code for a Claude Code session when `claude` is on PATH, Codex otherwise) and are capped at twelve reserved or actual attempts per rolling twenty-four hours. Failed attempts are not refunded; off/on and `--forget` do not reset that budget. Each prompt is bounded to 16,384 redacted bytes, with one call in flight per session; a reading has a 180-second timeout and a goal summary a 60-second one. Quota consent authorizes neither path. | +| `--observer-model` / `--no-observer-model` | Offer optional Codex goal summaries, or refuse every model call for this run (refusal wins, including unasked checks). Goal summaries are off by default and retain their separate browser consent. On a goal-less Claude Code or Codex session the goal is drafted from the published first prompt (the latest where no first one with a time is published), and Analyze drift or Save intent adopts it. A session whose first prompt is a harness control such as `/clear` drafts nothing, says so, and never drafts a later prompt in its place. The goal field also offers the latest prompt without checking. Adopted goals say "from your prompt" and never enable unasked readings; editing the goal makes typed words. Reader-requested readings need no startup flag: the first Analyze drift presents its disclosure and Allow and analyze. The answer is remembered across tabs and restarts while the destination its disclosure named is unchanged, and asked for again once it moves; Turn off readings revokes it. They spend the capacity of the provider the disclosure names (Claude Code for a Claude Code session when `claude` is on PATH, Codex otherwise) and are capped at 100 reserved or actual attempts per rolling twenty-four hours. Failed attempts are not refunded; off/on and `--forget` do not reset that budget. Each prompt is bounded to 16,384 redacted bytes, with one call in flight per session; a reading has a 180-second timeout and a goal summary a 60-second one. Quota consent authorizes neither path. | | `--interaction-origin-session ` / `--interaction-origin-registration-file ` | Enable the prototype terminal for one exact session only when both flags are supplied. The generated private file supplies the registration capability; registration must happen inside that session's tmux pane. Output is read-only and bounded; no terminal input is accepted. | | `--port N` | Change port (default 4553; valid range 1–65535). If the port is busy, check `--status` first — a running dashboard may already be there; don't kill it blindly. | | `--host A` | Bind address: `127.0.0.1` (default) or `0.0.0.0`, IPv4 only. Nothing narrower — a single-interface bind is refused rather than half-supported, because `--status`, `--stop` and the hook forwarders all reach the dashboard over loopback and such a bind does not answer there. **Nothing authenticates a remote reader**: anything that reaches the port reads every session's titles, prompts and paths, and can answer a question a session is waiting on. Prefer `ssh -L 4553:127.0.0.1:4553`; use `--host` only on a network the user would hand the transcripts to. | diff --git a/compass/skills/compass/compass_runtime/reading.py b/compass/skills/compass/compass_runtime/reading.py index 214df043..ccbd477d 100644 --- a/compass/skills/compass/compass_runtime/reading.py +++ b/compass/skills/compass/compass_runtime/reading.py @@ -2044,7 +2044,7 @@ def _not_read(earlier: int, untimed: int, after_stop: int) -> str: f"{_entries(after_stop)} after the last observed stop " f"{'was' if after_stop == 1 else 'were'} not read" ) - return "".join(f"; {clause}" for clause in clauses) + return "".join(f" {clause[0].upper()}{clause[1:]}." for clause in clauses) def cutoff_text( @@ -2080,25 +2080,18 @@ def cutoff_text( unread_checks = int(measured.get("unread_checks") or 0) left_out = _not_read(earlier, untimed, after_stop) empty = ( - f"No entry in the observed record was read{left_out}." + "No entry in the observed record was read." if not total - else f"None of the {_entries(total)} after your words could be read{left_out}." + else f"None of the {_entries(total)} after your intent could be read." ) mix = _cutoff_mix(selected) - stamped = [row["at"] for row in selected if row["at"] > 0] - if not stamped: - window = "none of them carrying a usable time" - else: - hours = max(0, int((now - min(stamped)) // 3600)) - window = f"the oldest about {hours}h ago" if hours else "all within the last hour" - if len(stamped) != len(selected): - window += f", {len(selected) - len(stamped)} carrying no usable time" + window = _cutoff_window(selected, now) partial = tail_truncated is True and tail_start is not None and tail_start > window_start > 0 tail_note = ( - "The message tail starts " - f"{time.strftime('%Y-%m-%d %H:%M UTC', time.gmtime(tail_start))}; " + " The message tail starts " + f"{time.strftime('%Y-%m-%d %H:%M UTC', time.gmtime(tail_start))}, so " f"about {max(1, math.ceil((tail_start - window_start) / 60))} min at the start " - "of the window were outside it." + "of the window were not read." if partial else "" ) @@ -2110,13 +2103,12 @@ def cutoff_text( f" {early} agent message{'s' if early != 1 else ''} that closed an earlier turn " f"{'were' if early != 1 else 'was'} kept." ) + read = _cutoff_read(len(selected), total, partial=partial) opening = ( - f"Read {len(selected)} entries. " + tail_note - if partial - else f"Read {len(selected)} of the {_entries(total)} available after your words." + f"{read}: {mix}. {window}{tail_note}{left_out}" + if selected + else empty + tail_note + left_out ) - if not selected: - opening = empty + (" " + tail_note if partial else "") omissions = [] if tail_truncated is True and measured.get("tail_start") is None: # The page says this too. A read that stopped before the window with nothing in it to name @@ -2131,11 +2123,40 @@ def cutoff_text( omissions.append( f"{unread_checks} check{'s' if unread_checks != 1 else ''} had no room in the prompt" ) - return ( - opening.rstrip(".") + left_out + f". Of those read, {window}: {mix}." - if selected - else opening - ) + (" " + "; ".join(omissions) + "." if omissions else "") + if not omissions: + return opening + tail = "; ".join(omissions) + return f"{opening} {tail[0].upper()}{tail[1:]}." + + +def _cutoff_window(selected: Sequence[LedgerEntry], now: float) -> str: + """How old the entries read are, as a sentence.""" + stamped = [row["at"] for row in selected if row["at"] > 0] + one = len(selected) == 1 + if not stamped: + return "It has no usable time." if one else "None of them has a usable time." + hours = max(0, int((now - min(stamped)) // 3600)) + window = ( + ("It is" if one else "The oldest is") + f" from about {hours}h ago" + if hours + else ("It is" if one else "All are") + " from within the last hour" + ) + if len(stamped) != len(selected): + window += f"; {len(selected) - len(stamped)} have no usable time" + return window + "." + + +def _cutoff_read(read: int, total: int, *, partial: bool) -> str: + """The cutoff's opening clause: how many of the entries after the intent were read.""" + if partial: + return f"Read {_entries(read)}" + if read == total: + return ( + f"Read all {total} entries after your intent" + if total > 1 + else ("Read the 1 entry after your intent") + ) + return f"Read {read} of the {_entries(total)} after your intent" def _cutoff_mix(selected: Sequence[LedgerEntry]) -> str: @@ -2145,27 +2166,29 @@ def _cutoff_mix(selected: Sequence[LedgerEntry]) -> str: subject, result = row.get("subject"), row.get("result") name = ( ( - "passed" + "passed check" if result == "passed" - else "failed" + else "failed check" if result == "failed" - else "run with no recorded result" + else "check with no recorded result" ) if subject == CHECK_SUBJECT - else ("file written" if subject == WRITE_SUBJECT else "launch") + else ("file write" if subject == WRITE_SUBJECT else "launch") ) elif row["author"] == AUTHOR_PERSON: - name = "you wrote" + name = "from you" elif row["author"] == AUTHOR_DERIVED: - name = "Compass derived" + name = "derived by Compass" else: - name = "message the agent wrote" if row["type"] == AGENT_MESSAGE_TYPE else "agent event" + name = "message from the agent" if row["type"] == AGENT_MESSAGE_TYPE else "agent event" counts[name] = counts.get(name, 0) + 1 plurals = { - "message the agent wrote": "messages the agent wrote", + "message from the agent": "messages from the agent", "agent event": "agent events", - "run with no recorded result": "runs with no recorded result", - "file written": "files written", + "passed check": "passed checks", + "failed check": "failed checks", + "check with no recorded result": "checks with no recorded result", + "file write": "file writes", "launch": "launches", } return ", ".join( diff --git a/compass/skills/compass/compass_runtime/reading_policy.py b/compass/skills/compass/compass_runtime/reading_policy.py index 8d2c1f26..d6392c60 100644 --- a/compass/skills/compass/compass_runtime/reading_policy.py +++ b/compass/skills/compass/compass_runtime/reading_policy.py @@ -23,7 +23,7 @@ from .config import RuntimeConfig DAY_SEC = 86_400.0 -DAILY_CAP = 12 +DAILY_CAP = 100 # How long the job ledger keeps the id each charge was made for (DRC-4713). # Far past `DAY_SEC`, because the budget reads only `spends`: a restart more # than a day after a crash must still find the row, or it would call a charged diff --git a/compass/skills/compass/compass_runtime/web/react.html b/compass/skills/compass/compass_runtime/web/react.html index 89783df8..2d560177 100644 --- a/compass/skills/compass/compass_runtime/web/react.html +++ b/compass/skills/compass/compass_runtime/web/react.html @@ -2,9 +2,9 @@ Compass - + - +
diff --git a/compass/skills/compass/compass_runtime/web/react.integrity.json b/compass/skills/compass/compass_runtime/web/react.integrity.json index 3bd89ba5..470e92a3 100644 --- a/compass/skills/compass/compass_runtime/web/react.integrity.json +++ b/compass/skills/compass/compass_runtime/web/react.integrity.json @@ -1,8 +1,8 @@ { "document": { - "bytes": 1843467, + "bytes": 1847871, "file": "react.html", - "sha256": "b5a14fa8590b03b7e4929e8687484e1074474733678d4398d3331690ad7ac31a" + "sha256": "a15ff58d698179a3c04937016912c7115f41968a2a06bbfee38ed22c2f536e2b" }, "format": 1, "frontend": "react", @@ -535,11 +535,11 @@ }, { "file": "frontend/src/drift/DriftBody.tsx", - "sha256": "b29e45bec0bd1174eddbcb489465f5d3def44ee59480bc4d93b20f8c7433ad8e" + "sha256": "2625888b781c07721b24deb1d7dde87b70bd837412d6dc29f5365c762c4a3155" }, { "file": "frontend/src/drift/DriftLevelView.tsx", - "sha256": "66754e587fb6470c290f378dd5e5eca14f6f70d3492c0bf09b66706bf21e642e" + "sha256": "9f676b03c29dacf040f8d21a2bd469792a25cad469071548b602e9083649fd79" }, { "file": "frontend/src/drift/DriftProvider.tsx", @@ -549,13 +549,17 @@ "file": "frontend/src/drift/Evidence.tsx", "sha256": "c25a7f24f7317cdbac4d1ac508cc0f453ef33bce6b572c62341c3fac412830c4" }, + { + "file": "frontend/src/drift/ReadingCapBanner.tsx", + "sha256": "06e4d45d6adaf821a62a35636b0de9de2f2d46ed19b1c3b875b6ddceb51010df" + }, { "file": "frontend/src/drift/ReadingControl.tsx", - "sha256": "6298d3c688a300716c21240e59c4a1e13556f32a665e0fdb63f04837fa867b26" + "sha256": "d144c7207e4874c2af39519729448de6e787a9954e1f38bc765fab43c6b0f0fc" }, { "file": "frontend/src/drift/ReadingResult.tsx", - "sha256": "4930beba198875ea8252cea4b547ca562bbd0d5c5fa84d36be62841b9dc5d4b4" + "sha256": "9b39fe43f18cd6843e89c33f05dc36a1a1fd40cc99e357449a03a6fe0c0f759c" }, { "file": "frontend/src/drift/ShowMore.tsx", @@ -591,7 +595,7 @@ }, { "file": "frontend/src/drift/drift.css", - "sha256": "6c1cdb928f67e29953918d6e9b3a5a063370b243d05f4b726f9feeef58df9390" + "sha256": "e26e7c9434c43ce150d38fa09ef6d9ca7be3d08771abec10dd92f9c317c5f6b1" }, { "file": "frontend/src/drift/editor.ts", @@ -607,7 +611,7 @@ }, { "file": "frontend/src/drift/index.ts", - "sha256": "13e33330f560f0a16e8673dd54a5d120822b668b99fa9f8ed722a53db6e22573" + "sha256": "c49bbb77072769bd6153bc59ee8b47853fb05d402cda1335ab5ebf797b2aadae" }, { "file": "frontend/src/drift/jobs.ts", @@ -631,11 +635,11 @@ }, { "file": "frontend/src/drift/route.ts", - "sha256": "7aea21af44d286ea924d4ecb08f3bedf17b76d8ab386521e7ac0fe99f2706756" + "sha256": "2566241dd2735d68b42e7d0ec5592de44dfe8baa12008a78a2d3255f78bec2ed" }, { "file": "frontend/src/drift/sentences.ts", - "sha256": "633ed5778dd5fcb8f0e80207b5734d9eb5163d0657dfb66f9f9ed123ceba7985" + "sha256": "160eae71ff7b01e70b2d8f0f52b9d5d0fa6cbf073609a4b3376b750cdee99f42" }, { "file": "frontend/src/drift/shape.ts", @@ -1063,7 +1067,7 @@ }, { "file": "frontend/src/sessions/SessionDetail.tsx", - "sha256": "7527120ff3ddfcb1483d346d2d7769b4851cb48bba647322b9c857cff7055cc7" + "sha256": "f78d156a05e3a02a41e60425a8dd5b424b067912b1c13c0e8780480b53b2a56a" }, { "file": "frontend/src/sessions/SessionsView.tsx", @@ -1095,7 +1099,7 @@ }, { "file": "frontend/src/sessions/sessions.css", - "sha256": "97ef194839535c5479945740b1328ed3bf3e5fabbb8c9bf5b10b9f629db2392c" + "sha256": "8466e3243965ca20695d59eac778a8d0cb65243530f9216d37936d3339b25175" }, { "file": "frontend/src/sessions/testing.tsx", @@ -1443,7 +1447,7 @@ }, { "file": "frontend/src/ui/alert.tsx", - "sha256": "7de8f6d251e4681d30599db39e45ba39662a43948f6802ce31328a214fe29a43" + "sha256": "802317f61bbe7860d96c665bbc7573a71551f08f14119eee16dd497f1e2b3806" }, { "file": "frontend/src/ui/badge.tsx", diff --git a/compass/skills/compass/tests/test_http_api.py b/compass/skills/compass/tests/test_http_api.py index fb2a4570..3d10c3ba 100644 --- a/compass/skills/compass/tests/test_http_api.py +++ b/compass/skills/compass/tests/test_http_api.py @@ -3019,12 +3019,12 @@ def test_lured_requests_cannot_change_remembered_permission(self) -> None: def test_daily_limit_refuses_before_call_and_reports_the_release_time(self) -> None: config, state = self._runtime() with self._counting_model() as calls, self._serving(self._app(config, state)) as port: - for _ in range(12): + for _ in range(reading_policy.DAILY_CAP): status, _ = self._post(port, self._press(allow=True, words_destination="")) self.assertEqual(202, status) status, body = self._post(port, self._press(allow=True, words_destination="")) self.assertEqual(429, status) - self.assertEqual(12, len(calls)) + self.assertEqual(reading_policy.DAILY_CAP, len(calls)) self.assertEqual(1_700_086_500.0, json.loads(body)["reading"]["retry_at"]) @staticmethod @@ -3671,7 +3671,7 @@ def test_a_refusal_at_the_model_seam_writes_nothing_and_ends_the_job(self) -> No config, state = self._runtime() def exhaust() -> None: - for _ in range(12): + for _ in range(reading_policy.DAILY_CAP): reading_policy.reserve(config, now=1_700_000_100.0) with ( diff --git a/compass/skills/compass/tests/test_last_turn.py b/compass/skills/compass/tests/test_last_turn.py index e8ffab7a..b56ddff5 100644 --- a/compass/skills/compass/tests/test_last_turn.py +++ b/compass/skills/compass/tests/test_last_turn.py @@ -782,10 +782,12 @@ def test_the_cutoff_counts_each_set_it_did_not_read(self) -> None: ] ) self.assertTrue( - assessment["cutoff"].startswith( - "Read 2 of the 2 entries available after your words; 1 earlier and 1 untimed entries were " - "not read; 1 entry after the last observed stop was not read. Of those read, " - ), + assessment["cutoff"].startswith("Read all 2 entries after your intent: "), + assessment["cutoff"], + ) + self.assertIn( + " 1 earlier and 1 untimed entries were not read. " + "1 entry after the last observed stop was not read.", assessment["cutoff"], ) @@ -800,16 +802,16 @@ def test_the_cutoff_says_how_many_entries_before_the_words_were_not_read(self) - ] ) self.assertTrue( - assessment["cutoff"].startswith( - "Read 2 of the 2 entries available after your words; 2 earlier entries were not read." - ), + assessment["cutoff"].startswith("Read all 2 entries after your intent: "), assessment["cutoff"], ) + self.assertIn(" 2 earlier entries were not read.", assessment["cutoff"]) assessment, _why, _spent = self.produce([_message("m1", PROMPT), _check(PROMPT + 60)]) self.assertTrue( - assessment["cutoff"].startswith("Read 2 of the 2 entries available after your words. "), + assessment["cutoff"].startswith("Read all 2 entries after your intent: "), assessment["cutoff"], ) + self.assertNotIn("not read", assessment["cutoff"]) def resolve(self, entries: list[reading.LedgerEntry], cites: list[int]) -> Any: return reading.resolve( diff --git a/compass/skills/compass/tests/test_reading.py b/compass/skills/compass/tests/test_reading.py index 922f80ce..42664fc7 100644 --- a/compass/skills/compass/tests/test_reading.py +++ b/compass/skills/compass/tests/test_reading.py @@ -1542,38 +1542,21 @@ def test_a_reader_is_told_how_many_entries_before_their_words_were_not_read(self entry(id="a", at=NOW - 60.0, type="agent_message"), entry(id="b", at=NOW - 60.0, type="agent_message"), ) - for counts, opening in ( - ( - {"earlier": 2}, - "Read 2 of the 3 entries available after your words; 2 earlier entries were not read. ", - ), - ( - {"earlier": 1}, - "Read 2 of the 3 entries available after your words; 1 earlier entry was not read. ", - ), - ( - {"untimed": 1}, - "Read 2 of the 3 entries available after your words; 1 untimed entry was not read. ", - ), - ( - {"earlier": 2, "untimed": 1}, - ( - "Read 2 of the 3 entries available after your words; 2 earlier " - "and 1 untimed entries were not read. " - ), - ), - ( - {"after_stop": 2}, - ( - "Read 2 of the 3 entries available after your words; 2 entries after the " - "last observed stop were not read. " - ), - ), + head = ( + "Read 2 of the 3 entries after your intent: 2 messages from the agent. " + "All are from within the last hour." + ) + for counts, clauses in ( + ({"earlier": 2}, " 2 earlier entries were not read."), + ({"earlier": 1}, " 1 earlier entry was not read."), + ({"untimed": 1}, " 1 untimed entry was not read."), + ({"earlier": 2, "untimed": 1}, " 2 earlier and 1 untimed entries were not read."), + ({"after_stop": 2}, " 2 entries after the last observed stop were not read."), ( {"earlier": 1, "after_stop": 1}, ( - "Read 2 of the 3 entries available after your words; 1 earlier entry was not read; " - "1 entry after the last observed stop was not read. " + " 1 earlier entry was not read. " + "1 entry after the last observed stop was not read." ), ), ): @@ -1586,16 +1569,14 @@ def test_a_reader_is_told_how_many_entries_before_their_words_were_not_read(self untimed=counts.get("untimed", 0), after_stop=counts.get("after_stop", 0), ) - self.assertTrue(sentence.startswith(opening + "Of those read, "), sentence) - self.assertTrue( - reading.cutoff_text(rows[:1], 1, NOW, earlier=1).startswith( - "Read 1 of the 1 entry available after your words; 1 earlier entry was not read. " - ) + self.assertEqual(head + clauses, sentence) + self.assertEqual( + reading.cutoff_text(rows[:1], 1, NOW, earlier=1), + "Read the 1 entry after your intent: 1 message from the agent. " + "It is from within the last hour. 1 earlier entry was not read.", ) plain = reading.cutoff_text(rows, 2, NOW) - self.assertTrue( - plain.startswith("Read 2 of the 2 entries available after your words. "), plain - ) + self.assertTrue(plain.startswith("Read all 2 entries after your intent: "), plain) self.assertNotIn("not read", plain) def test_a_reader_is_told_when_the_reading_rests_on_nobody_but_the_agent(self) -> None: @@ -1603,14 +1584,14 @@ def test_a_reader_is_told_when_the_reading_rests_on_nobody_but_the_agent(self) - entry(id="a", at=NOW - 60.0, type="agent_message"), entry(id="b", at=NOW - 60.0, type="agent_message"), ) - self.assertIn("2 messages the agent wrote", reading.cutoff_text(rows, 2, NOW)) + self.assertIn("2 messages from the agent", reading.cutoff_text(rows, 2, NOW)) mixed = ( person_entry(at=NOW - 60.0), entry(id="b", at=NOW - 60.0, type="agent_message"), derived_entry(at=NOW - 60.0), ) sentence = reading.cutoff_text(mixed, 3, NOW) - self.assertIn("1 you wrote", sentence) + self.assertIn("1 from you", sentence) self.assertIn("Compass", sentence) @@ -1996,7 +1977,7 @@ def test_a_reading_names_the_revision_it_read_and_not_the_first_one(self) -> Non self.assertEqual(3, assessment["revision_read"]) self.assertEqual(reading.SCOPE_MID_FLIGHT, assessment["scope"]) self.assertEqual(reading.SCOPE_TEXT[reading.SCOPE_MID_FLIGHT], assessment["scope_text"]) - self.assertIn("Read 1 of the 1 entry available after your words", assessment["cutoff"]) + self.assertIn("Read the 1 entry after your intent", assessment["cutoff"]) self.assertEqual({reading.CONSTRAINT_GOAL}, set(assessment["criteria"])) def test_a_reading_of_an_ended_session_records_the_end_it_rested_on(self) -> None: diff --git a/compass/skills/compass/tests/test_reading_coverage.py b/compass/skills/compass/tests/test_reading_coverage.py index b698a8bd..0166e6f7 100644 --- a/compass/skills/compass/tests/test_reading_coverage.py +++ b/compass/skills/compass/tests/test_reading_coverage.py @@ -178,16 +178,16 @@ def test_checks_and_writes_are_not_counted_as_agent_messages(self) -> None: producer.person_entry(), ) text = reading.cutoff_text(rows, len(rows), producer.NOW) - self.assertIn("1 message the agent wrote", text) + self.assertIn("1 message from the agent", text) for words in ( - "1 passed", - "1 failed", - "1 run with no recorded result", - "1 file written", + "1 passed check", + "1 failed check", + "1 check with no recorded result", + "1 file write", "1 launch", ): self.assertIn(words, text) - self.assertNotIn("6 the agent wrote", text) + self.assertNotIn("6 messages from the agent", text) def test_a_partial_tail_names_the_missing_interval_instead_of_a_false_total(self) -> None: rows = (producer.entry(type="agent_message", at=producer.NOW - 60),) @@ -200,7 +200,7 @@ def test_a_partial_tail_names_the_missing_interval_instead_of_a_false_total(self ) self.assertIn("78 min", text) self.assertIn("message tail", text) - self.assertNotIn("of the 1 entry after your words", text) + self.assertNotIn("of the 1 entry after your intent", text) def test_listing_omissions_are_the_window_count_not_the_session_wide_more(self) -> None: text = reading.cutoff_text( @@ -260,7 +260,7 @@ def test_large_counts_keep_every_loss_clause_and_source_fallback_inside_the_stor "passed", "failed", "no recorded result", - "file written", + "file write", "launch", "unlisted", "no room", diff --git a/compass/skills/compass/tests/test_reading_policy.py b/compass/skills/compass/tests/test_reading_policy.py index d0ce36ca..48f7fe92 100644 --- a/compass/skills/compass/tests/test_reading_policy.py +++ b/compass/skills/compass/tests/test_reading_policy.py @@ -141,7 +141,7 @@ def test_turn_off_clears_every_content_tier_and_tool_output_grant(self) -> None: def test_rolling_cap_expires_at_the_oldest_spend_not_midnight(self) -> None: reading_policy.set_consent(self.config, True, now=100.0) - for offset in range(12): + for offset in range(reading_policy.DAILY_CAP): self.assertEqual("", reading_policy.reserve(self.config, now=100.0 + offset)["reason"]) refused = reading_policy.reserve(self.config, now=150.0) self.assertEqual("daily-cap", refused["reason"]) @@ -149,14 +149,15 @@ def test_rolling_cap_expires_at_the_oldest_spend_not_midnight(self) -> None: self.assertEqual("", reading_policy.reserve(self.config, now=86500.0)["reason"]) self.assertEqual("daily-cap", reading_policy.reserve(self.config, now=86500.0)["reason"]) - def test_concurrent_clients_cannot_admit_more_than_twelve(self) -> None: + def test_concurrent_clients_cannot_admit_more_than_the_cap(self) -> None: reading_policy.set_consent(self.config, True, now=100.0) + cap = reading_policy.DAILY_CAP with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool: results = list( - pool.map(lambda _: reading_policy.reserve(self.config, now=100.0), range(24)) + pool.map(lambda _: reading_policy.reserve(self.config, now=100.0), range(2 * cap)) ) - self.assertEqual(12, sum(result["reason"] == "" for result in results)) - self.assertEqual(12, reading_policy.status(self.config, now=100.0)["used"]) + self.assertEqual(cap, sum(result["reason"] == "" for result in results)) + self.assertEqual(cap, reading_policy.status(self.config, now=100.0)["used"]) def test_known_missing_cli_reserves_nothing(self) -> None: reading_policy.set_consent(self.config, True, now=100.0) @@ -248,10 +249,13 @@ def test_missing_sqlite_refuses_permission_and_launch(self) -> None: def test_separate_processes_share_the_same_admission_bound(self) -> None: reading_policy.set_consent(self.config, True, now=100.0) with concurrent.futures.ProcessPoolExecutor(max_workers=4) as pool: - results = list(pool.map(_reserve_in_process, [self.home.name] * 24)) + results = list( + pool.map(_reserve_in_process, [self.home.name] * (2 * reading_policy.DAILY_CAP)) + ) # Every reason, not two counts: on a Windows runner three presses came # back neither admitted nor capped, and the counts could not say why. - self.assertEqual({"": 12, "daily-cap": 12}, collections.Counter(results)) + cap = reading_policy.DAILY_CAP + self.assertEqual({"": cap, "daily-cap": cap}, collections.Counter(results)) def test_a_reservation_waits_out_a_write_that_outlasts_a_read(self) -> None: # DRC-4707: a press refused as store-unavailable while under the cap, @@ -351,7 +355,7 @@ def test_turning_readings_off_or_forgetting_revokes_every_provider(self) -> None def test_the_rolling_cap_is_shared_across_providers(self) -> None: for provider in ("codex", "claude"): reading_policy.set_consent(self.config, True, now=100.0, provider=provider) - for index in range(12): + for index in range(reading_policy.DAILY_CAP): provider = ("codex", "claude")[index % 2] self.assertEqual( "", reading_policy.reserve(self.config, now=100.0, provider=provider)["reason"] diff --git a/docs/design-reading-a-session.md b/docs/design-reading-a-session.md index c3df8b58..3b366607 100644 --- a/docs/design-reading-a-session.md +++ b/docs/design-reading-a-session.md @@ -4230,10 +4230,12 @@ decisions below follow that ruling and the DRC-4702 decisions of the same date. words, every one of them after the session's last observed stop, as a resumed turn the row has not caught up with leaves it: "Everything after your words came after the session's last observed stop, so there is nothing finished to read yet." -- The cutoff sentence counts each set honestly (owner, 2026-09-27): "Read 2 of the 3 entries - after your words; 1 earlier and 1 untimed entries were not read; 1 entry after the last observed - stop was not read." Each clause appears only when its count is not zero, in the singular for - one, and the time and author mix follow in their own sentence, "Of those read, ...". +- The cutoff sentence counts each set honestly (owner, 2026-09-27). Each clause appears only when + its count is not zero, in the singular for one. Reworded 2026-10-16 in plain sentences, with the + same counts: "Read 2 of the 3 entries after your intent: 1 from you, 1 message from the agent. + The oldest is from about 2h ago. 1 earlier and 1 untimed entries were not read. 1 entry after the + last observed stop was not read." The page lists each sentence as its own item under What the + analysis read. #### Amended 2026-10-04 (owner): disclose measured reading coverage @@ -4525,6 +4527,30 @@ old numbering ([the recorded departure](design-frontend-migration.md#held-to-rec The triggers in item 7 are unchanged. The [DEC-23 amendment of the same day](#amended-2026-10-14-owner-a-write-outside-a-named-folder-does-not-settle-a-line) records that a recorded write outside a named folder is not one of them, and why. +### Amended 2026-10-16 (owner): the budget, the mark and what it read + +The reader-requested cap rose from 12 to 100 attempts per rolling 24 hours. The unasked lane keeps +its own cap of 12. The "N of 12 left today" line beside Analyze is gone: under the cap the count was +noise. Once the cap is spent, a banner sticks to the top of the session page and names the limit +and the local time the oldest attempt ages out. The refusal under Analyze uses the same words. It +says "you can analyze again at" rather than "it resets at", because a rolling window frees one +attempt at that time, not all of them. + +"Not accurate?" became "Mark as inaccurate". Before the press, a line beside it says what the mark +does. After it, a status beside the control says what changed, the control reads "Undo", and the +level's slot says the level is hidden because of the mark. Before, the only change near the +button was its pressed state, while the level and Steer back vanished far above it and the column +jumped. The press now holds the control where it was pressed. The wording says the analysis "no +longer sets" the level or Steer back rather than that Steer back is hidden: a failed check after +the reader's last message still offers Steer back on its own, and the first draft said otherwise. + +"What it read" became "What the analysis read", with four headings: the model, the intent (its +version, when it was saved, where it came from, each line quoted), the session, and what is never +read. The session list draws the scope from its token and gives each cutoff sentence its own item. +The reading definition and the long scope paragraph left this panel, since the page states both +elsewhere. The cutoff is reworded at the producer in plain sentences with the same counts +(see [what the window build decided](#what-the-window-build-decided-2026-09-27)). A reading stored before the change keeps the sentence it was stored with. + ### What the result build decided, 2026-09-28 DRC-4695 built items 6, 10 and 14 on the session page, with the analysis-derived level of diff --git a/docs/promise-map.md b/docs/promise-map.md index 5b621574..3efe6a84 100644 --- a/docs/promise-map.md +++ b/docs/promise-map.md @@ -77,7 +77,7 @@ turn, based on recorded checks and file paths rather than the meaning of your in shows its level, each outcome line against the recorded work and what the agent said with its source, where files were written, and why a result is stale. Recorded checks and file paths include subagent work, labelled as such. -Reader-requested readings are capped at twelve attempts per rolling twenty-four hours. With no saved +Reader-requested readings are capped at 100 attempts per rolling twenty-four hours. With no saved goal, a check adopts the goal drafted from your first Claude Code or Codex prompt (your latest where no first one with a time is published); the goal field also offers your latest prompt. Adopted words are marked, and missing source times refuse adoption. diff --git a/docs/visibility-2x2/items.json b/docs/visibility-2x2/items.json index a9af4bd1..ea5aa2c7 100644 --- a/docs/visibility-2x2/items.json +++ b/docs/visibility-2x2/items.json @@ -64,7 +64,7 @@ "id": "mid", "name": "Mid-flight", "question": "What is it doing, and when should I come back?", - "promise": "For each live session, what it is doing now, what it plans next, how far into the current turn it is, and an estimate of when that turn ends. It tells you when that changes instead of making you poll. The first screen sets your goal or a permitted prompt beside current activity and brings recorded departures forward. The session page asks once before a reading, remembers your answer, and offers an off switch, without steering it for you. Open a session, set a goal and press Analyze drift. The How Intent and drift works button beside Intent explains the steps and the limits; Not enough recorded yet is normal until you add an expected outcome line. On Claude Code sessions, you can turn on a live drift estimate in the panel and header after every turn, based on recorded checks and file paths rather than the meaning of your intent. An analysis shows its level, each outcome line against the recorded work and what the agent said with its source, where files were written, and why a result is stale. Recorded checks and file paths include subagent work, labelled as such. Reader-requested readings are capped at twelve attempts per rolling twenty-four hours. With no saved goal, a check adopts the goal drafted from your first Claude Code or Codex prompt (your latest where no first one with a time is published); the goal field also offers your latest prompt. Adopted words are marked, and missing source times refuse adoption. Claude Code and Codex hooks also report matched destructive command shapes; a match does not prove success. Compass saves a stage you choose for a Spacedock workflow and alerts once when it observes an entity enter that stage; gaps and unavailable sources are stated, and a notification request does not prove a banner appeared." + "promise": "For each live session, what it is doing now, what it plans next, how far into the current turn it is, and an estimate of when that turn ends. It tells you when that changes instead of making you poll. The first screen sets your goal or a permitted prompt beside current activity and brings recorded departures forward. The session page asks once before a reading, remembers your answer, and offers an off switch, without steering it for you. Open a session, set a goal and press Analyze drift. The How Intent and drift works button beside Intent explains the steps and the limits; Not enough recorded yet is normal until you add an expected outcome line. On Claude Code sessions, you can turn on a live drift estimate in the panel and header after every turn, based on recorded checks and file paths rather than the meaning of your intent. An analysis shows its level, each outcome line against the recorded work and what the agent said with its source, where files were written, and why a result is stale. Recorded checks and file paths include subagent work, labelled as such. Reader-requested readings are capped at 100 attempts per rolling twenty-four hours. With no saved goal, a check adopts the goal drafted from your first Claude Code or Codex prompt (your latest where no first one with a time is published); the goal field also offers your latest prompt. Adopted words are marked, and missing source times refuse adoption. Claude Code and Codex hooks also report matched destructive command shapes; a match does not prove success. Compass saves a stage you choose for a Spacedock workflow and alerts once when it observes an entity enter that stage; gaps and unavailable sources are stated, and a notification request does not prove a banner appeared." }, { "id": "gate", diff --git a/frontend/css-budget.json b/frontend/css-budget.json index 84b68eb8..58604626 100644 --- a/frontend/css-budget.json +++ b/frontend/css-budget.json @@ -1,6 +1,6 @@ { "rule": "UTF-8 bytes after CRLF to LF normalization, including comments, of all CSS under frontend except the directive-only Tailwind entry. Strip only an outer @layer legacy wrapper. Inventory every path; added paths need prior base approval.", - "bytes": 122816, + "bytes": 122704, "files": [ "frontend/src/attention/attention.css", "frontend/src/capacity/capacity.css", diff --git a/frontend/e2e/drift-parity.mjs b/frontend/e2e/drift-parity.mjs index 4e171a74..c9444714 100644 --- a/frontend/e2e/drift-parity.mjs +++ b/frontend/e2e/drift-parity.mjs @@ -573,7 +573,10 @@ try { const marked = await compare('not accurate', (g) => stored(g, { session: { annotation_not_accurate: true } }), ); - assert.match(marked.text, /You marked this analysis not accurate/); + assert.match( + marked.text, + /Marked inaccurate\. This analysis no longer sets the drift level or Steer back/, + ); const withheld = await compare('withheld', (g) => claude(g, { session: { diff --git a/frontend/src/drift/DrawnPress.test.tsx b/frontend/src/drift/DrawnPress.test.tsx index dd066032..6a60bb83 100644 --- a/frontend/src/drift/DrawnPress.test.tsx +++ b/frontend/src/drift/DrawnPress.test.tsx @@ -138,7 +138,7 @@ describe('Not accurate acts on the state the card showed', () => { const page = mountDrift({ session, facts: FACTS, routes }); await page.settle(); await page.settle(); - expect(byAction('not-accurate')?.getAttribute('aria-pressed')).toBe('false'); + expect(byAction('not-accurate')?.textContent).toBe('Mark as inaccurate'); await hold(page); await page.poll(later({}, { annotation_not_accurate: true })); await act(async () => { diff --git a/frontend/src/drift/DriftBody.tsx b/frontend/src/drift/DriftBody.tsx index 176abc2e..8fb66fc8 100644 --- a/frontend/src/drift/DriftBody.tsx +++ b/frontend/src/drift/DriftBody.tsx @@ -10,7 +10,6 @@ import { offeredDirection, steerOffer, steerTrigger, type SteerOffer } from './c import { DriftLevelView } from './DriftLevelView'; import { readingBoard } from './flip'; import { - BudgetLine, DISCLOSURE_ID, DisclosureParts, ReadingControl, @@ -171,7 +170,7 @@ function TriggerLine({ offer }: { readonly offer: SteerOffer }) { /* The question before the press, in the Analyze control's place while a later direction is unsettled: Keep is then the stage's one primary. The question is the Intent step's; this adds the Analyze step's side of it: the reading Keep may start, the box a running analysis draws above it, Turn off readings beside it, and the - disclosure, the count, the budget and the refusal under it. */ + disclosure, the count and the refusal under it. */ function QuestionWithAnalysis({ board, primary, @@ -229,7 +228,6 @@ function QuestionWithAnalysis({ ) : null} {annotation ? : null} - {!edited && reason ? : null} } diff --git a/frontend/src/drift/DriftLevelView.tsx b/frontend/src/drift/DriftLevelView.tsx index d4510c4e..ca5bc9d3 100644 --- a/frontend/src/drift/DriftLevelView.tsx +++ b/frontend/src/drift/DriftLevelView.tsx @@ -10,6 +10,7 @@ import { routeRefusal } from './route'; import { HARNESS_LIMIT, WORK_HARNESSES, + LEVEL_HIDDEN_MARKED, LEVEL_SCALE, LIVE_LEVEL_NAMES, LIVE_LINE, @@ -144,6 +145,15 @@ export function DriftLevelView() { const running = Boolean(model.job); const level = found && 'level' in found ? found : null; if (model.drafted || !level || !level.label) { + /* A marked analysis contributes no level. Its slot says so, so the reader who marked it sees why the + meter is gone, where the meter was. */ + if (!model.drafted && annotation?.['not_accurate'] === true && readingStored(annotation)) { + return ( +

+ {LEVEL_HIDDEN_MARKED} +

+ ); + } const unchecked = model.annotate && annotation && diff --git a/frontend/src/drift/ReadingCapBanner.test.tsx b/frontend/src/drift/ReadingCapBanner.test.tsx new file mode 100644 index 00000000..0e601ba1 --- /dev/null +++ b/frontend/src/drift/ReadingCapBanner.test.tsx @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { capLine, capReset, readingCap } from './route'; +import { mountDrift, textOf } from './testing'; + +/* The analysis budget is said only once it is spent, and then at the top of the session page. */ +const local = (h: number, m: number, dayOffset = 0) => + new Date(2026, 9, 16 + dayOffset, h, m).getTime() / 1000; + +describe('the analysis budget', () => { + it('is silent under the cap and names the limit and the reset once it is spent', () => { + expect(readingCap({ reading: { used: 99, limit: 100 } })).toBeNull(); + expect(readingCap({ reading: { reason: 'consent-required', used: 3, limit: 100 } })).toBeNull(); + expect(readingCap({})).toBeNull(); + const spent = readingCap({ + reading: { reason: 'daily-cap', used: 100, limit: 100, retry_at: local(9, 38) }, + }); + expect(spent).toEqual({ limit: 100, retryAt: local(9, 38) }); + // A count at the limit is spent even where the reason names something that outranks it. + expect(readingCap({ reading: { reason: 'run-disabled', used: 100, limit: 100 } })).toEqual({ + limit: 100, + retryAt: null, + }); + }); + + it('says the reset as a local time, and says tomorrow when it is', () => { + const now = local(8, 0) * 1000; + const today = capReset(local(9, 38), now); + expect(today).toMatch(/09:38|9:38/); + expect(today).not.toContain('tomorrow'); + expect(capReset(local(7, 15, 1), now)).toMatch(/tomorrow$/); + expect(capLine({ limit: 100, retryAt: local(9, 38) }, now)).toBe( + `You’ve reached the limit of 100 drift analyses for the past 24 hours. You can analyze again at ${today}.`, + ); + expect(capLine({ limit: 100, retryAt: null }, now)).toBe( + 'You’ve reached the limit of 100 drift analyses for the past 24 hours.', + ); + }); + + it('draws nothing on the session page while budget is left', async () => { + const under = mountDrift({ payload: { reading: { used: 1, limit: 100 } } }); + await under.settle(); + expect(document.querySelector('[data-next-reading-cap]')).toBeNull(); + }); + + it('draws the banner first on the session page when the budget is spent', async () => { + const page = mountDrift({ + payload: { + reading: { reason: 'daily-cap', used: 100, limit: 100, retry_at: local(9, 38) }, + }, + }); + await page.settle(); + const banner = document.querySelector('[data-next-reading-cap]') as HTMLElement; + expect(banner).not.toBeNull(); + expect(banner.getAttribute('role')).toBe('status'); + expect(textOf('[data-next-reading-cap]')).toMatch( + /^You’ve reached the limit of 100 drift analyses for the past 24 hours\. You can analyze again at /, + ); + // First in the article, above the header and both columns. + expect(banner.parentElement?.firstElementChild).toBe(banner); + expect(banner.parentElement?.hasAttribute('data-next-session-detail')).toBe(true); + }); +}); diff --git a/frontend/src/drift/ReadingCapBanner.tsx b/frontend/src/drift/ReadingCapBanner.tsx new file mode 100644 index 00000000..17f52ee0 --- /dev/null +++ b/frontend/src/drift/ReadingCapBanner.tsx @@ -0,0 +1,18 @@ +import { Alert } from '../ui/alert'; +import type { Row } from '../observed'; +import { useShell } from '../shell/context'; +import { capLine, readingCap } from './route'; + +/* Shown only once the shared analysis budget is spent. Under the cap the count is noise, so nothing about it + is drawn; at the cap the reader needs to know before pressing, wherever they are on the page, so it holds + the top of the viewport. */ +export function ReadingCapBanner({ payload }: { readonly payload: Row }) { + const { clock } = useShell(); + const cap = readingCap(payload); + if (!cap) return null; + return ( + + {capLine(cap, clock.now())} + + ); +} diff --git a/frontend/src/drift/ReadingControl.tsx b/frontend/src/drift/ReadingControl.tsx index c2e01836..e5c81531 100644 --- a/frontend/src/drift/ReadingControl.tsx +++ b/frontend/src/drift/ReadingControl.tsx @@ -13,7 +13,6 @@ import { useDrift } from './context'; import type { Board } from './flip'; import { anyConsent, - budgetLine, disclosureParts, needsAllow, readingJob, @@ -82,12 +81,6 @@ export function ReadingCount({ count }: { readonly count: number }) { ); } -export function BudgetLine() { - const { model } = useDrift(); - const line = budgetLine(model.payload); - return line ?

{line}

: null; -} - /* Turn off readings, busy state and all, drawn only while some receiver's permission is on record. */ export function TurnOff() { const { ctx, model } = useDrift(); @@ -261,7 +254,6 @@ export function ReadingControl({ /* Only from a published annotation: with the store off there is no count to read, and "0 requests" would be a default standing in for one. */ const counted = annotation ? : null; - const budget = ; /* What the last press or withdrawal came to, announced. Every arm prints it: a failed "Turn off readings" that says nothing leaves the reader believing a permission is gone that is still on record. */ const answered = request?.message && !request.refusal ? String(request.message) : ''; @@ -286,7 +278,6 @@ export function ReadingControl({ {said(answered)} {counted} - {budget} ); } @@ -422,7 +413,6 @@ export function ReadingControl({ {lead ? null : steers} {accounts} {counted} - {budget} {aboutWhy} ); @@ -469,7 +459,6 @@ export function ReadingControl({ {inert ? null : counted} {accounts} {sent} - {budget} ); } diff --git a/frontend/src/drift/ReadingResult.test.tsx b/frontend/src/drift/ReadingResult.test.tsx index beb2bbdc..66e1bfca 100644 --- a/frontend/src/drift/ReadingResult.test.tsx +++ b/frontend/src/drift/ReadingResult.test.tsx @@ -73,7 +73,9 @@ describe('a stored reading', () => { ).toBeNull(); expect(byAction('reading-ask')).toBeNull(); expect(textOf('[data-next-result]')).toContain('1 model request'); - expect(textOf('.next-cockpit-reading-budget')).toBe('9 of 10 left today'); + // Under the cap the budget is not mentioned anywhere. + expect(document.body.textContent).not.toMatch(/left today/); + expect(document.querySelector('[data-next-reading-cap]')).toBeNull(); }); it('withholds a verdict that cites nothing it can resolve, never softening it', async () => { @@ -344,6 +346,8 @@ describe('a finding jump', () => { }); }); +const foot = () => document.querySelector('[data-next-result-mark]') as HTMLElement | null; + describe('Not accurate', () => { it('derives its mark from the payload and never from the press', async () => { const page = mountDrift({ @@ -356,7 +360,12 @@ describe('Not accurate', () => { await page.settle(); await page.settle(); const mark = byAction('not-accurate') as HTMLElement; - expect(mark.getAttribute('aria-pressed')).toBe('false'); + expect(mark.textContent).toBe('Mark as inaccurate'); + // Before the press, the reader is told what it does. + expect(foot()?.getAttribute('data-next-result-mark')).toBe('unmarked'); + expect(foot()?.textContent).toContain( + 'Stops this analysis from setting the drift level or Steer back. Nothing is sent or re-run.', + ); await press(mark); await page.settle(); const posts = page.state.requests.filter( @@ -369,10 +378,11 @@ describe('Not accurate', () => { not_accurate: true, read_at: 1000, }); - // The store did not take it: still unmarked, and said so. - expect(byAction('not-accurate')?.getAttribute('aria-pressed')).toBe('false'); - expect(textOf('.next-cockpit-result-foot')).toContain('Your mark was not saved.'); - // Marked on the board, it draws marked, and the sentence is the board's. + // The store did not take it: still unmarked, and said so beside the control. + expect(foot()?.getAttribute('data-next-result-mark')).toBe('unmarked'); + expect(foot()?.textContent).toContain('Your mark was not saved. Try again.'); + // Marked on the board, it draws marked: what changed is said beside the control, the control undoes it, + // and the level's own slot says why it is gone. await page.poll({ ...(page.state.data as object), generated: 1100, @@ -383,9 +393,68 @@ describe('Not accurate', () => { }, ], }); - expect(byAction('not-accurate')?.getAttribute('aria-pressed')).toBe('true'); - expect(textOf('.next-cockpit-result-marked')).toBe('You marked this analysis not accurate.'); - // A level is not drawn from a reading marked not accurate. + expect(foot()?.getAttribute('data-next-result-mark')).toBe('marked'); + expect(byAction('not-accurate')?.textContent).toBe('Undo'); + expect(textOf('[data-next-result-mark] [role="status"]')).toBe( + 'Marked inaccurate. This analysis no longer sets the drift level or Steer back.', + ); expect(document.querySelector('[data-next-drift-level]')).toBeNull(); + expect(textOf('[data-next-drift-marked]')).toBe( + 'Drift level hidden because you marked this analysis inaccurate.', + ); + // Undo posts the mark back off. + await press(byAction('not-accurate') as HTMLElement); + await page.settle(); + const undo = page.state.requests.filter( + (r) => r.method === 'POST' && r.path === '/api/annotate', + ); + expect(undo.at(-1)?.body).toMatchObject({ not_accurate: false, read_at: 1000 }); + }); +}); + +describe('What the analysis read', () => { + it('groups the model, the intent, the session and what is never read under headings', async () => { + const page = mountDrift({ + session: stored({ + annotation_assessment: { + ...ASSESSMENT, + scope: 'final', + stamp: 'claude-sonnet-5-5 · read at 09:38', + cutoff: + 'Read all 4 entries after your intent: 1 from you, 3 messages from the agent. The oldest is from about 13h ago.', + }, + }), + facts: FACTS, + }); + await page.settle(); + await page.settle(); + const read = document.querySelector('[data-next-read]') as HTMLElement; + expect(read).not.toBeNull(); + const section = (name: string) => + read.querySelector(`[data-next-read-section="${name}"]`) as HTMLElement; + expect(section('model').textContent).toContain('claude-sonnet-5-5 · read at 09:38'); + const intent = section('intent'); + expect(intent.querySelector('h4')?.textContent).toBe('Your intent'); + expect(intent.textContent).toContain('Version 3'); + expect([...intent.querySelectorAll('li')].map((li) => li.textContent)).toEqual([ + 'Goal “Ship the queue”', + 'Expected outcome “Tests pass”', + ]); + expect([...section('session').querySelectorAll('li')].map((li) => li.textContent)).toEqual([ + 'Everything up to the session’s end.', + 'Read all 4 entries after your intent: 1 from you, 3 messages from the agent.', + 'The oldest is from about 13h ago.', + ]); + expect(section('not-read').textContent).toContain('Files, diffs, test results'); + // The retired wording is gone. + for (const retired of [ + 'A reading is one model pass', + 'What it reads is the evidence on this page', + 'Baseline from your prompt', + 'GOAL FROM YOUR PROMPT', + 'Revision 3', + ]) { + expect(read.textContent).not.toContain(retired); + } }); }); diff --git a/frontend/src/drift/ReadingResult.tsx b/frontend/src/drift/ReadingResult.tsx index f6a2b42e..2a53221d 100644 --- a/frontend/src/drift/ReadingResult.tsx +++ b/frontend/src/drift/ReadingResult.tsx @@ -1,7 +1,7 @@ import { SampleBeside, SampleMark } from '../sample/SampleMark'; import { Card } from '../ui/card'; import { Button } from '../ui/button'; -import type { ReactNode } from 'react'; +import { useLayoutEffect, useRef, type ReactNode } from 'react'; import { nextNumber } from '../api/bootstrap'; import { contextKey } from '../api/identity'; import { useDisplayed } from '../shell/context'; @@ -32,14 +32,21 @@ import { CLAUSE_UNRETAINED, DEPARTURE, NOT_REACHED, - READING_DEFINITION, + READ_INTENT, + READ_MODEL, + READ_NEVER, + READ_NOT_READ, + READ_SCOPE, + READ_SESSION, + READ_SUMMARY, RESULT_CANT_TELL, RESULT_DEPARTS, RESULT_MARK_UNSAVED, RESULT_MARKED, RESULT_NOT_ACCURATE, + RESULT_NOT_ACCURATE_HINT, RESULT_NOTHING_FOUND, - SCOPE, + RESULT_UNMARK, unloadedCites, } from './sentences'; import type { SteerSlot } from './ReadingControl'; @@ -293,29 +300,66 @@ function ResultWorkView() { /* Not accurate: a token on this reading, posted with the reading's time so a newer one is never marked by a tab drawn before it. Pressed again it takes the mark back. It is never sent anywhere else and never counted. - The mark and `aria-pressed` derive from the payload: a store that did not take the press draws unmarked and - says so, rather than showing a mark nothing holds. */ + The mark derives from the payload: a store that did not take the press draws unmarked and says so, rather + than showing a mark nothing holds. Both states say, beside the control, what the mark does, because what it + changes (the level and Steer back) is drawn far above it. The press holds the control where the reader + pressed it: the level and Steer back leave the column above, and the page would otherwise jump. */ +const HOLD_MS = 10_000; + function ResultFoot() { const { ctx, model } = useDrift(); const readAt = nextNumber(model.raw?.['read_at']); - if (readAt === null || !model.annotate) return null; const marked = model.annotation?.['not_accurate'] === true; + const foot = useRef(null); + const held = useRef<{ top: number; from: boolean; at: number } | null>(null); + useLayoutEffect(() => { + const hold = held.current; + const node = foot.current; + if (!hold || !node || hold.from === marked) return; + held.current = null; + if (Date.now() - hold.at > HOLD_MS) return; + const moved = node.getBoundingClientRect().top - hold.top; + if (moved) window.scrollBy(0, moved); + }, [marked]); + if (readAt === null || !model.annotate) return null; + const unsaved = ctx.drift.notAccurate.has(model.key); + const control = ( + { + if (!model.identity) return; + const top = foot.current?.getBoundingClientRect().top; + held.current = top === undefined ? null : { top, from: marked, at: Date.now() }; + void markNotAccurate(ctx, model.identity, readAt, marked); + }} + /> + ); return ( -
- { - if (model.identity) void markNotAccurate(ctx, model.identity, readAt, marked); - }} - /> - {ctx.drift.notAccurate.has(model.key) ? ( +
+ {marked ? ( + <> +

+ {RESULT_MARKED} +

+ {control} + + ) : ( + <> + {control} +

{RESULT_NOT_ACCURATE_HINT}

+ + )} + {unsaved ? (

{RESULT_MARK_UNSAVED}

@@ -324,47 +368,132 @@ function ResultFoot() { ); } -/* What it read, one click away: the revision it read and when, the words it read against, the definition, the - model stamp, the baseline's source and the cutoff. Revision and times are about the reading, never about - the work. */ -function Baseline({ shape, extra }: { readonly shape: Shape; readonly extra: ReactNode }) { +/* The cutoff is composed by the producer as sentences; one item per sentence reads faster than the run. */ +function readSentences(text: string): string[] { + return text + .split(/(?<=\.)\s+(?=[A-Z0-9])/) + .map((sentence) => sentence.trim()) + .filter(Boolean); +} + +function ReadSection({ + name, + title, + meta, + children, +}: { + readonly name: string; + readonly title: string; + readonly meta?: string; + readonly children: ReactNode; +}) { + return ( +
+

+ {title} +

+ {meta ?

{meta}

: null} + {children} +
+ ); +} + +const READ_LIST = + 'm-0 flex list-disc flex-col gap-[4px] pl-[18px] text-body leading-[1.5] text-muted-foreground [font-family:var(--sans)]'; + +/* The shape labels each line it read in capitals ("TYPED GOAL", "EXPECTED OUTCOME · LINE 2"); this says the + same thing in a sentence's case, numbering an outcome line only when there is more than one. */ +function intentLabel(label: string, lines: number): string { + if (label.includes('GOAL')) return 'Goal'; + const n = /LINE (\d+)$/.exec(label)?.[1]; + return n && lines > 1 ? `Expected outcome ${n}` : 'Expected outcome'; +} + +/* What it read, one click away: the model, the intent it read against (which revision, when and where it came + from), how much of the session it read, and what it never reads. Revision and times are about the reading, + never about the work. */ +function Baseline({ shape, arrived }: { readonly shape: Shape; readonly arrived: string }) { const { model } = useDrift(); - if (shape.revisionRead === null) { - return extra ? ( - - {extra} - - ) : null; - } - /* One sentence either way. With no time it read "Revision 2, when it was typed was not recorded."; adopted - words were saved rather than typed, so their missing time is the save's. */ - const typed = - shape.revisionReadAt !== null - ? `, ${shape.promptSource ? 'saved' : 'typed'} ${fmtDur(Math.max(0, model.generated - shape.revisionReadAt))} ago` - : ` (${shape.promptSource ? 'save time' : 'time'} not recorded)`; - /* Both times where they differ: typed words read work from the reader's latest message before the save. - Adopted words already name their prompt above. */ - const opened = - !shape.promptSource && - shape.windowStart !== null && - shape.revisionReadAt !== null && - shape.windowStart < shape.revisionReadAt - ? `; reads work from your message ${fmtDur(Math.max(0, model.generated - shape.windowStart))} ago` - : ''; + const cap = nextNumber(model.payload['annotate_cap']); + /* With no time it said "time not recorded"; adopted words were saved rather than typed, so their missing + time is the save's. */ + const meta = + shape.revisionRead === null + ? '' + : [ + `Version ${String(shape.revisionRead)}`, + shape.revisionReadAt !== null + ? `${shape.promptSource ? 'saved' : 'typed'} ${fmtDur(Math.max(0, model.generated - shape.revisionReadAt))} ago` + : `${shape.promptSource ? 'save time' : 'time'} not recorded`, + shape.promptSource ? 'drafted from your prompt' : '', + /* Both times where they differ: typed words read work from the reader's latest message before the + save. Adopted words already name their prompt. */ + !shape.promptSource && + shape.windowStart !== null && + shape.revisionReadAt !== null && + shape.windowStart < shape.revisionReadAt + ? `reads work from your message ${fmtDur(Math.max(0, model.generated - shape.windowStart))} ago` + : '', + ] + .filter(Boolean) + .join(' · '); + const lines = shape.readClauses.filter(([label]) => intentLabel(label, 0) !== 'Goal').length; + const session = [ + READ_SCOPE[shape.scope] ?? shape.scopeText, + ...readSentences(shape.cutoff), + arrived, + ].filter(Boolean); return ( - -

{`Revision ${String(shape.revisionRead)}${typed}${opened}.`}

- {extra} - {shape.readClauses.map(([label, clause]) => ( -
- {label} - {clause ? ( - {clause} - ) : ( - {CLAUSE_UNRETAINED} - )} -
- ))} + +
+ {shape.stamp ? ( + +

{shape.stamp}

+
+ ) : null} + {shape.readClauses.length ? ( + +
    + {shape.readClauses.map(([label, clause]) => { + const name = intentLabel(label, lines); + const cut = + name === 'Goal' && shape.promptSource && cap !== null && clause.length >= cap; + return ( +
  • + {name}{' '} + {clause ? ( + {`“${clause}”`} + ) : ( + + {CLAUSE_UNRETAINED} + + )} + {cut ? ( + + {shape.coverage?.goal_source === 'whole' + ? ` Shown cut at ${String(cap)} characters; the analysis read the whole prompt, up to 1,000 characters.` + : ` Cut at ${String(cap)} characters.`} + + ) : null} +
  • + ); + })} +
+
+ ) : null} + {session.length ? ( + +
    + {session.map((line) => ( +
  • {line}
  • + ))} +
+
+ ) : null} + +

{READ_NEVER}

+
+
); } @@ -397,21 +526,6 @@ export function ReadingResult({ const lines = shape.criteria.filter((row) => isOutcomeLine(row.key)); /* What the agent claimed, after the intent it is independent of, under its own heading. */ const claimed = claimsDrawn(shape); - const read = ( - <> -

{READING_DEFINITION}

-

{SCOPE}

- {shape.stamp ?

{shape.stamp}

: null} - {shape.promptSource ? ( -

Baseline from your prompt.

- ) : null} - {shape.scopeText ?

{shape.scopeText}

: null} - {arrived ?

{arrived}

: null} - {/* The cutoff was the departures section's, which no longer repeats the reading: "raised nothing" is - worth only the evidence read. */} - {shape.cutoff ?

{shape.cutoff}

: null} - - ); /* A current result keeps the disclosure and accounting without a second run control. [DEC-27](../../../docs/design-reading-a-session.md#dec-27-the-first-view-of-a-session-is-the-goal-analyze-drift-and-the-result) A direction question owns the sole run path while it stands. */ @@ -445,9 +559,6 @@ export function ReadingResult({ {callout} ) : null} - {annotation['not_accurate'] === true ? ( -

{RESULT_MARKED}

- ) : null} {answer ? ( {foot ?
{foot}
: null} - +
); } diff --git a/frontend/src/drift/drift.css b/frontend/src/drift/drift.css index 6d24d9f1..e7da0590 100644 --- a/frontend/src/drift/drift.css +++ b/frontend/src/drift/drift.css @@ -27,7 +27,7 @@ .next-session-drift-range,.next-session-drift-reason{margin:0;color:var(--ink2);font-size:var(--fs-body);line-height:1.5} .next-session-drift-reason{color:var(--ink)} .next-session-drift-limit{margin:0 16px 10px;color:var(--ink2);font-size:var(--fs-body);line-height:1.5} -.next-cockpit-reading-count,.next-cockpit-reading-budget{margin:0;color:var(--ink2);font:500 var(--fs-body)/1.4 var(--sans)} +.next-cockpit-reading-count{margin:0;color:var(--ink2);font:500 var(--fs-body)/1.4 var(--sans)} .next-cockpit-steer-tools{display:flex;flex-wrap:wrap;gap:8px 12px;align-items:center} .next-cockpit-steer-tools>p{flex:1 1 12em;margin:0} @@ -97,7 +97,6 @@ .next-cockpit-result-folder{display:flex;flex-wrap:wrap;gap:2px 10px;color:var(--ink2);font:var(--fs-body)/1.5 var(--sans)} .next-cockpit-result-path{color:var(--ink);font-family:var(--mono);overflow-wrap:anywhere} .next-cockpit-result-foot{display:flex;flex-wrap:wrap;align-items:center;gap:4px 12px;padding-top:10px;border-top:1px solid var(--rule)} -.next-cockpit-result-marked{color:var(--ink2);font:var(--fs-body)/1.5 var(--sans)} .next-cockpit-result{gap:14px} .next-cockpit-result-checklist,.next-cockpit-result-claims{display:flex;flex-direction:column;gap:8px} .next-cockpit-result-checklist h3,.next-cockpit-result-claims h3{margin:0;color:var(--ink);font:600 var(--fs-body)/1.45 var(--sans)} diff --git a/frontend/src/drift/index.ts b/frontend/src/drift/index.ts index 8699a705..6796dd9d 100644 --- a/frontend/src/drift/index.ts +++ b/frontend/src/drift/index.ts @@ -7,3 +7,4 @@ export { useDrift } from './context'; export { DriftPill } from './DriftLevelView'; export { DeparturesSection } from './Departures'; export { WorkListView } from './WorkListView'; +export { ReadingCapBanner } from './ReadingCapBanner'; diff --git a/frontend/src/drift/route.differential.test.ts b/frontend/src/drift/route.differential.test.ts index 62b028f5..9bf46ff9 100644 --- a/frontend/src/drift/route.differential.test.ts +++ b/frontend/src/drift/route.differential.test.ts @@ -9,7 +9,7 @@ import { canonical, firstDifference } from '../observed/legacy.test.helper'; import { loadLegacyDrift } from './legacy.test.helper'; import { anyConsent, - budgetLine, + capReset, eligibility, needsAllow, policyReason, @@ -69,7 +69,6 @@ legacy.lift(['fmtDur'], 'next-cockpit-compat.js'); const CASES = 80; const SEEDS = CASES; -const plain = (html: string): string => html.replace(/<[^>]*>/g, '').replace(/\s+/g, ''); function genPayload(rnd: Rng): Record { const policy = pick(rnd, [ @@ -167,13 +166,31 @@ describe('the route, consent and refusals read as the legacy page reads them', ( annotation, }; const route = readingRoute(payload as never, session as never); + /* Deviation: the spent budget is worded as the session page's banner words it, with the reset as a + local time rather than a full date. */ + const policy = payload['reading'] as Record | null | undefined; + const retry = typeof policy?.['retry_at'] === 'number' ? policy['retry_at'] : null; + const capped = (text: string) => + text + .replace( + 'The daily reading limit is reached. Wait for the next update.', + 'Analysis limit reached. Wait for the next update.', + ) + .replace( + /The daily reading limit is reached\. Try again after [^.]*\./, + retry === null + ? '' + : `Analysis limit reached. You can analyze again at ${capReset(retry, Date.now())}.`, + ); const same = (name: string, theirs: unknown, mine: unknown) => { const found = firstDifference( canonical( typeof theirs === 'string' - ? theirs.replace( - "Last turn isn't recorded as finished. Run another turn to open Analyze.", - 'Analyze opens while this session runs.', + ? capped( + theirs.replace( + "Last turn isn't recorded as finished. Run another turn to open Analyze.", + 'Analyze opens while this session runs.', + ), ) : theirs, ), @@ -229,11 +246,9 @@ describe('the route, consent and refusals read as the legacy page reads them', ( legacy.call('nextReadingJob', session), readingJob(payload as never, session as never), ); - same( - 'budget', - plain(legacy.call('nextReadingBudgetLine')), - plain(budgetLine(payload as never) ? `

${budgetLine(payload as never)}

` : ''), - ); + /* The budget line is no longer drawn: the spent budget is the session page's banner, and under the cap + nothing is said. The legacy call stays in the recorded sequence, which chains every call. */ + legacy.call('nextReadingBudgetLine'); same( 'states', legacy.call('nextCockpitReadingStates', annotation, null), diff --git a/frontend/src/drift/route.ts b/frontend/src/drift/route.ts index da694de9..633da83f 100644 --- a/frontend/src/drift/route.ts +++ b/frontend/src/drift/route.ts @@ -142,7 +142,7 @@ export function routeRefusal(payload: Row, session: Row): string { return route['provider'] ? '' : String(route['note'] || ROUTE_UNREAD); } -export function policyReason(policy: unknown): string { +export function policyReason(policy: unknown, nowMs: number = Date.now()): string { if (!isRecord(policy)) return MODEL_UNREAD; if (policy['reason'] === 'run-disabled') return MODEL_OFF; if (policy['reason'] === 'store-unavailable') { @@ -151,8 +151,8 @@ export function policyReason(policy: unknown): string { if (policy['reason'] === 'daily-cap') { const at = nextNumber(policy['retry_at']); return at === null - ? 'The daily reading limit is reached. Wait for the next update.' - : `The daily reading limit is reached. Try again after ${new Date(at * 1000).toLocaleString()}.`; + ? 'Analysis limit reached. Wait for the next update.' + : `Analysis limit reached. You can analyze again at ${capReset(at, nowMs)}.`; } return ''; } @@ -165,19 +165,44 @@ export function readingJob(payload: Row, session: Row): Row | null { return isRecord(job) && typeof job['id'] === 'string' ? job : null; } -export function budgetLine(payload: Row): string { +/* The analysis budget is published so a reader who has spent it can be told, and only then: under the cap + the count is noise. Rolling, so `retry_at` is when the oldest analysis in the window ages out and one + more may run. */ +export interface ReadingCap { + readonly limit: number; + /** Epoch seconds, or null when the server did not publish one. */ + readonly retryAt: number | null; +} + +export function readingCap(payload: Row): ReadingCap | null { const policy = payload['reading']; - if (!isRecord(policy)) return ''; + if (!isRecord(policy)) return null; const { used, limit } = policy; - if ( - !Number.isInteger(used) || - !Number.isInteger(limit) || - (used as number) < 0 || - (limit as number) <= 0 - ) { - return ''; - } - return `${String(Math.max(0, (limit as number) - (used as number)))} of ${String(limit)} left today`; + const counted = + Number.isInteger(used) && Number.isInteger(limit) && (limit as number) > 0 + ? (used as number) >= (limit as number) + : false; + if (policy['reason'] !== 'daily-cap' && !counted) return null; + return { + limit: Number.isInteger(limit) && (limit as number) > 0 ? (limit as number) : 0, + retryAt: nextNumber(policy['retry_at']), + }; +} + +/* "09:38", or "09:38 tomorrow" when it falls on the next local day. The window is 24 hours, so it is never + further out than that. */ +export function capReset(retryAt: number, nowMs: number): string { + const at = new Date(retryAt * 1000); + const time = at.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + return at.toDateString() === new Date(nowMs).toDateString() ? time : `${time} tomorrow`; +} + +export function capLine(cap: ReadingCap, nowMs: number): string { + const limit = cap.limit ? `${String(cap.limit)} drift analyses` : 'drift analyses'; + const head = `You’ve reached the limit of ${limit} for the past 24 hours.`; + return cap.retryAt === null + ? head + : `${head} You can analyze again at ${capReset(cap.retryAt, nowMs)}.`; } /* A discard record satisfies the nothing-typed arm too, and the wider answer is the less true of the two, so diff --git a/frontend/src/drift/sentences.ts b/frontend/src/drift/sentences.ts index 67727867..f91bcb63 100644 --- a/frontend/src/drift/sentences.ts +++ b/frontend/src/drift/sentences.ts @@ -135,9 +135,33 @@ export const RESULT_CLAIM_TAIL = "The record read is the board's recent tail."; export const RESULT_DEPARTS = 'Departs from your intent'; export const RESULT_NOTHING_FOUND = 'Nothing found against what it read. This is not a check that the work was done.'; -export const RESULT_NOT_ACCURATE = 'Not accurate?'; -export const RESULT_MARKED = 'You marked this analysis not accurate.'; -export const RESULT_MARK_UNSAVED = 'Your mark was not saved. Press Not accurate? again to retry.'; +/* The mark is a local flag on this one analysis. Each state says what the press does and what changed, beside + the control, because both effects (the level and Steer back) are drawn far above it. */ +export const RESULT_NOT_ACCURATE = 'Mark as inaccurate'; +export const RESULT_NOT_ACCURATE_HINT = + 'Stops this analysis from setting the drift level or Steer back. Nothing is sent or re-run.'; +export const RESULT_MARKED = + 'Marked inaccurate. This analysis no longer sets the drift level or Steer back.'; +export const RESULT_UNMARK = 'Undo'; +export const RESULT_MARK_UNSAVED = 'Your mark was not saved. Try again.'; +export const LEVEL_HIDDEN_MARKED = + 'Drift level hidden because you marked this analysis inaccurate.'; + +/* What it read: the model, the intent it read against, how much of the session it read, and what it never + reads. Headings and short items rather than one paragraph of caveats. */ +export const READ_SUMMARY = 'What the analysis read'; +export const READ_MODEL = 'Model'; +export const READ_INTENT = 'Your intent'; +export const READ_SESSION = 'The session'; +export const READ_NOT_READ = 'Not read'; +export const READ_NEVER = + 'Files, diffs, test results, git or gh output, connected tools, or your screen. The model only sees what is on this page.'; +export const READ_SCOPE: Readonly> = { + final: 'Everything up to the session’s end.', + 'mid-flight': 'The work so far. The session was still running.', + 'last-turn': 'Everything up to the last finished turn.', + withdrawn: 'The session end this analysis relied on is no longer reported.', +}; export const READING_FINISHED = 'The analysis finished. Its result is in the Drift section.'; export const STEER_BY_HAND = 'Raised to you and nowhere else.'; diff --git a/frontend/src/sessions/SessionDetail.tsx b/frontend/src/sessions/SessionDetail.tsx index b946db6f..568b9f3b 100644 --- a/frontend/src/sessions/SessionDetail.tsx +++ b/frontend/src/sessions/SessionDetail.tsx @@ -17,6 +17,7 @@ import { DriftPill, DriftProvider, DeparturesSection, + ReadingCapBanner, WorkListView, } from '../drift'; import { DriftSlot } from './DriftSlot'; @@ -284,6 +285,7 @@ export function SessionDetail({ route, data, session }: SessionDetailProps): Rea {...(state ? { 'data-next-session-state': state.token } : {})} data-tone={observed.tone} > + {observed.isNeeds || observed.askKnown ? (
; +export default meta; +type Story = StoryObj; + +const scenario = (value: Scenario): Pick => ({ args: { scenario: value } }); + +/* The person's message comes after the failed check, so Steer back is offered by the analysis's departure + alone and a mark is seen to withdraw it. A failed check after the person's last message would offer Steer + back on its own, mark or no mark. */ +const RECORD = [ + written('w1', 220, 'src/queue/worker.ts'), + failedCheck('c1', 500), + fact('u1', 550, { type: 'user_message', by: 'person:me', summary: 'Ship it' }), + passedCheck('c2', 600), +]; + +/* The reading as the producer now words it: a final scope, the model's stamp and the cutoff's sentences. */ +const READING = { + ...DEPARTURE_READING, + scope: 'final', + stamp: 'claude-sonnet-5-5 · read at 09:38', + cutoff: + 'Read all 4 entries after your intent: 1 from you, 1 file write, 1 failed check, 1 passed check. The oldest is from about 13h ago.', +}; + +const RESULT: Scenario = { + session: { ...STORED_READING, annotation_assessment: READING }, + facts: RECORD, + work: { analysis_levels: [analysisRow('claude')] }, +}; + +async function card(canvasElement: HTMLElement): Promise { + return within(canvasElement).findByRole('region', { name: 'Drift' }); +} + +/* The foot is the Mark as inaccurate row; `data-next-result-mark` names its state. */ +async function foot(root: HTMLElement): Promise { + return waitFor(() => { + const node = root.querySelector('[data-next-result-mark]'); + expect(node).not.toBeNull(); + return node as HTMLElement; + }); +} + +async function noBudgetLine(root: HTMLElement) { + await expect(root.textContent).not.toMatch(/left today/); + await expect(root.textContent).not.toMatch(/of \d+ left/); +} + +export const UnderTheLimitSaysNothingAboutIt: Story = { + ...scenario(RESULT), + play: async ({ canvasElement }) => { + await foot(await card(canvasElement)); + await noBudgetLine(canvasElement); + await expect(canvasElement.querySelector('[data-next-reading-cap]')).toBeNull(); + }, +}; + +export const LimitReachedHoldsTheTopOfThePage: Story = { + ...scenario({ + ...RESULT, + payload: { + reading: { + providers: { claude: true, codex: true }, + words: { claude: true, codex: true }, + tool_output: { claude: ['api'] }, + used: 100, + limit: 100, + reason: 'daily-cap', + retry_at: Math.floor(Date.now() / 1000) + 3600, + }, + }, + }), + play: async ({ canvasElement }) => { + const banner = await waitFor(() => { + const node = canvasElement.querySelector('[data-next-reading-cap]'); + expect(node).not.toBeNull(); + return node as HTMLElement; + }); + await expect(banner).toHaveAttribute('role', 'status'); + await expect(banner).toHaveTextContent( + /^You’ve reached the limit of 100 drift analyses for the past 24 hours\. You can analyze again at .+\.$/, + ); + // The top 2rem of the viewport, and still there once the page has scrolled. + const rem = Number.parseFloat(getComputedStyle(document.documentElement).fontSize); + await expect(banner.getBoundingClientRect().height).toBeGreaterThanOrEqual(2 * rem - 1); + await expect(getComputedStyle(banner).position).toBe('sticky'); + window.scrollTo(0, document.documentElement.scrollHeight); + await waitFor(() => expect(Math.round(banner.getBoundingClientRect().top)).toBe(0)); + window.scrollTo(0, 0); + await noBudgetLine(canvasElement); + }, +}; + +export const MarkAsInaccurateSaysWhatItDoes: Story = { + ...scenario({ ...RESULT, keepsMarks: true }), + play: async ({ canvasElement }) => { + const drift = await card(canvasElement); + const row = await foot(drift); + await expect(row).toHaveAttribute('data-next-result-mark', 'unmarked'); + // Before the press: what it does, beside the control. + await expect(row).toHaveTextContent(RESULT_NOT_ACCURATE_HINT); + await waitFor(() => expect(drift.querySelector('[data-next-drift-level]')).not.toBeNull()); + await within(drift).findByRole('button', { name: 'Steer back' }); + const mark = within(row).getByRole('button', { name: RESULT_NOT_ACCURATE }); + mark.scrollIntoView({ block: 'center' }); + const before = row.getBoundingClientRect().top; + await userEvent.click(mark); + // After: what changed, said beside the control, and Undo in its place. + await waitFor(() => expect(row).toHaveAttribute('data-next-result-mark', 'marked')); + await expect(within(row).getByRole('status')).toHaveTextContent(RESULT_MARKED); + const undo = within(row).getByRole('button', { name: RESULT_UNMARK }); + // The level and Steer back left the column above; the control stayed where it was pressed. + await expect(Math.abs(row.getBoundingClientRect().top - before)).toBeLessThan(4); + await expect(drift.querySelector('[data-next-drift-level]')).toBeNull(); + await expect(drift.querySelector('[data-next-drift-marked]')).toHaveTextContent( + LEVEL_HIDDEN_MARKED, + ); + await expect(within(drift).queryByRole('button', { name: 'Steer back' })).toBeNull(); + await userEvent.click(undo); + await waitFor(() => expect(row).toHaveAttribute('data-next-result-mark', 'unmarked')); + await waitFor(() => expect(drift.querySelector('[data-next-drift-level]')).not.toBeNull()); + await expect(drift.querySelector('[data-next-drift-marked]')).toBeNull(); + }, +}; + +export const MarkedInaccurate: Story = { + ...scenario({ + ...RESULT, + session: { ...RESULT.session, annotation_not_accurate: true }, + }), + play: async ({ canvasElement }) => { + const drift = await card(canvasElement); + const row = await foot(drift); + await expect(row).toHaveAttribute('data-next-result-mark', 'marked'); + await expect(within(row).getByRole('status')).toHaveTextContent(RESULT_MARKED); + await expect(within(row).getByRole('button', { name: RESULT_UNMARK })).toBeVisible(); + await expect(drift.querySelector('[data-next-drift-marked]')).toHaveTextContent( + LEVEL_HIDDEN_MARKED, + ); + // The old wording is gone. + await expect(drift.textContent).not.toContain('Not accurate?'); + await expect(drift.textContent).not.toContain('You marked this analysis not accurate.'); + }, +}; + +export const WhatTheAnalysisRead: Story = { + ...scenario(RESULT), + play: async ({ canvasElement }) => { + const drift = await card(canvasElement); + const summary = await within(drift).findByRole('button', { name: READ_SUMMARY }); + await userEvent.click(summary); + const read = await waitFor(() => { + const node = drift.querySelector('[data-next-read]'); + expect(node).not.toBeNull(); + return node as HTMLElement; + }); + const section = (name: string) => + read.querySelector(`[data-next-read-section="${name}"]`) as HTMLElement; + const headings = [...read.querySelectorAll('h4')].map((h) => h.textContent); + await expect(headings).toEqual(['Model', 'Your intent', 'The session', 'Not read']); + await expect(section('model')).toHaveTextContent('claude-sonnet-5-5 · read at 09:38'); + await expect(section('intent')).toHaveTextContent(/Version 3 · typed .+ ago/); + await expect([...section('intent').querySelectorAll('li')].map((li) => li.textContent)).toEqual( + ['Goal “Ship the queue”', 'Expected outcome “Tests pass”'], + ); + const session = [...section('session').querySelectorAll('li')]; + await expect(session.map((li) => li.textContent)).toEqual([ + 'Everything up to the session’s end.', + 'Read all 4 entries after your intent: 1 from you, 1 file write, 1 failed check, 1 passed check.', + 'The oldest is from about 13h ago.', + ]); + // Each item is its own line, not one run of words. + await expect(new Set(session.map((li) => li.getBoundingClientRect().top)).size).toBe(3); + await expect(section('not-read')).toHaveTextContent(READ_NEVER); + for (const retired of [ + 'A reading is one model pass', + 'What it reads is the evidence on this page', + 'Baseline from your prompt', + 'GOAL FROM YOUR PROMPT', + 'Revision 3', + ]) { + await expect(read.textContent).not.toContain(retired); + } + }, +}; + +export const WhatTheAnalysisReadFromAPromptGoal: Story = { + ...scenario({ + ...RESULT, + session: { + ...RESULT.session, + annotation_goal: 'x'.repeat(240), + annotation_goal_source: 'first-prompt', + annotation_assessment: { + ...READING, + goal_source: 'first-prompt', + goal_source_at: 100, + coverage: { ...READING.coverage, goal_source: 'whole' }, + criteria: { + ...READING.criteria, + goal: { ...READING.criteria.goal, clause: 'x'.repeat(240) }, + }, + }, + }, + }), + play: async ({ canvasElement }) => { + const drift = await card(canvasElement); + await userEvent.click(await within(drift).findByRole('button', { name: READ_SUMMARY })); + const intent = await waitFor(() => { + const node = drift.querySelector('[data-next-read-section="intent"]'); + expect(node).not.toBeNull(); + return node as HTMLElement; + }); + await expect(intent).toHaveTextContent(/drafted from your prompt/); + await expect(intent).toHaveTextContent( + 'Shown cut at 240 characters; the analysis read the whole prompt, up to 1,000 characters.', + ); + }, +}; diff --git a/frontend/stories/alert.stories.tsx b/frontend/stories/alert.stories.tsx index af738971..e1a218cb 100644 --- a/frontend/stories/alert.stories.tsx +++ b/frontend/stories/alert.stories.tsx @@ -60,6 +60,15 @@ export const DriftNudge: Story = { await expect(canvas.getByRole('status')).toHaveTextContent(args.children as string); }, }; +export const ReadingCap: Story = { + args: { variant: 'reading-cap', children: 'Alert reading-cap' }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const alert = canvas.getByRole('status'); + await expect(alert).toHaveTextContent(args.children as string); + await expect(getComputedStyle(alert).position).toBe('sticky'); + }, +}; export const Silent: Story = { args: { announce: false, children: 'Quiet notice' }, play: async ({ canvasElement }) => { diff --git a/frontend/stories/drift.scenario.tsx b/frontend/stories/drift.scenario.tsx index a4f5b1be..713ae552 100644 --- a/frontend/stories/drift.scenario.tsx +++ b/frontend/stories/drift.scenario.tsx @@ -85,6 +85,8 @@ export interface Scenario { readonly work?: Record; /** The route for this harness, where a story is about a machine that is not the healthy one. */ readonly route?: Record; + /** Whether `POST /api/annotate` keeps a Not accurate mark, as the store does, so the next board carries it. */ + readonly keepsMarks?: boolean; } export const fact = ( @@ -152,6 +154,7 @@ function DriftScenario({ scenario }: { readonly scenario: Scenario }) { const clock = createFakeClock(); const history = fakeRouterEnvironment(`#n=session:demo:${harness}:s1`); let generation = 1000; + let marked: boolean | null = null; const json = (body: unknown) => new Response(JSON.stringify(body), { status: 200 }); return createShell({ env: createFakeEnvironment({ clock }), @@ -161,7 +164,14 @@ function DriftScenario({ scenario }: { readonly scenario: Scenario }) { search: '', doc: null, reducedMotion: () => true, - fetch: async (url) => { + fetch: async (url, init) => { + if (scenario.keepsMarks && url.startsWith('/api/annotate')) { + const body = JSON.parse(String(init?.body ?? '{}')) as Record; + if (typeof body['not_accurate'] === 'boolean') { + marked = body['not_accurate']; + return json({ ok: true, outcome: 'stored', persisted: true }); + } + } if (url.startsWith('/api/data')) return json({ generated: generation++, @@ -184,6 +194,7 @@ function DriftScenario({ scenario }: { readonly scenario: Scenario }) { reading_eligibility: ELIGIBLE, ...(scenario.unsaved ? {} : SAVED), ...(scenario.session ?? {}), + ...(marked === null ? {} : { annotation_not_accurate: marked }), }, ], }); diff --git a/frontend/test/fixtures/client-contract/data-empty.json b/frontend/test/fixtures/client-contract/data-empty.json index 43e357d7..61f6dfdd 100644 --- a/frontend/test/fixtures/client-contract/data-empty.json +++ b/frontend/test/fixtures/client-contract/data-empty.json @@ -69,7 +69,7 @@ "rate_window_sec": 600, "reading": { "consent": false, - "limit": 12, + "limit": 100, "providers": { "claude": false, "codex": false @@ -111,7 +111,7 @@ }, "headers": { "Cache-Control": "no-store", - "Content-Length": "3958", + "Content-Length": "3959", "Content-Security-Policy": "frame-ancestors 'none'", "Content-Type": "application/json", "X-Compass-Revision": "1900000000.1" diff --git a/frontend/test/fixtures/client-contract/data-healthy.json b/frontend/test/fixtures/client-contract/data-healthy.json index e554ffbf..a96f85a6 100644 --- a/frontend/test/fixtures/client-contract/data-healthy.json +++ b/frontend/test/fixtures/client-contract/data-healthy.json @@ -69,7 +69,7 @@ "rate_window_sec": 600, "reading": { "consent": false, - "limit": 12, + "limit": 100, "providers": { "claude": false, "codex": false @@ -554,7 +554,7 @@ }, "headers": { "Cache-Control": "no-store", - "Content-Length": "18207", + "Content-Length": "18208", "Content-Security-Policy": "frame-ancestors 'none'", "Content-Type": "application/json", "X-Compass-Revision": "1900000000.1" diff --git a/frontend/test/fixtures/client-contract/data-identity-collisions.json b/frontend/test/fixtures/client-contract/data-identity-collisions.json index 0c5a3da9..d9d889db 100644 --- a/frontend/test/fixtures/client-contract/data-identity-collisions.json +++ b/frontend/test/fixtures/client-contract/data-identity-collisions.json @@ -69,7 +69,7 @@ "rate_window_sec": 600, "reading": { "consent": false, - "limit": 12, + "limit": 100, "providers": { "claude": false, "codex": false @@ -554,7 +554,7 @@ }, "headers": { "Cache-Control": "no-store", - "Content-Length": "18767", + "Content-Length": "18768", "Content-Security-Policy": "frame-ancestors 'none'", "Content-Type": "application/json", "X-Compass-Revision": "1900000000.1" diff --git a/frontend/test/fixtures/client-contract/data-restarted-build.json b/frontend/test/fixtures/client-contract/data-restarted-build.json index 6bdf33fb..cb4d3c9f 100644 --- a/frontend/test/fixtures/client-contract/data-restarted-build.json +++ b/frontend/test/fixtures/client-contract/data-restarted-build.json @@ -69,7 +69,7 @@ "rate_window_sec": 600, "reading": { "consent": false, - "limit": 12, + "limit": 100, "providers": { "claude": false, "codex": false @@ -554,7 +554,7 @@ }, "headers": { "Cache-Control": "no-store", - "Content-Length": "18207", + "Content-Length": "18208", "Content-Security-Policy": "frame-ancestors 'none'", "Content-Type": "application/json", "X-Compass-Revision": "1900000500.1" diff --git a/frontend/test/fixtures/client-contract/data-show-all.json b/frontend/test/fixtures/client-contract/data-show-all.json index 62564c14..d5e37e63 100644 --- a/frontend/test/fixtures/client-contract/data-show-all.json +++ b/frontend/test/fixtures/client-contract/data-show-all.json @@ -69,7 +69,7 @@ "rate_window_sec": 600, "reading": { "consent": false, - "limit": 12, + "limit": 100, "providers": { "claude": false, "codex": false @@ -554,7 +554,7 @@ }, "headers": { "Cache-Control": "no-store", - "Content-Length": "18206", + "Content-Length": "18207", "Content-Security-Policy": "frame-ancestors 'none'", "Content-Type": "application/json", "X-Compass-Revision": "1900000000.2" diff --git a/frontend/test/fixtures/client-contract/data-unavailable.json b/frontend/test/fixtures/client-contract/data-unavailable.json index 591be7b8..9da0e52c 100644 --- a/frontend/test/fixtures/client-contract/data-unavailable.json +++ b/frontend/test/fixtures/client-contract/data-unavailable.json @@ -69,7 +69,7 @@ "rate_window_sec": 600, "reading": { "consent": false, - "limit": 12, + "limit": 100, "providers": { "claude": false, "codex": false @@ -111,7 +111,7 @@ }, "headers": { "Cache-Control": "no-store", - "Content-Length": "3992", + "Content-Length": "3993", "Content-Security-Policy": "frame-ancestors 'none'", "Content-Type": "application/json", "X-Compass-Revision": "1900000000.1" diff --git a/frontend/test/fixtures/client-contract/index.json b/frontend/test/fixtures/client-contract/index.json index bab9f05d..58c22739 100644 --- a/frontend/test/fixtures/client-contract/index.json +++ b/frontend/test/fixtures/client-contract/index.json @@ -281,7 +281,7 @@ "method": "GET", "name": "data-empty", "route": "/api/data", - "sha256": "c1c5fab89eeff7e83e1d49d10b8098370776158124d1e8068f56eba5ba9ebe50", + "sha256": "2d83ca9f50b21c9effcb3a2eb4ada9a216abb4584a832165f4dd45609b41e7ce", "status": 200 }, { @@ -302,35 +302,35 @@ "method": "GET", "name": "data-healthy", "route": "/api/data", - "sha256": "891bbfd02f4e7d37effad26b56a373f987a7684b62679f2ae595807fdc1fdf7a", + "sha256": "b8e817804ae2a9b2a555a6ebaa5f80140f4acc4e51b3d8871f73aefca4d6c37e", "status": 200 }, { "method": "GET", "name": "data-identity-collisions", "route": "/api/data", - "sha256": "91de59a4af5d6db2804f6e46618fdbff101c4baeeff36fabba3a65bcdbc16b1f", + "sha256": "d676b7a573aaeb6770bd0a9cb749f20572c8d38c5706f4a0501890706af348b7", "status": 200 }, { "method": "GET", "name": "data-restarted-build", "route": "/api/data", - "sha256": "881c07665227e9fd8d47e1a9b84c7dbecce08282bb4a49349f9ef5368ea9205a", + "sha256": "634a2235e94e8705cd4f5f0384984c40bcdc0c2fd9f77790fdccb7284ce57e68", "status": 200 }, { "method": "GET", "name": "data-show-all", "route": "/api/data", - "sha256": "681a84c27e2f3e7a79560c4d2937a5b5715e81f97f2a6d62ef9580c6b474d424", + "sha256": "bddc8dcb6d926e3c4765421cfeab16c93afd48f6fd984b8f4d47f8a8937a3011", "status": 200 }, { "method": "GET", "name": "data-unavailable", "route": "/api/data", - "sha256": "c0f523e72f274be4323bec884fc22715a8d5bbaa8bdd777dd71b015d15772f64", + "sha256": "320ee453accb8c6f438b6a11e73d1f34f85438101205d69592610f118985f216", "status": 200 }, { @@ -512,7 +512,7 @@ "method": "POST", "name": "reading-consent-required", "route": "/api/reading", - "sha256": "bf496e830624a0a15742c8f7bc109694bbae4535193d285013bbd9b3116a44b1", + "sha256": "218dcfd1b6d44e41b109dca2f1615cac259675ebeb7650ec1a7b51c778d3c1f7", "status": 403 }, { diff --git a/frontend/test/fixtures/client-contract/reading-consent-required.json b/frontend/test/fixtures/client-contract/reading-consent-required.json index cd3d3aac..46ddff32 100644 --- a/frontend/test/fixtures/client-contract/reading-consent-required.json +++ b/frontend/test/fixtures/client-contract/reading-consent-required.json @@ -24,7 +24,7 @@ "produced": false, "reading": { "consent": false, - "limit": 12, + "limit": 100, "providers": { "claude": false, "codex": false @@ -65,7 +65,7 @@ }, "headers": { "Cache-Control": "no-store", - "Content-Length": "2548", + "Content-Length": "2549", "Content-Security-Policy": "frame-ancestors 'none'", "Content-Type": "application/json" }, diff --git a/scripts/tests/test_score_abstention.py b/scripts/tests/test_score_abstention.py index 2f4608c4..9ae6f2c4 100644 --- a/scripts/tests/test_score_abstention.py +++ b/scripts/tests/test_score_abstention.py @@ -666,7 +666,7 @@ def test_offline_replay_reads_only_frozen_facts_at_the_frozen_time(self) -> None for absent in ("REVIEWER ONLY", "FUTURE VERIFIER"): self.assertNotIn(absent, self.model.prompts[0]) local = json.loads(self.result.read_text()) - self.assertIn("all within the last hour", local["cases"][self.case["id"]]["cutoff"]) + self.assertIn("within the last hour", local["cases"][self.case["id"]]["cutoff"]) committed = self.summary.read_text() for absent in ("FROZEN evidence", "REVIEWER ONLY", "row_snapshot", "producer_facts"): self.assertNotIn(absent, committed)