| 文件 | 最后提交记录 | 最后更新时间 |
|---|---|---|
A connection is closed by the task that opened it, or it takes the interface with it (#1368) Deleting the default conversation from the sessions panel killed the whole interface. Measured over plain HTTP, no browser, no page: /sessions/forget on any other conversation -> 200, interface alive /sessions/forget on the DEFAULT -> 200, process DEAD, exit 1 `Link.open` called `__aenter__` on `stdio_client` and on `ClientSession` by hand, and `Link.close` called `__aexit__` on them - from whatever task happened to be closing. Both are anyio context managers, so each owns a cancel scope, and a cancel scope has to be exited in the task that ENTERED it. Exiting it elsewhere delivers the cancellation to the scope enclosing the entering task. `cli.serve` opens the default conversation and then runs uvicorn in the same task, so closing that link from a request task cancelled `server.serve()`. And the others were not fine, they were quiet. Their scopes belong to request tasks that had already finished, so the same wrong exit raised `RuntimeError: Attempted to exit cancel scope in a different task than it was entered in`, which `close` was swallowing under a sentence written about teardown failures. EVERY close was wrong; exactly one of them had something alive to damage. Making only the default lazy would have removed the visible half and kept the defect. So the connection has an OWNER. One task opens it, publishes it, waits to be told to stop, and closes it itself; `open` starts that task and waits until the connection is usable, `close` asks it to let go and waits until it has. Closing from another task is not guarded against - it is made impossible, because no other task ever holds the contexts. `_ctx` and `_sess_ctx` are gone with it. A failure BEFORE the connection is usable is now `open`'s to report rather than to swallow: it is the difference between being told the server did not start and a page waiting for one that never will. Six tests against the real server over stdio, because the defect is about task ownership of a real transport and a double cannot have it. Five known-bad inputs, all killed: the whole previous implementation put back (three of the six go red with the cancel-scope sentence verbatim), an owner that does not wait, an open that cannot report a failure, a close that does not wait for the owner, and a close that does not even ask it to stop. Two of those five SURVIVED the first draft of the tests, and both survivals were real holes rather than bad mutations. Nothing asserted that an open connection ANSWERS, so a link that closed itself the instant after it was published read exactly like a live one; and the failed-open arm accepted any exception, including the `TimeoutError` that means it hung, which is the very thing it exists to forbid. Both are fixed in the tests, not in the code. `mcp/session.py` has the same SHAPE on `InvisiblePlaywright` - `__aenter__` in start, `__aexit__` in close - and was checked rather than assumed: no file in `invisible_playwright` imports anyio, so there is no cancel scope there and no such failure to have. Written down because an audit that names only the broken places does not say how much it looked at. Verified on the real product: deleting the default conversation now leaves the interface alive and `/sessions` answering 200. Suite: 738 passed, 9 skipped. Lint, `invisible_core.english` and `check_content.py` clean. | 3 小时前 | |
One page per browser, no session concept, and the names to match (#1301) 24 tools before, 16 now. No tool takes `session_id`; which piece of work a server is comes from `AIHAWK_SESSION_ID`, read once at spawn. The four tab tools are gone - a browser drives one page, and the answer to "I need a second page" is the `support` browser, which is a better answer because a second tab inside `main` carries that identity's cookies to the second site. One server process per conversation instead of one connection multiplexed by an id. Bugs found and fixed along the way, each with the measurement that found it: - The wake reopened every saved url as its own page, and `browser_status` then blamed the site for pages the wake had opened. - The interface and the server could silently address different files: two declarations of `"default"`, guarded by an assertion comparing one of them to a literal. - The key rule had two implementations and the tested one was dead. - A test in the fast CI job downloaded 665 MB of Firefox, and four pushes hung to GitHub's six-hour ceiling without ever going red: a job that hangs reports `in_progress`. Jobs have a ceiling now and a hung test dumps its stack. - A unit test served the interface forever, because its brake pointed at a name the command had stopped reading. It was green locally only because the developer's own interface held the port. - A reopened conversation lost the answer of every turn but the last. - The session drawer moved the whole window 48px and rewrapped the transcript, then covered half the composer, then still covered it below 720px where the panes stack. - A page older than its server degraded in silence; it says so now. - The English gate had never looked at `.js`, `.css` or `.html` - 67 KB of served page - while five front-end files carried Italian. - Two fetches were outside the one funnel that knows what a 404 and a 410 mean. `ruff --select F` is a CI job now, earned by measurement: with an import dropped the suite stays fully green and ruff finds it in under a second. 556 passed, 8 skipped, 47 deselected. Every new gate was run against its own known-bad and green again on restore. Five of them EXECUTE the thing they guard rather than scanning it, because a scan cannot tell whether a timer is reached, what a function returns, or where a box ends up on screen. | 6 天前 | |
The product surface is what the product uses (0.67.0) (#1351) An audit for code that exists only because the tests grew up around it. Seven surfaces came out of src/aihawk and every one had the same shape: zero callers in the product, and enough callers in the suite to look load-bearing. AGENT.RUN_TASK WAS A SECOND WAY TO RUN THE AGENT LOOP. Its own docstring said so: "not called by the product - kept because the suite drives the loop through it, about twenty-five tests". Worse than unused, it took an object with list_tools and call_tool, which is the shape from before Link existed, while the product passes link.call and link.tools to Conversation.run. A reader of agent.py met two entry points with two different ideas of how a tool is reached, and one of them was imaginary. The convenience was real, so it moved to tests/_loop.py rather than being deleted. The e2e test that drove a real server through it now goes through Conversation.run, which is the path the product takes. SESSIONS.AROUND OFFERED ITSELF TO "ANYTHING EMBEDDING THIS", a user that does not exist: this is an application, and the only importer of sessions.py is cli.py. Eleven callers, all in two test modules. The argument it was written for is good and survives in tests/_sessions.py, because it is a rule about the suite: a test driving ONE conversation should still go through build_app and the routes, so the single case is exercised by the same code the many case uses. SESSIONS._OPEN_LINK WAS A SEAM NOTHING DECLARED. It was assigned in __init__ and reassigned from outside by two test modules - a convention a reader of the signature could not see and a reader of the assignment could not tell from an accident. It is a keyword argument now, so how a connection is made is part of the interface and the default is the product's one way. SESSIONPLAN.DESCRIBE WAS A SECOND MAPPING OF A PLAN ONTO THE SENTENCE a caller is told, beside the one in work.open, and the two were free to disagree the day a fifth field joined the sentence. It also described something that does not happen: between planning and launching, work.open can rewrite the settings and the exit note - that is how the helper browser comes to share main's exit - so the sentence is read from what was LAUNCHED and never from what was planned. A method on the plan quietly offered the other thing. Its six callers were all in one test file and now go through the function the product goes through. CLEAN.RELEVANCE AND CLEAN.CLEAN_STATS ARRIVED DEAD and stayed that way. Fifty seven lines scoring how much a model needs an element, and seven figures about what the cleaning saved, both imported with the server on 2026-09-06 and named by no commit since. clean_stats was in __all__ and had a test; relevance had neither, and the module docstring described it as part of how the file works - "the relevance score below orders and annotates" - so the file's own account of itself named a pass that does not run. What those two were really carrying is kept: that nothing here may drop an element, and that a reduction figure says nothing about whether the result is still usable. Both are the invariant at the top of the module, which is where a rule can act. CLEAN.BLOCK_TAGS was a tuple nothing read. TWO PLACES ALSO STOPPED HOLDING THE SAME FACT TWICE. storage.file_for owns the rule that turns a session id into a path, so chats.py and mcp/store.py each name only their own directory and neither spells the join. And a test migrated off the store.home re-export onto aihawk.storage.home, which is where home lives. THE GATE IS ON THE CLASS, NOT ON THE SEVEN NAMES. A list would stop those seven; the defect is that a surface can be added, be used only by its own tests, and look exactly like one the product depends on. So it asks the general question: every top-level function, class and constant in src/aihawk must be named somewhere in src/aihawk. Being registered excuses it, and that is structural - a decorator spelled .tool, .command or .group hands the object to FastMCP or to click, where the call happens over a wire or from a shell. Wrapping does not: dataclass and contextmanager hand the object straight back, so it is still called by name. Exporting it in __all__ does not count either, because __all__ is the claim under audit rather than evidence for it, and that is precisely where clean_stats was hiding. WHAT THE GATE DOES NOT SEE IS WRITTEN INTO IT. 178 definitions across 25 modules, with floors asserted so a scan that goes blind fails instead of printing the same clean line. Methods are out of scope: a method reaches the code as an attribute, and plan.describe and SessionPlan.describe are the same attribute name, so no scan can tell the dead one from the live one beside it. That one was found by reading, and this gate would not have found it. Suite 654 green, from a 642 baseline: one test went with clean_stats and thirteen arrived with the gate. Six known-bad inputs and five cases that must not fire, plus a mutation against the real tree - relevance put back into clean.py, which the gate names by file and line. The first three must-not-fire cases failed on the first run and the gate was right: a toy module whose own outermost function has no caller has a dead surface, so each fixture ends at a registered entry point, exactly as the package does. | 3 天前 | |
The product surface is what the product uses (0.67.0) (#1351) An audit for code that exists only because the tests grew up around it. Seven surfaces came out of src/aihawk and every one had the same shape: zero callers in the product, and enough callers in the suite to look load-bearing. AGENT.RUN_TASK WAS A SECOND WAY TO RUN THE AGENT LOOP. Its own docstring said so: "not called by the product - kept because the suite drives the loop through it, about twenty-five tests". Worse than unused, it took an object with list_tools and call_tool, which is the shape from before Link existed, while the product passes link.call and link.tools to Conversation.run. A reader of agent.py met two entry points with two different ideas of how a tool is reached, and one of them was imaginary. The convenience was real, so it moved to tests/_loop.py rather than being deleted. The e2e test that drove a real server through it now goes through Conversation.run, which is the path the product takes. SESSIONS.AROUND OFFERED ITSELF TO "ANYTHING EMBEDDING THIS", a user that does not exist: this is an application, and the only importer of sessions.py is cli.py. Eleven callers, all in two test modules. The argument it was written for is good and survives in tests/_sessions.py, because it is a rule about the suite: a test driving ONE conversation should still go through build_app and the routes, so the single case is exercised by the same code the many case uses. SESSIONS._OPEN_LINK WAS A SEAM NOTHING DECLARED. It was assigned in __init__ and reassigned from outside by two test modules - a convention a reader of the signature could not see and a reader of the assignment could not tell from an accident. It is a keyword argument now, so how a connection is made is part of the interface and the default is the product's one way. SESSIONPLAN.DESCRIBE WAS A SECOND MAPPING OF A PLAN ONTO THE SENTENCE a caller is told, beside the one in work.open, and the two were free to disagree the day a fifth field joined the sentence. It also described something that does not happen: between planning and launching, work.open can rewrite the settings and the exit note - that is how the helper browser comes to share main's exit - so the sentence is read from what was LAUNCHED and never from what was planned. A method on the plan quietly offered the other thing. Its six callers were all in one test file and now go through the function the product goes through. CLEAN.RELEVANCE AND CLEAN.CLEAN_STATS ARRIVED DEAD and stayed that way. Fifty seven lines scoring how much a model needs an element, and seven figures about what the cleaning saved, both imported with the server on 2026-09-06 and named by no commit since. clean_stats was in __all__ and had a test; relevance had neither, and the module docstring described it as part of how the file works - "the relevance score below orders and annotates" - so the file's own account of itself named a pass that does not run. What those two were really carrying is kept: that nothing here may drop an element, and that a reduction figure says nothing about whether the result is still usable. Both are the invariant at the top of the module, which is where a rule can act. CLEAN.BLOCK_TAGS was a tuple nothing read. TWO PLACES ALSO STOPPED HOLDING THE SAME FACT TWICE. storage.file_for owns the rule that turns a session id into a path, so chats.py and mcp/store.py each name only their own directory and neither spells the join. And a test migrated off the store.home re-export onto aihawk.storage.home, which is where home lives. THE GATE IS ON THE CLASS, NOT ON THE SEVEN NAMES. A list would stop those seven; the defect is that a surface can be added, be used only by its own tests, and look exactly like one the product depends on. So it asks the general question: every top-level function, class and constant in src/aihawk must be named somewhere in src/aihawk. Being registered excuses it, and that is structural - a decorator spelled .tool, .command or .group hands the object to FastMCP or to click, where the call happens over a wire or from a shell. Wrapping does not: dataclass and contextmanager hand the object straight back, so it is still called by name. Exporting it in __all__ does not count either, because __all__ is the claim under audit rather than evidence for it, and that is precisely where clean_stats was hiding. WHAT THE GATE DOES NOT SEE IS WRITTEN INTO IT. 178 definitions across 25 modules, with floors asserted so a scan that goes blind fails instead of printing the same clean line. Methods are out of scope: a method reaches the code as an attribute, and plan.describe and SessionPlan.describe are the same attribute name, so no scan can tell the dead one from the live one beside it. That one was found by reading, and this gate would not have found it. Suite 654 green, from a 642 baseline: one test went with clean_stats and thirteen arrived with the gate. Six known-bad inputs and five cases that must not fire, plus a mutation against the real tree - relevance put back into clean.py, which the gate names by file and line. The first three must-not-fire cases failed on the first run and the gate was right: a toy module whose own outermost function has no caller has a dead surface, so each fixture ends at a registered entry point, exactly as the package does. | 3 天前 | |
The piece of work is one object, a rebuild is said, and a dead browser is a type (0.51.0) (#1326) The server held its whole lifecycle in four module globals - the registry, a restored flag, the pages seen and the pages owed - and a dozen free functions reading them. Every fixture that wanted a clean server rebuilt all four by hand, which is the tell of state that wants to be one thing with one lifecycle; the cost showed three times in one afternoon (#1324). `Work` now owns it: declare, wake, look, act, remember, close, each one method, documented in the order it happens. The server builds one from `AIHAWK_SESSION_ID` and every tool goes through it; a test installs one of its own with a factory that launches nothing. `server.py` is 783 lines where it was 1231, with nothing added but the object. A rebuild is no longer silent: `browser_navigate` says in front of its answer that the browser had died and was reopened as the same person, so a person watching the window close and reopen reads the same thing in the transcript. And a dead browser is recognised by its type: invisible-playwright 0.15.0 raises one `TargetClosedError` for a disposed object and a closed pipe alike and exports it, so the three sentences 0.50.0 matched are gone and the floor moves to 0.15.0. The address scanners read both modules now. Five known-bad mutations seen red. Suite 622 green, ruff, content and english gates clean. Version 0.51.0. | 4 天前 | |
A failed selector says what to do next, from every tool that takes one (#1366) * A failed selector says what to do next, from every tool that takes one Three tools take a selector and only one of them could explain a failure. `browser_click` asked the page why and reported it; `browser_type` and `browser_select_option` handed back Playwright's bare timeout, which names the selector and nothing else. The same page and the same failure gave a useful answer or a useless one depending on which tool the caller reached for. And the commonest answer was missing altogether: nothing matches. Measured on a real run. The model wrote `.inbox-dataentry a, .inbox a, [class*="mail-item"] a`, waited the full fifteen seconds for nothing, then spent four more `browser_evaluate` calls hunting through the DOM by hand before it thought of taking a fresh snapshot - which worked first time. It could not have read those class names anywhere in this product. `browser_snapshot` builds its `selector` from id, name, href, data-testid or aria-label and never from class, and `browser_read_html` drops the class attribute outright. So a class-based selector is ALWAYS one the caller wrote. The product knew that and did not say it at the only moment it mattered. `next_move` is now the one place that decides what follows from what the page reported, and `_on_selector` is the one place that asks. Click, type and select all go through it. The sentences live beside each other rather than inside the tools, because the answer depends on what the PAGE says and never on which tool was asking. The diagnosis stays a courtesy and not a condition: a page that cannot be asked - it navigated, it died - lets the original failure reach the caller instead of replacing it with a failure to explain it. Seven tests, five known-bad mutations killed: type back to a bare fill, select back to a bare select_option, the no-match sentence losing the snapshot advice, the healthy case borrowing somebody else's sentence, and a failed diagnosis swallowing the real error. Suite: 721 passed, 9 skipped. Lint, `invisible_core.english` and `check_content.py` clean. * 0.68.6 | 12 小时前 | |
Small truths in the shell (0.62.0) (#1346) Five, each small, each the same family as the rest of this week: a thing that said what was not true, or work nobody could see. THE SEPARATOR SAYS WHEN IT CANNOT MOVE. Its floor is the conversation's narrowest and its ceiling is what is left after the picture's, so under a window about 957px wide the two meet: the ceiling collapses onto the floor. Below that it was announced to a screen reader with a value, a floor and a ceiling all equal - a range of zero presented as a range - and to a hand as a thing you can drag that does nothing. It carries aria-disabled there now, and not `disabled`, for the reason the Clear button already carries: a dead control cannot say why. The three bounds are written in ONE place while I was in there; they were in two, which is two chances to announce a range that is not the one being clamped to. AND THE MARKUP STOPS CARRYING A VALUE FOR IT. The comment beside that attribute argues, correctly, that the FLOOR must not be a literal because it would be a second copy of a token. The same argument covers the value, and it was applied to two of the three. THE SCREEN YOU PINNED GETS THE ROOM. Clicking a screen marked it and left it exactly the size of the other, so with two browsers open you always watched at half width - and reading a form the agent is filling in is most of what watching is. The other screen stays on the stage rather than going away, because the agent may move to it at any moment and losing sight of that is worse than a narrow picture. Only when the PERSON has pinned one: the layout never moves on its own, which is the line this page draws everywhere between the agent's hand and the reader's eye. TWO CLOCKS FOR ONE FACT, AND NOW ONE. The address bar had a pump of its own at two seconds, reading a fleet that a different pump refreshed at three: it could only ever redraw the same answer between two arrivals, and the one moment it has to be quick - a screen being clicked - already called it directly. Whoever changes the fleet tells it. Same shape as the /live/address route, removed for the same reason. AND THE COLUMN IS NOT REBUILT WHERE NOBODY CAN SEE IT. The end of every turn asked the server for the list and rebuilt the DOM inside a panel that is hidden almost always, on a page that stops every other pump the moment the tab is hidden for exactly this reason. The boot line already carried the guard, so the rule was known and applied in one of the two places. Suite 616 green. Nine known-bad inputs, nine killed. | 3 天前 | |
The rule about writing bytes is written where it is applied (#1350) * The rule about writing bytes is written where it is applied store.save carried the bytes-not-text paragraph in full and does not apply it: it calls storage.write_atomically, which is where the rule lives and where it is documented. So the copy sat in a function that delegates - a reader fixing it there would have changed nothing, and the two were free to drift into two accounts of one decision. Two things make it worth a gate rather than a quiet edit. store.load, five lines below, already did it right: it names storage.read_json and says the reason is not repeated here. And storage.py opens by arguing that the atomic write was once copied into both files "with only one of the two carrying the comment explaining why", and that two copies is the arrangement where a fix reaches one of them. The module that removed the duplicated code kept a duplicate of its own headline rule in prose. Found by a scan for prose repeated across modules - eight-word runs of comment or docstring text appearing in more than one file - which is the detector worth keeping. It found eight groups; this is the only one that was a rule rather than a shared premise or the pointer pattern used correctly twice. A narrower scan then asked which docstrings state a rule about a call the function does not make. It named five, and four are correct as written: naming a thing by CONTRAST is how a rule explains itself ("never write_text", "no longer the constant main"). The gate here is limited to the two write helpers for that reason, and the docstring says so, because a wider version accused four healthy paragraphs out of five. Four known-bad inputs, four killed. Suite 642 green. No behaviour changes, so no release. * Propose 0.66.0: the version gate asks every change to shipped code for a free one | 3 天前 | |
The focus is the browser the agent is working in (0.55.0) (#1336) browser_list answered focus: "main" from a literal. The tools that moved a focus went with the eight-browser session on 2026-09-11 and nothing replaced them, so the field had been a constant since. The interface believed it: it draws a dot on that browser whose title reads "the agent is working here", so with the helper open the dot sat on main while the agent typed into support, and the screen marked aria-current was main whatever the agent was doing. The one thing that dot exists to say was the one thing it could not say. The fact was already inside Work.acting, which every command that touches a page goes through and which knows the role of each one. It was thrown away. Now it is kept, and focused() hands it out only when that browser is still open, falling back through roles() so main wins whenever it is up and "" means nothing is open. Opening a browser counts as working in it; a command that FAILED still happened there, so the role is recorded once the browser has answered for itself and before the action runs. Where a command that names no browser lands did not move: it is main, it is a constant of the two roles rather than a fact about this moment, and it is said in note and in the tool description. The two were the same string until now, which is exactly why one line used to serve both. Three things went with it. focused, per row, was id == focus with focus named two lines above it in the same answer. limit was the constant 2 and nothing in this product ever read it: the word does not appear in the interface at all, and note already says how many of how many. Same reason running went in 0.54.0. The stage stopped sorting the watched browser to the front. That meant something while a session held up to eight browsers and the stage showed fewer than all of them; it now shows two screens exactly when there are two browsers, so the sort could only decide which of two equal cells sat on the left - and with focus becoming a real fact it would have swapped the two panes under the eye of whoever was watching, every time the agent moved between them. The watched screen is marked, never moved. And removing limit exposed something the route was doing. /live/browsers smoothed an unreadable answer into an empty workspace with a 200, for a server older than 0.18.0 that replied in prose - a server that cannot exist any more, because the interface spawns the one it ships with out of this same package. What the smoothing still did was make a failure look exactly like an empty room, and with limit gone the fallback body became byte-identical to a genuine "nothing is open". It is a 503 with the reason now, the same shape /live/frame uses, and the page keeps the stage it already has instead of tearing every screen down for one unanswered poll. The wiki's sample of a client session was rebuilt by RUNNING the script it publishes rather than by editing it: it still showed running: false, removed in 0.54.0, and a fleet containing a browser that a freshly started server has not had since open-first landed in 0.53.0. The description-size anchor caught the twelve pages that publish the tool surface; nothing was watching the sample, which is why it was three versions stale. Suite 589 green. Eleven known-bad inputs, eleven killed: the role not recorded, the focus handed out without asking whether it is still open, opening not counting as working in it, the note reading the focus, the rows carrying focused again, the unreadable answer smoothed into an empty workspace, the watched screen sorted to the front, the dot on every screen, the dot on none, a failed poll emptying the stage, and the address bar answering some row when nobody is being watched. A twelfth was written and reported a survivor: it changed the find inside addressOf while the guard above it answered first, so the known-bad never reached the line it changed. | 3 天前 | |
Every swallowed exception says why on the line that swallows it (0.52.0) (#1331) Sixteen places in the package caught an exception and passed, each with its reason in a comment beside the `pass` - the one thing a reader skips and a gate cannot see. `swallow(why)` is a context manager that makes the reason an argument: it sits on the line that swallows, it can be grepped, and it is logged at debug with the traceback, so a silence that hides a real defect can be heard by turning the logger up instead of by adding a print. The workbench records what an unheard silence cost: a live pane frozen on an old page for a whole session, the capture stopped, the only sign a swallowed exception nobody could see. A gate reads the package for the bare shape - `except Exception:` then, comments aside, `pass` - and refuses it; it found three the grep that listed the sixteen had missed. Three mutations, three killed: a bare pass back with a good comment, a silence that stops logging, a silence that re-raises. One sentence of prose that described a session of eight browsers now describes the two there are. | 4 天前 | |
Sixteen tests nobody was running, and they had rotted (#1356) Sixteen tests nobody was running, and they had rotted `addopts` is `-m 'not ui and not e2e'`, and the only job that asked for anything asked for `-m e2e tests/mcp_server`. So the sixteen tests in tests/test_ui_drive.py - which drive a real browser through a real MCP server, the sharpest thing this suite can do - were selected by no run anywhere. ALL SIXTEEN ERRORED AT FIXTURE SETUP, and had for some time. The fixture navigated into a browser it had never opened; the product stopped allowing that when browser_open became the only tool that opens one, and the comment beside the call still described the older world in so many words: "browser_navigate opens the first page itself, which is the only way a page is opened at all". True when written. NOTHING COULD GO RED. A deselected test does not fail. It is absent, and a summary calls that "deselected", which reads like a decision rather than a gap. Same shape as the workflow trigger that never fires, one file over: something is declared and nothing executes it. With browser_open added to the fixture, all sixteen pass in 27 seconds. THE FIX IS THE CAUSE. The e2e job already fetches an engine and already drives it, so it now runs the ui selection too, after the e2e one because both drive real browsers and must run serially. About half a minute, no extra download. AND A GATE ON THE CLASS: every marker `addopts` deselects must be named by some workflow. A marker nobody asks for is a set of tests nobody runs, and they rot without a red. ⛔ THAT GATE FAILED ITS OWN FIRST KNOWN-BAD, and the way it failed is worth the line it cost. Removing `-m ui` from the job left it green - because the comment I had just written beside the job, the one explaining that it now runs `-m ui`, satisfied the search. The check was answered by the prose next to the code, which is the most repeated defect in this repository, met inside the gate written to find things that never run. It strips comments now, and the known-bad kills it. The second known-bad is the one that proves the defect was real rather than imagined: put the fixture back as it was and the sixteen error again. AND THE ENGLISH GATE CAUGHT ME while this was being written - an Italian sentence in a comment, in a public English-only repository. It works. Also measured while the machine was idle, because a suite this expensive is worth knowing the shape of: with STEALTHFOX_BINARY pointed at the pinned engine, the e2e selection is 34 passed and 0 skipped in six minutes. Without it, twelve of those skip for want of a real binary - which is what the runner does, and is worth knowing rather than reading 22 passed as full coverage. Suite 681 green by default, 16 green on -m ui, 34 green on -m e2e. Tests and a workflow, nothing that goes into the wheel, so no version. | 3 天前 | |
A tool result is read in one place, which its docstring already claimed (#1358) `link.text_of` carried a docstring saying it was "shared with the agent loop rather than written twice: a tool result is read in two places now, and two readers of one wire format drift". It was not shared. `agent._result_text` held the same five lines, the `[non-text result]` literal included, and imported nothing from `link`, and `/live/frame` was a third reader spelling out `getattr(result, "isError", False)` for itself beside a call to `text_of`. Three readers, two of them with their own tests, so either copy could have moved alone and stayed green. `link.answer_of` answers both halves now, and the loop and the route read it. The name is not `said`, which is already a function in `agent.py`, the name of an event the page draws, and a local in two routes. The tests that held `_result_text` moved with the function. The gate that keeps it that way is structural rather than nominal, because the surface gate added in 0.67.0 cannot see this class: a duplicated reader is named in `src` and answers yes. It walks the AST and asserts that only `link.py` reads `isError` or the no-text literal, so a comment can neither trip it nor satisfy it. Four known-bad inputs, all killed, including `link` no longer reading the flag at all, which would otherwise have left the first assertion vacuous. Two smaller things in the same branch. `Conversation.known` was the only attribute of that class not declared in `__init__`: it was assigned inside `if self.tool_defs is None`, so a conversation whose definitions arrived any other way raised AttributeError on the line that tells a model it asked for a tool nobody has. It is read off `tool_defs` now; an empty list in `__init__` would have been worse, because an empty list is a legal answer that refuses every tool quietly. And two comments named code that has not existed for weeks in the present tense, `registry.py` in `mcp/server.py` and `registry.peek` reached from a `looking` helper in `link.py`, the second inside a paragraph that opens by warning about stale reasoning. The past-tense mentions stay. Separately, `07-fleet.css` went in 0.53.0 and left the stylesheet folder reading 01 to 06 and then 08, in a module whose docstring says the order is load-bearing. `08-stage.css` is `07-stage.css` and the assembled page is byte for byte what it was. 0.68.2 because six files that go into the wheel changed and 0.68.1 is already on the index, which `test_version_is_not_taken` refused until it was bumped. Verified locally: 703 passed, ruff clean, `invisible_core.english` clean, `check_content.py` clean, the version gate green against the real index, and the wheel builds at 0.68.2 with the renamed stylesheet inside. Both opt-in suites still collect. | 2 天前 | |
A tool result is read in one place, which its docstring already claimed (#1358) `link.text_of` carried a docstring saying it was "shared with the agent loop rather than written twice: a tool result is read in two places now, and two readers of one wire format drift". It was not shared. `agent._result_text` held the same five lines, the `[non-text result]` literal included, and imported nothing from `link`, and `/live/frame` was a third reader spelling out `getattr(result, "isError", False)` for itself beside a call to `text_of`. Three readers, two of them with their own tests, so either copy could have moved alone and stayed green. `link.answer_of` answers both halves now, and the loop and the route read it. The name is not `said`, which is already a function in `agent.py`, the name of an event the page draws, and a local in two routes. The tests that held `_result_text` moved with the function. The gate that keeps it that way is structural rather than nominal, because the surface gate added in 0.67.0 cannot see this class: a duplicated reader is named in `src` and answers yes. It walks the AST and asserts that only `link.py` reads `isError` or the no-text literal, so a comment can neither trip it nor satisfy it. Four known-bad inputs, all killed, including `link` no longer reading the flag at all, which would otherwise have left the first assertion vacuous. Two smaller things in the same branch. `Conversation.known` was the only attribute of that class not declared in `__init__`: it was assigned inside `if self.tool_defs is None`, so a conversation whose definitions arrived any other way raised AttributeError on the line that tells a model it asked for a tool nobody has. It is read off `tool_defs` now; an empty list in `__init__` would have been worse, because an empty list is a legal answer that refuses every tool quietly. And two comments named code that has not existed for weeks in the present tense, `registry.py` in `mcp/server.py` and `registry.peek` reached from a `looking` helper in `link.py`, the second inside a paragraph that opens by warning about stale reasoning. The past-tense mentions stay. Separately, `07-fleet.css` went in 0.53.0 and left the stylesheet folder reading 01 to 06 and then 08, in a module whose docstring says the order is load-bearing. `08-stage.css` is `07-stage.css` and the assembled page is byte for byte what it was. 0.68.2 because six files that go into the wheel changed and 0.68.1 is already on the index, which `test_version_is_not_taken` refused until it was bumped. Verified locally: 703 passed, ruff clean, `invisible_core.english` clean, `check_content.py` clean, the version gate green against the real index, and the wheel builds at 0.68.2 with the renamed stylesheet inside. Both opt-in suites still collect. | 2 天前 | |
One page per browser, no session concept, and the names to match (#1301) 24 tools before, 16 now. No tool takes `session_id`; which piece of work a server is comes from `AIHAWK_SESSION_ID`, read once at spawn. The four tab tools are gone - a browser drives one page, and the answer to "I need a second page" is the `support` browser, which is a better answer because a second tab inside `main` carries that identity's cookies to the second site. One server process per conversation instead of one connection multiplexed by an id. Bugs found and fixed along the way, each with the measurement that found it: - The wake reopened every saved url as its own page, and `browser_status` then blamed the site for pages the wake had opened. - The interface and the server could silently address different files: two declarations of `"default"`, guarded by an assertion comparing one of them to a literal. - The key rule had two implementations and the tested one was dead. - A test in the fast CI job downloaded 665 MB of Firefox, and four pushes hung to GitHub's six-hour ceiling without ever going red: a job that hangs reports `in_progress`. Jobs have a ceiling now and a hung test dumps its stack. - A unit test served the interface forever, because its brake pointed at a name the command had stopped reading. It was green locally only because the developer's own interface held the port. - A reopened conversation lost the answer of every turn but the last. - The session drawer moved the whole window 48px and rewrapped the transcript, then covered half the composer, then still covered it below 720px where the panes stack. - A page older than its server degraded in silence; it says so now. - The English gate had never looked at `.js`, `.css` or `.html` - 67 KB of served page - while five front-end files carried Italian. - Two fetches were outside the one funnel that knows what a 404 and a 410 mean. `ruff --select F` is a CI job now, earned by measurement: with an import dropped the suite stays fully green and ruff finds it in under a second. 556 passed, 8 skipped, 47 deselected. Every new gate was run against its own known-bad and green again on restore. Five of them EXECUTE the thing they guard rather than scanning it, because a scan cannot tell whether a timer is reached, what a function returns, or where a box ends up on screen. | 6 天前 | |
A .env beside the command, and the floor that makes the verb table true (#1210) The key and the browser path are the two things nobody wants to retype, and a shell profile is a bad home for them: global, invisible from the project, and different on every machine. A .env in the directory the command runs from is read at startup. Three decisions carry it. The current directory only, never a walk upwards, so running from a subfolder cannot silently pick up somebody else's key with nothing on screen saying which file was used. It never overrides what is already set, so the order a reader can rely on is --flag > environment > .env > default. And the startup line names the variables it applied and never their values, because terminals get pasted into issues. python-dotenv is declared rather than inherited through mcp: a transitive dependency is one somebody else can drop in a minor release. That work existed but shipped undiscoverable: no tests, and no mention in the README, which is the same defect the tool descriptions were audited for. Seven tests now, including one that fails if the README stops explaining it. The dependency floor moves to invisible-playwright-mcp>=0.11.0, and that half is not cosmetic. #1213 added verbs for session_start and session_status, which only exist from 0.11.0; with the floor at 0.10.0 an install can resolve a server that offers neither, and test_the_table_names_no_tool_that_does_not_exist refuses a table naming tools the server does not have. CI resolves that package from the index rather than a checkout, so the floor is the only place that can say which server this interface goes with. | 14 天前 | |
The transcript follows the agent, and the scroll waits for the layout (#1361) It reads as "the transcript stops scrolling after a while". It never scrolled. For the first 24 rows the transcript is shorter than the window, so there is nothing to scroll and every distance to the bottom is zero by construction. From there the view falls behind by exactly the height that arrives, 623 px per 20 rows, for as long as the agent keeps working. Two separate things were wrong, and the second one only became visible once the first was fixed. **The page had two answers to "is the reader following".** `settleOnce()` scrolled once, 150 ms after the first event, and set a latch so nothing would scroll again. At 150 ms the transcript is empty, so that one scroll had nothing to do. Meanwhile an IntersectionObserver knew the answer continuously and was used only to show a button. The latch drove the scrolling. The rest was left to `#anchor` and `overflow-anchor:auto`, which can only hold a bottom something else has already reached, because the browser will not choose an anchor that is off screen. The CSS carried a comment claiming "bottom-pinning with no scroll handler"; that was false and is corrected here. The alternative reading was that anchoring is suppressed at scroll offset zero, so it was measured: with the scroller one pixel from the top, 374 px of rows arrived and it moved by zero. One pixel is not zero, so the offset is not the reason. **A scroll scheduled while appending runs before the layout of what was appended.** With the latch gone and `put` scrolling on a frame, a live run followed perfectly and a reopened conversation still opened at the top, three times out of three, 2084 px from the bottom. Instrumented from inside that callback: the DOM already held all 65 rows and the scroller still reported a height of 808, its own window. It scrolled to 808, which clamps to zero. No choice of target fixes that, because `scrollIntoView` on the sentinel reads the same layout, and checking whether the scroll arrived does not either, because the check reads that layout too and concludes it did. So there is one fact and one trigger. The fact is the observer, which knows continuously whether the reader is following. The trigger is a ResizeObserver on the transcript, delivered after layout, which is the only moment the bottom is knowable and which also covers every other way the transcript can grow: a picture that loads, a font that swaps, a row expanded, the window resized. `put` no longer scrolls. The latch, the timer and the two variables behind them are gone rather than repaired, and a tombstone says why. Five tests, which execute the real page code in node under a shim rather than scanning it. The shim is half the gate, because two earlier versions passed while lying. It counts the `scrollTop` setter instead of its own ticks, since a page that scrolled sixty times inside one tick reported one and passed; what is promised is one scroll per arrival, so scrolls are what is counted. It clamps like a real scroller, or `toBottom` would leave the offset past the end and every distance would read negative. And it delivers the two observations in the order a browser delivers them, the resize after layout and the intersection after that, so a page that cannot work in a browser cannot pass here. The other half is the regression this file exists to prevent. The old design bought "never yank a reader who scrolled up" by never scrolling at all, and a fix that follows the bottom and drags the reader back every time a row lands would be worse than the defect it replaces. So the "left alone" half is asserted twice in a row: a latch passes it once and never again, which is exactly the shape of what is being removed. Six known-bad mutations, all killed: `grew` scrolling never, `grew` ignoring the reader, `grew` with the condition inverted, the scroll moved back into `put`, `toBottom` going to the top, and nothing counting what arrived. Verified in a real browser, not only under the shim: a 65-row conversation reopens at the bottom three times out of three, the view follows for a whole agent run, and a reader who scrolls up is left alone twice in a row. The scan terms took three tries to get narrow enough, which is this repository's most repeated gate defect taken from the side where it accuses rather than passes. `pinned` on its own accuses `stage.pinned`, the pane the person chose to watch. `requestAnimationFrame` over the whole page accuses `fitOrOpen`, which uses one to batch layout reads and has nothing to do with scrolling. And `settleOnce` was satisfied by its own tombstone. So comments come out before the scan, and the frame scheduling is asserted absent from the mechanism, and only there. One mutation survived and was not a hole: reading the observer after the append instead of before changes nothing, because an IntersectionObserver is asynchronous and the two orderings are equivalent. The fix was to correct the comment that claimed too much, not to weaken the gate. The third commit is a rename. The gate was written with Italian identifiers and `invisible_core.english` refuses them; the six mutations were re-run afterwards and all six are still killed. Verified locally: 714 passed, ruff clean, `invisible_core.english` clean, `check_content.py` clean, and the version gate green against the real index. | 18 小时前 | |
The two doors are gone, and every route is a function with a name (#1329) * The two doors are gone, and every route is a function with a name `web.py` was forty lines re-exporting four modules, kept so that callers would not have to change; `brain.py` was twenty-one lines of code forwarding to `agent.py`, beside a docstring about an implementation removed in 0.4.0. Both are deleted. Every importer names the module the thing lives in: `aihawk.ui` for the page, `aihawk.routes` for the app, `aihawk.sessions`, `aihawk.chat`, and `aihawk.agent` for the brain, which is the loop's one consumer and now sits at the end of the loop's file. The routes were closures inside `build_app`, 297 lines deep, so a reader looking for the handler that serves the frame had to find it inside the function that built the app. They are module-level functions now and find the registry of conversations on `request.app.state.sessions`; `build_app` does the one thing its name says. The route tests build their fake requests through one helper that carries the app. The door had a measured cost, which is why this is not tidying. The gate that checks every event kind the server can send is drawn by the page read `web.py` for those kinds - and had been reading forty lines of re-exports since 2026-09-10, green the whole time. Read from the three modules that emit, it found `note`, the interface's own "Stopped." after the button, drawn by the page's total default as if the model had said it. The page names it now. Started from this tree after the change: the page, the session listing, the browsers and the frame answer as before, and an unknown session is 410. Three mutations, three killed. * The clean-venv import in CI names the modules that exist | 4 天前 | |
Become the package, not a folder that contains one The repository held documentation and a `pkg-cli/` directory off to one side. It is the product now, so the package is at the root: pyproject.toml, src/aihawk, tests. The articles move to articles/ because they are prose, and src/ is for code. The README was describing the opposite of what this is. It told a reader to install an MCP server into Claude Desktop and drive it from there - true when this repository was documentation for somebody else's client, and backwards now that the interface and the model are here. It leads with `uvx aihawk ui` and an OpenRouter key, and keeps the MCP path further down for people who already have a client, because for them it is still the right answer. CONTRIBUTING said "there is no application to build or run here". That was written this morning and was true for about six hours. ONE LOOP, and this is the part worth reading. `do` ran agent.run_task and the interface had its own copy with narration added: two implementations of the same five decisions, while the README promised "same machinery". The narration is a parameter now - `do` passes a sink that drops it, the interface passes the thing that pushes events to the page - and there is exactly one call to the model in the whole package. Conversation holds the transcript so the follow-up box means something; `do` throws it away after one instruction and gets its old behaviour for free. Two failures a tool can produce stop ending the run, and both changes were requested by the tests that pinned the old behaviour: they said in their own docstrings that they recorded a defect and should be updated the day somebody fixed it. Malformed tool arguments are now reported back so the model can retry, and the tool is still not called with them. A tool that raises - a timeout, a closed page, a refused connection - is fed back as its result. On a page nobody controls those are the normal texture of a task, and a twenty-step run that dies on the first one is not a run. CI arrives: the English gate as its own copy, because running a sibling's copy scans the sibling and reports clean; tests on Linux and Windows across 3.11 to 3.13; and a job that builds the wheel and installs it into an empty environment, because an editable install passes every test while the built artifact is missing a module. Every run: block was executed locally before this was committed. | 15 天前 | |
The browser server forgets the model key, whoever handed it one (#1359) `child_env` strips the OpenRouter key from the environment the interface hands its child, by name and by every alias carrying the value, with twenty-two tests behind it. The child then took it straight back. The child is `python -m aihawk`, which is this same click group, and the first thing the group does is read `.env` from the directory it inherited. The key is in that file, because the README says to put it there. So the process that launches Firefox held it anyway, and `invisible_playwright._session.build_env` seeds the engine's environment from that process: measured against the published 0.68.2, the key arrives in the environment Firefox is launched with. The product said so out loud and nobody was reading it. This line appears twice at startup, once per process: ``` env .env: OPENROUTER_API_KEY ``` and it names only what the file APPLIED, which is only what was not already there. The second copy is the child announcing that it had no key and has just given itself one. `runner.forget_key` takes the key out of a live environment, and the server path in `cli.main` calls it for itself. Reading the environment rather than filtering the file is deliberate: a browser server has no use for a model key whoever started it, so somebody running `uvx aihawk` in a shell that exports one, or beside a `.env` that holds one, had the same exposure and was never told. One call covers the file, the export and any alias. What counts as the key is now `runner.without_key`, one function with two callers asking it from opposite ends. Six tests, four of which could not have existed before, because every test in that file stopped at the handover. One spawns the real server as a subprocess and asks what it ended up with, reading its own stderr line rather than anything written for the test. One holds the other direction, that the INTERFACE still gets the key from `.env`: without it, "delete the key everywhere" passes everything else and the product cannot start. Four known-bad inputs, all killed: the call removed, the call made unconditional, the report taken before the drop, and removal by exact name only. Two smaller things fixed in passing. The module docstring claimed two xfail markers the file itself records as deleted, and said no test spawns the server. And these tests now restore `os.environ`, which is not tidiness: `load_env_file` sets variables `monkeypatch` never saw, and one left behind made the next test read an environment that already held it, so the file applied nothing and the failure pointed at the product; `forget_key` deletes by value, so a developer running the suite with a real `OPENAI_API_KEY` exported would have lost it. This is not a leak to the network: Firefox does not send its environment anywhere. It is a defence this repository wrote on purpose, undone half a second after it ran. Verified locally: 709 passed, ruff clean, `invisible_core.english` clean, `check_content.py` clean, and the version gate green against the real index. | 2 天前 | |
Identify the app to OpenRouter, and default to GLM 5.3 Flash (0.12.0) (#1257) Two changes to what leaves the process for the model. OpenRouter groups its public rankings by HTTP-Referer and X-Title, and this package sent neither, so every request was anonymous traffic on the key. They go on the client rather than the call site: one place, and every request carries them whatever calls it. Nothing fails when they are missing - no error, no changed answer, the app simply is not there - so the tests assert them on the wire against a local stub server rather than on a client attribute, which is also how the key is asserted a few tests above. The default model moves from z-ai/glm-4.6 to z-ai/glm-5.3-flash: input is $0.071-$0.388 per million against $0.43-$0.60, output $0.237-$1.358 against $1.75-$2.20, and the context window is 1,310,720 tokens against 204,800. Anyone who set --model or AIHAWK_MODEL is unaffected. The wiki carried the old default in four pages, three of them with cost arithmetic resting on its prices; all four are updated with figures retrieved today, and the section arguing that a cheaper model which retries is not cheaper now says plainly that the caveat applies to the new default too, since there is no measurement here of how it holds up over twenty turns of tool calls. One test pinned the default as a literal copy and now reads the constant, which is the drift the test beside it warns about. | 10 天前 | |
The interface after a UX pass: a modal panel, a truthful transcript, controls that say what they do (#1302) A UX pass over the interface, driven by an audit of six dimensions - spatial model, typography, interaction, accessibility, visual hierarchy, first run and failure states - with every finding verified against the code before it was acted on, and every change checked in a real browser afterwards. The owner's complaint first. The sessions panel covered half the conversation and left it looking readable: measured 240px off the front of every line at every desktop width, 48% of the measure at 1440px, 61% at 960. It is a modal now - the page behind it goes inert and dims, Escape closes it, a click outside closes it, choosing a conversation closes it - and `inert` has one owner because two reasons can hold the same pane. The transcript stops saying things that are not true. A step nobody landed breathed for ever; Stop was reported as an error; a failed step was marked by colour alone with the same verb as one still running; tool output was cut in silence for the watcher and the model; a dead stream left the clock counting; the queued sentence was never on screen and a second Enter destroyed it; the most frequent row said the same four words twice. Nobody is locked out by how they use it. The live region was rewritten 25 times a second; a reconnect read the whole conversation aloud; the one input had no focus ring, then two; every finished step was a tab stop that opened nothing; screens and chips were named by their contents. Every control says what it does. Enter looks different when it will queue; Clear explains itself instead of going dead; the first-run example fills the box; a failed load of the session list is told from an empty one; one rule says "cannot be used"; pressed is one rung above hovered; the panel's icon sits on the line everything else shares. Found only by screenshots, and recorded as such: a comment inside a start tag that printed attributes down the seam between the panes; two backspace bytes that had blinded two gates; two nested focus rectangles on the composer. Each has a gate now, including one that reads the bytes of every tracked file and one that reads the served page with an HTML parser. Measured and rejected, so nobody re-derives it: widening the conversation when the browser pane is empty (it already opens at 70 characters a line), and centring the two panes (ragged bands at both edges). 579 passed. Fifty-one known-bad mutations run against the new and rewritten gates, fifty-one killed. One commit landed with a red test because the suite's verdict was read through a word filter; the next commit says so and fixes it. | 5 天前 | |
One conversation gets one server, however many requests arrive at once (#1365) * One conversation gets one server, however many requests arrive at once `Sessions.get` was a check-then-act across an await. It looked in `_live`, found nothing, awaited `_open_link` - which spawns `python -m aihawk` and shakes hands with it - and only then wrote the service into `_live`. Every request that arrived inside that window found nothing too, and spawned its own. Measured through the seam this class already declares for exactly this purpose, with an `open_link` that takes 200 ms: six concurrent calls for one conversation opened SIX connections and returned SIX DISTINCT services. The process leak is the smaller half. A conversation is a piece of state - a transcript, whether a run is in flight, the listeners an open page is subscribed to - so six services for one conversation are six copies of it, and a page handed a losing one watches a conversation that will never advance, because the events are emitted on a different object. `close_all` iterates `_live.values()`, so it closes the winner and cannot close what it never learned about: on the same measurement, three of five connections survived the shutdown, each still holding a process. The page produces that concurrency without trying. Opening a saved conversation fires `/chat/events`, `/live/browsers`, `/live/frame` and `/sessions` at once, and after a restart none of them is live. It did not reproduce over HTTP on an idle machine, and that is the point: the window is only as wide as the spawn is slow. Hoping a production race fires is not a measurement, so the duration of the spawn is a parameter of the test instead. The fix is one lock around the making of a conversation, with the question asked again inside it - whoever held the lock may have been making exactly this one, and the answer that mattered was taken before waiting for them. One lock and not one per id: the fast path, a conversation already open, returns before touching it, so what is serialised is the making of two conversations at the same instant. That is rare and already expensive, while a dictionary of locks would be a second registry to keep in agreement with `_live` and for somebody to empty. The shutdown leak needed no separate repair: with one connection per conversation there is nothing `_live` cannot see. One cause, both symptoms. Five tests, three known-bad mutations all killed: the guard removed, the guard without the second look, and the service never registered. The suite's own gate caught the first draft of the file for declaring `asyncio` per test when the suite declares `asyncio_mode = "auto"` once. Verified on the real product too: eight concurrent requests for a saved conversation, one server, and every server it opened closed on shutdown. Suite: 719 passed, 9 skipped. Lint, `invisible_core.english` and `check_content.py` clean. And the same assumption one method along. `new` picked an id from the clock in milliseconds, checked it was free, and then awaited. Two callers in the same millisecond computed the same id and both found it free - a conversation being MADE is in neither `_live` nor on disk - so the second was handed the first one's conversation, transcript included. Measured: two `new()` at once returned the same object. That is the id space having a third state, "being made", that nothing represented. `new` now chooses and claims without letting go of the lock, so "free" means something: the previous caller has registered before releasing. The creation itself is `_make`, one place, called by both entry points rather than `new` going through `get` - which is what made the id it had just chosen somebody else's business. Seven tests, four known-bad mutations killed: the guard removed, the guard without the second look, the id chosen outside the lock, and `new` going back through `get`. Suite: 721 passed, 9 skipped. And a third place, in `forget`, with two windows of the same shape. It closed the connection and only THEN dropped the service, so anything asking for that conversation in between was handed one whose link was already shut; and it did not wait for a creation in flight, so a conversation being made registered itself into `_live` AFTER its files had been erased - alive, unreachable by name, and holding a process nobody would close. It now runs under the same lock and leaves the registry before it closes anything. Nine tests, six known-bad mutations killed. `Work.listing` was checked for the same shape and does not have it: every call on one conversation's link is serialised by `Link._lock`, so a close cannot overlap a listing. Suite: 723 passed, 9 skipped. Lint, `invisible_core.english` and `check_content.py` clean. * 0.68.5 | 12 小时前 | |
Nobody calls these, and one rule was still written twice (0.56.0) (#1337) Four removals and one move, all found by reading the tree rather than by a test, because none of them can fail: code nobody calls cannot be wrong. Work.launched_with had no caller anywhere, tests included. StealthSession.where_pages_are had no caller in the product. Three assertions kept it alive, and they were the only reason it still existed: it is the cheap half of describe_pages, from the days when a command asked for page urls on every call. The two assertions worth keeping moved onto describe_pages, which is the one thing that answers where the pages are. The saved session file carried a name that was always a copy of its id. It was kept on the argument that an older build rolled back onto this directory would expect to find it, and no build ever read it: its one reader was known(), which went with the session_list tool when MCP stopped having a session concept. Files already on disk still carry it and are read exactly as before - load hands back what it finds and remembered() reads two keys. And the thing the storage split missed, because it went looking for duplicated VALUES. When aihawk.storage was carved out it took the three things that carry a value somebody could get wrong: where the data lives, how an id becomes a file name, how a file is replaced without a torn read. Reading and deleting stayed behind in both halves, four lines each, looking like plumbing. They are not: load answering None for a file that will not parse is a DECISION, and it was written twice with only one of the two explaining itself. Deleting was the same again, and both copies answered a bool that neither caller read and whose False meant "there was nothing there" and "it could not be deleted", which are opposite news - the exact ambiguity Sessions.forget carries a scar from, on the day it answered False for a session somebody else had already deleted and the page said it was still working. Both now live in storage, once, and erase answers nothing. Two gates make the removals stick rather than leaving them to be undone. The saved session's whole key set is asserted, the way the listing's rows are since 0.54.0 - nothing was watching that document, which is how a copy of a field sat in it unread. And the wiring is asserted rather than the behaviour: both halves are shown to go through the one reader and the one eraser, because two identical implementations agree on every input by construction, which is exactly why this duplication survived a refactor that went looking for it. Suite 594 green, and seven known-bad inputs, seven killed: each half given back its own parse, each half given back its own unlink, an unparsable file raising instead of reading as nothing, erasing something absent failing again, and the copy of id coming back into the file. | 3 天前 | |
A tool result is read in one place, which its docstring already claimed (#1358) `link.text_of` carried a docstring saying it was "shared with the agent loop rather than written twice: a tool result is read in two places now, and two readers of one wire format drift". It was not shared. `agent._result_text` held the same five lines, the `[non-text result]` literal included, and imported nothing from `link`, and `/live/frame` was a third reader spelling out `getattr(result, "isError", False)` for itself beside a call to `text_of`. Three readers, two of them with their own tests, so either copy could have moved alone and stayed green. `link.answer_of` answers both halves now, and the loop and the route read it. The name is not `said`, which is already a function in `agent.py`, the name of an event the page draws, and a local in two routes. The tests that held `_result_text` moved with the function. The gate that keeps it that way is structural rather than nominal, because the surface gate added in 0.67.0 cannot see this class: a duplicated reader is named in `src` and answers yes. It walks the AST and asserts that only `link.py` reads `isError` or the no-text literal, so a comment can neither trip it nor satisfy it. Four known-bad inputs, all killed, including `link` no longer reading the flag at all, which would otherwise have left the first assertion vacuous. Two smaller things in the same branch. `Conversation.known` was the only attribute of that class not declared in `__init__`: it was assigned inside `if self.tool_defs is None`, so a conversation whose definitions arrived any other way raised AttributeError on the line that tells a model it asked for a tool nobody has. It is read off `tool_defs` now; an empty list in `__init__` would have been worse, because an empty list is a legal answer that refuses every tool quietly. And two comments named code that has not existed for weeks in the present tense, `registry.py` in `mcp/server.py` and `registry.peek` reached from a `looking` helper in `link.py`, the second inside a paragraph that opens by warning about stale reasoning. The past-tense mentions stay. Separately, `07-fleet.css` went in 0.53.0 and left the stylesheet folder reading 01 to 06 and then 08, in a module whose docstring says the order is load-bearing. `08-stage.css` is `07-stage.css` and the assembled page is byte for byte what it was. 0.68.2 because six files that go into the wheel changed and 0.68.1 is already on the index, which `test_version_is_not_taken` refused until it was bumped. Verified locally: 703 passed, ruff clean, `invisible_core.english` clean, `check_content.py` clean, the version gate green against the real index, and the wheel builds at 0.68.2 with the renamed stylesheet inside. Both opt-in suites still collect. | 2 天前 | |
Identify the app to OpenRouter, and default to GLM 5.3 Flash (0.12.0) (#1257) Two changes to what leaves the process for the model. OpenRouter groups its public rankings by HTTP-Referer and X-Title, and this package sent neither, so every request was anonymous traffic on the key. They go on the client rather than the call site: one place, and every request carries them whatever calls it. Nothing fails when they are missing - no error, no changed answer, the app simply is not there - so the tests assert them on the wire against a local stub server rather than on a client attribute, which is also how the key is asserted a few tests above. The default model moves from z-ai/glm-4.6 to z-ai/glm-5.3-flash: input is $0.071-$0.388 per million against $0.43-$0.60, output $0.237-$1.358 against $1.75-$2.20, and the context window is 1,310,720 tokens against 204,800. Anyone who set --model or AIHAWK_MODEL is unaffected. The wiki carried the old default in four pages, three of them with cost arithmetic resting on its prices; all four are updated with figures retrieved today, and the section arguing that a cheaper model which retries is not cheaper now says plainly that the caveat applies to the new default too, since there is no measurement here of how it holds up over twenty turns of tool calls. One test pinned the default as a literal copy and now reads the constant, which is the drift the test beside it warns about. | 10 天前 | |
Nothing ever drove a route through a real server, and one listing was checked by nobody The interface being an MCP client is this architecture's central claim: it is why the tools are said to be provably sufficient, because the flagship interface is a client of them. No test joined the two halves. One test drove a real server and never touched a route; the others drove every route against a fake link, and the two assertions on /live/frame accepted every status the route can produce. One request now goes through the whole chain with only the browser left out, and making the route reach the registry in-process instead turns it red. No engine is downloaded, so it runs by default. Writing it found two things. The first version used TestClient, like every other route test here, and hung with no output: TestClient runs the app in its own event loop while a real Link holds its stdio streams in the test's. Every request is now bounded, so the next regression of that shape fails with a name instead of hanging a suite. And the first assertion was wrong about the product rather than the plumbing: browser_list reports the browsers that exist, not the two roles that could. Also: server.json's description was the one duplicated fact nothing compared, and the file's own docstring still named a manifest removed in #1316. | 4 天前 | |
One page per browser, no session concept, and the names to match (#1301) 24 tools before, 16 now. No tool takes `session_id`; which piece of work a server is comes from `AIHAWK_SESSION_ID`, read once at spawn. The four tab tools are gone - a browser drives one page, and the answer to "I need a second page" is the `support` browser, which is a better answer because a second tab inside `main` carries that identity's cookies to the second site. One server process per conversation instead of one connection multiplexed by an id. Bugs found and fixed along the way, each with the measurement that found it: - The wake reopened every saved url as its own page, and `browser_status` then blamed the site for pages the wake had opened. - The interface and the server could silently address different files: two declarations of `"default"`, guarded by an assertion comparing one of them to a literal. - The key rule had two implementations and the tested one was dead. - A test in the fast CI job downloaded 665 MB of Firefox, and four pushes hung to GitHub's six-hour ceiling without ever going red: a job that hangs reports `in_progress`. Jobs have a ceiling now and a hung test dumps its stack. - A unit test served the interface forever, because its brake pointed at a name the command had stopped reading. It was green locally only because the developer's own interface held the port. - A reopened conversation lost the answer of every turn but the last. - The session drawer moved the whole window 48px and rewrapped the transcript, then covered half the composer, then still covered it below 720px where the panes stack. - A page older than its server degraded in silence; it says so now. - The English gate had never looked at `.js`, `.css` or `.html` - 67 KB of served page - while five front-end files carried Italian. - Two fetches were outside the one funnel that knows what a 404 and a 410 mean. `ruff --select F` is a CI job now, earned by measurement: with an import dropped the suite stays fully green and ruff finds it in under a second. 556 passed, 8 skipped, 47 deselected. Every new gate was run against its own known-bad and green again on restore. Five of them EXECUTE the thing they guard rather than scanning it, because a scan cannot tell whether a timer is reached, what a function returns, or where a box ends up on screen. | 6 天前 | |
The product surface is what the product uses (0.67.0) (#1351) An audit for code that exists only because the tests grew up around it. Seven surfaces came out of src/aihawk and every one had the same shape: zero callers in the product, and enough callers in the suite to look load-bearing. AGENT.RUN_TASK WAS A SECOND WAY TO RUN THE AGENT LOOP. Its own docstring said so: "not called by the product - kept because the suite drives the loop through it, about twenty-five tests". Worse than unused, it took an object with list_tools and call_tool, which is the shape from before Link existed, while the product passes link.call and link.tools to Conversation.run. A reader of agent.py met two entry points with two different ideas of how a tool is reached, and one of them was imaginary. The convenience was real, so it moved to tests/_loop.py rather than being deleted. The e2e test that drove a real server through it now goes through Conversation.run, which is the path the product takes. SESSIONS.AROUND OFFERED ITSELF TO "ANYTHING EMBEDDING THIS", a user that does not exist: this is an application, and the only importer of sessions.py is cli.py. Eleven callers, all in two test modules. The argument it was written for is good and survives in tests/_sessions.py, because it is a rule about the suite: a test driving ONE conversation should still go through build_app and the routes, so the single case is exercised by the same code the many case uses. SESSIONS._OPEN_LINK WAS A SEAM NOTHING DECLARED. It was assigned in __init__ and reassigned from outside by two test modules - a convention a reader of the signature could not see and a reader of the assignment could not tell from an accident. It is a keyword argument now, so how a connection is made is part of the interface and the default is the product's one way. SESSIONPLAN.DESCRIBE WAS A SECOND MAPPING OF A PLAN ONTO THE SENTENCE a caller is told, beside the one in work.open, and the two were free to disagree the day a fifth field joined the sentence. It also described something that does not happen: between planning and launching, work.open can rewrite the settings and the exit note - that is how the helper browser comes to share main's exit - so the sentence is read from what was LAUNCHED and never from what was planned. A method on the plan quietly offered the other thing. Its six callers were all in one test file and now go through the function the product goes through. CLEAN.RELEVANCE AND CLEAN.CLEAN_STATS ARRIVED DEAD and stayed that way. Fifty seven lines scoring how much a model needs an element, and seven figures about what the cleaning saved, both imported with the server on 2026-09-06 and named by no commit since. clean_stats was in __all__ and had a test; relevance had neither, and the module docstring described it as part of how the file works - "the relevance score below orders and annotates" - so the file's own account of itself named a pass that does not run. What those two were really carrying is kept: that nothing here may drop an element, and that a reduction figure says nothing about whether the result is still usable. Both are the invariant at the top of the module, which is where a rule can act. CLEAN.BLOCK_TAGS was a tuple nothing read. TWO PLACES ALSO STOPPED HOLDING THE SAME FACT TWICE. storage.file_for owns the rule that turns a session id into a path, so chats.py and mcp/store.py each name only their own directory and neither spells the join. And a test migrated off the store.home re-export onto aihawk.storage.home, which is where home lives. THE GATE IS ON THE CLASS, NOT ON THE SEVEN NAMES. A list would stop those seven; the defect is that a surface can be added, be used only by its own tests, and look exactly like one the product depends on. So it asks the general question: every top-level function, class and constant in src/aihawk must be named somewhere in src/aihawk. Being registered excuses it, and that is structural - a decorator spelled .tool, .command or .group hands the object to FastMCP or to click, where the call happens over a wire or from a shell. Wrapping does not: dataclass and contextmanager hand the object straight back, so it is still called by name. Exporting it in __all__ does not count either, because __all__ is the claim under audit rather than evidence for it, and that is precisely where clean_stats was hiding. WHAT THE GATE DOES NOT SEE IS WRITTEN INTO IT. 178 definitions across 25 modules, with floors asserted so a scan that goes blind fails instead of printing the same clean line. Methods are out of scope: a method reaches the code as an attribute, and plan.describe and SessionPlan.describe are the same attribute name, so no scan can tell the dead one from the live one beside it. That one was found by reading, and this gate would not have found it. Suite 654 green, from a 642 baseline: one test went with clean_stats and thirteen arrived with the gate. Six known-bad inputs and five cases that must not fire, plus a mutation against the real tree - relevance put back into clean.py, which the gate names by file and line. The first three must-not-fire cases failed on the first run and the gate was right: a toy module whose own outermost function has no caller has a dead surface, so each fixture ends at a registered entry point, exactly as the package does. | 3 天前 | |
A tag that published nothing is said out loud (#1339) Measured today. v0.56.0 was tagged, the publish workflow ran, its gate job died on a 504 downloading the engine from GitHub's release assets, and upload was therefore skipped. So the tag existed, main carried the bumped version, and the index served nothing - for ten minutes, with a red workflow as the only signal. Re-running the failed jobs published it. Had nobody opened that run, the next release would have been 0.57.0 and 0.56.0 would simply not exist. Nothing in this suite could have said so. test_release_pages walks the INDEX, so a version that never reached the index is not in anything it looks at, and it is one-directional on purpose because a release page ahead of the index is a normal intermediate state. test_version_is_not_taken answers "free" for exactly this case, which is its correct answer. A TAG is the durable artifact of an attempted release - the release page is not, because a failed gate skips that step too - so the new walk starts from tags and asks the index. It is bounded by AGE rather than forbidden outright: a tag from a minute ago with no version on the index is a publish in flight, which is normal; one from an hour ago is a release that silently shipped nothing. The grace is 45 minutes, which is loose enough that a release in flight is never red and tight enough that a failed one is red the same afternoon. Cheap by construction: one call for the tags, one for the index, and one per SUSPECT, which on a healthy repository is none. Right now the two sets are 64 and 64, so it is born green - and it would have been red between 16:59 and 17:09 today. It compares against every version the index has ever served, yanked ones included: a yank says nobody should install that version, not that it was never published, so comparing against the live list would report every yank as a failed publish. Two known-bad inputs. Point PACKAGE at a name this account does not publish and every tag becomes a suspect: red. And the case that must NOT fire - a version missing from the index whose tag is minutes old, which is a publish in flight - stays quiet. The CI step that runs this file was called "every published version has a release page", which is now half of what it does. | 3 天前 | |
One page per browser, no session concept, and the names to match (#1301) 24 tools before, 16 now. No tool takes `session_id`; which piece of work a server is comes from `AIHAWK_SESSION_ID`, read once at spawn. The four tab tools are gone - a browser drives one page, and the answer to "I need a second page" is the `support` browser, which is a better answer because a second tab inside `main` carries that identity's cookies to the second site. One server process per conversation instead of one connection multiplexed by an id. Bugs found and fixed along the way, each with the measurement that found it: - The wake reopened every saved url as its own page, and `browser_status` then blamed the site for pages the wake had opened. - The interface and the server could silently address different files: two declarations of `"default"`, guarded by an assertion comparing one of them to a literal. - The key rule had two implementations and the tested one was dead. - A test in the fast CI job downloaded 665 MB of Firefox, and four pushes hung to GitHub's six-hour ceiling without ever going red: a job that hangs reports `in_progress`. Jobs have a ceiling now and a hung test dumps its stack. - A unit test served the interface forever, because its brake pointed at a name the command had stopped reading. It was green locally only because the developer's own interface held the port. - A reopened conversation lost the answer of every turn but the last. - The session drawer moved the whole window 48px and rewrapped the transcript, then covered half the composer, then still covered it below 720px where the panes stack. - A page older than its server degraded in silence; it says so now. - The English gate had never looked at `.js`, `.css` or `.html` - 67 KB of served page - while five front-end files carried Italian. - Two fetches were outside the one funnel that knows what a 404 and a 410 mean. `ruff --select F` is a CI job now, earned by measurement: with an import dropped the suite stays fully green and ruff finds it in under a second. 556 passed, 8 skipped, 47 deselected. Every new gate was run against its own known-bad and green again on restore. Five of them EXECUTE the thing they guard rather than scanning it, because a scan cannot tell whether a timer is reached, what a function returns, or where a box ends up on screen. | 6 天前 | |
The two doors are gone, and every route is a function with a name (#1329) * The two doors are gone, and every route is a function with a name `web.py` was forty lines re-exporting four modules, kept so that callers would not have to change; `brain.py` was twenty-one lines of code forwarding to `agent.py`, beside a docstring about an implementation removed in 0.4.0. Both are deleted. Every importer names the module the thing lives in: `aihawk.ui` for the page, `aihawk.routes` for the app, `aihawk.sessions`, `aihawk.chat`, and `aihawk.agent` for the brain, which is the loop's one consumer and now sits at the end of the loop's file. The routes were closures inside `build_app`, 297 lines deep, so a reader looking for the handler that serves the frame had to find it inside the function that built the app. They are module-level functions now and find the registry of conversations on `request.app.state.sessions`; `build_app` does the one thing its name says. The route tests build their fake requests through one helper that carries the app. The door had a measured cost, which is why this is not tidying. The gate that checks every event kind the server can send is drawn by the page read `web.py` for those kinds - and had been reading forty lines of re-exports since 2026-09-10, green the whole time. Read from the three modules that emit, it found `note`, the interface's own "Stopped." after the button, drawn by the page's total default as if the model had said it. The page names it now. Started from this tree after the change: the page, the session listing, the browsers and the frame answer as before, and an unknown session is 410. Three mutations, three killed. * The clean-venv import in CI names the modules that exist | 4 天前 | |
The half the gate said it could not do, and one declaration of asyncio (#1352) 0.67.0 shipped a gate on dead product surface and declared methods out of scope, with a reason: a method reaches the code as an attribute, and plan.describe and SessionPlan.describe are the same attribute name, so counting names cannot tell the dead one from the live one beside it. That reason was true and it was not a limit, it was a missing step. It is done now. RESOLVE THE OWNER, AND THE AMBIGUITY GOES AWAY. An attribute on a name that the file imported as a MODULE is the module's function and never the method; self.X, Class.X and anything.X could all be the method and count. The second half is deliberately generous - an arbitrary expression counts as a reference to every method of that name - so it errs by letting something live, never by accusing it. 47 methods judged out of the 59 defined: the twelve left out are methods of a class WITH A BASE, which may be satisfying somebody else's contract, and decorated ones, which can be handed somewhere this cannot follow. The real-tree mutation it exists for - SessionPlan.describe put back - is named by file and line. AND THE FIRST VERSION OF IT LET THAT MUTATION SURVIVE. Strings were split into words, so the docstrings in plan.py - which say "describe() reads the KWARGS" - counted as reaching the method. The check was satisfied by the prose beside the code, which is the most repeated defect in this project, met inside the tool built to find it. Whole-string identifiers only now, which keeps the genuine getattr case and drops the sentences: the universe of attribute names went from 4,798 to 500, so nine tenths of what was keeping methods alive was prose. Floors AND a ceiling are asserted, because a collapse makes every method look dead, an explosion makes every one look alive, and a hand-written perimeter goes stale the day somebody changes what is excluded - which it already had, at 59. THE PAGE WAS SCANNED THE SAME WAY AND IS CLEAN, which is worth writing down as a measured negative rather than an assumption: src/aihawk/ui is product surface too and 0.67.0 never looked at it. 109 top-level JS bindings, every one named by another file or by the markup; 49 CSS classes, every one applied, confirmed by a second measurement taken a different way. The ten scripts become one concatenated script, so the question is the same one the Python gate asks. No gate here - the page has its own in test_the_browser_workspace.py - and the numbers are in the module docstring. ASYNCIO WAS DECLARED 46 TIMES FOR ONE FACT. pyproject.toml has asyncio_mode = "auto", which marks every async test already; 45 more copies sat in the files as @pytest.mark.asyncio or a module-level pytestmark. Copies of one fact can disagree with it and these did: nine sat on SYNCHRONOUS functions, so every run printed nine warnings saying the marker did not belong. Noise is where a real warning hides. They are gone and the suite prints none. What makes that safe is a gate rather than a hope. Under strict mode pytest-asyncio SKIPS an unmarked async test instead of failing it, so deleting the surviving declaration would leave the suite green while 45 tests quietly stopped running. test_the_suite_declares_asyncio_once.py holds the declaration, proves its own known-bad against a mutated copy of the file, and refuses a file that starts declaring it again - because one re-added marker is harmless on its own, which is exactly how the other 44 would follow. AND THAT LAST CHECK EARNED ITSELF IMMEDIATELY: it found two the removal script had missed, both in list form (pytestmark = [pytest.mark.asyncio, ...]) where the pattern did not match. A partial job that reports success is the thing a gate is for. A must-not-fire case failed on the first run for the second time in two days, and the gate was right both times: a toy module whose outermost function has no caller HAS a dead surface. The real package always has an outer caller; a fixture does not unless it is given one. Suite 664 green from 654, with no warnings. Tests only, nothing under src, so no version: the gate says in its own words that tests alter nothing anybody installs. | 3 天前 | |
The repository is the MCP bundle, in the form the MCPB spec gives for Python (#1314) manifest.json at the root (MCPB 0.4, server.type uv), .mcpbignore, mcpb/ removed, scripts/pack_bundle.py with the archive check and the derived Smithery variant, a CI job that packs, unpacks and runs the bundle, and a publish job that attaches both archives to the release page. Measured first: a fresh copy of the tracked tree runs with uv run --directory <bundle> python -m aihawk. src/ and pyproject.toml untouched. | 5 天前 | |
The contrast floors are computed, not described (#1345) Tests only. Nothing that ships moves, so there is no version to bump. This is the most repeated defect in the stylesheet and nothing was watching it. 01-tokens.css carries two separate blocks about the same thing: a token declared DECORATION ONLY ended up colouring words - nine rules at 2.4:1 where AA asks 4.5, among them the address that is printed precisely so an injected link can be read, which was then the hardest thing on the page to read - and the same class again one rung up, a graphic measured against the ground instead of against the surface it is actually painted on. Both were found by somebody looking. The file records the ratios it relies on as numbers written into comments, and nothing recomputed them: this project refuses a hand-written count in its documentation and had no opinion about one in its stylesheet, in the file whose whole subject is numbers that have to hold. Three halves. Every ratio the file PUBLISHES is recomputed from the token and has to match, and a figure that appears with nothing saying what it measures fails too - a stale comment is worse than none, because it is evidence that was true once. The floors are asserted for the roles the file itself declares, against the surfaces it itself names: text at 4.5 for the three inks that carry words and for the accent and the two verdicts, a graphic at 3.0 for the decorative one, measured where it is painted rather than against the ground, and the send button's mark against its own fill, which is the one pair the ladder cannot see. And the rule the file has broken twice is made mechanical: no stylesheet sets `color` from the decorative token. Border, background and shadow are what it is for and stay. All eight published figures are correct today. The gate is for the day one of them stops being. Six known-bad inputs, six killed: an ink moved with its comment left behind, a text ink under the floor, the decorative ink under the graphic floor, the send button's mark moved towards its fill, the decorative token colouring a word again, and a new figure appearing unaccounted for. | 3 天前 | |
The half the gate said it could not do, and one declaration of asyncio (#1352) 0.67.0 shipped a gate on dead product surface and declared methods out of scope, with a reason: a method reaches the code as an attribute, and plan.describe and SessionPlan.describe are the same attribute name, so counting names cannot tell the dead one from the live one beside it. That reason was true and it was not a limit, it was a missing step. It is done now. RESOLVE THE OWNER, AND THE AMBIGUITY GOES AWAY. An attribute on a name that the file imported as a MODULE is the module's function and never the method; self.X, Class.X and anything.X could all be the method and count. The second half is deliberately generous - an arbitrary expression counts as a reference to every method of that name - so it errs by letting something live, never by accusing it. 47 methods judged out of the 59 defined: the twelve left out are methods of a class WITH A BASE, which may be satisfying somebody else's contract, and decorated ones, which can be handed somewhere this cannot follow. The real-tree mutation it exists for - SessionPlan.describe put back - is named by file and line. AND THE FIRST VERSION OF IT LET THAT MUTATION SURVIVE. Strings were split into words, so the docstrings in plan.py - which say "describe() reads the KWARGS" - counted as reaching the method. The check was satisfied by the prose beside the code, which is the most repeated defect in this project, met inside the tool built to find it. Whole-string identifiers only now, which keeps the genuine getattr case and drops the sentences: the universe of attribute names went from 4,798 to 500, so nine tenths of what was keeping methods alive was prose. Floors AND a ceiling are asserted, because a collapse makes every method look dead, an explosion makes every one look alive, and a hand-written perimeter goes stale the day somebody changes what is excluded - which it already had, at 59. THE PAGE WAS SCANNED THE SAME WAY AND IS CLEAN, which is worth writing down as a measured negative rather than an assumption: src/aihawk/ui is product surface too and 0.67.0 never looked at it. 109 top-level JS bindings, every one named by another file or by the markup; 49 CSS classes, every one applied, confirmed by a second measurement taken a different way. The ten scripts become one concatenated script, so the question is the same one the Python gate asks. No gate here - the page has its own in test_the_browser_workspace.py - and the numbers are in the module docstring. ASYNCIO WAS DECLARED 46 TIMES FOR ONE FACT. pyproject.toml has asyncio_mode = "auto", which marks every async test already; 45 more copies sat in the files as @pytest.mark.asyncio or a module-level pytestmark. Copies of one fact can disagree with it and these did: nine sat on SYNCHRONOUS functions, so every run printed nine warnings saying the marker did not belong. Noise is where a real warning hides. They are gone and the suite prints none. What makes that safe is a gate rather than a hope. Under strict mode pytest-asyncio SKIPS an unmarked async test instead of failing it, so deleting the surviving declaration would leave the suite green while 45 tests quietly stopped running. test_the_suite_declares_asyncio_once.py holds the declaration, proves its own known-bad against a mutated copy of the file, and refuses a file that starts declaring it again - because one re-added marker is harmless on its own, which is exactly how the other 44 would follow. AND THAT LAST CHECK EARNED ITSELF IMMEDIATELY: it found two the removal script had missed, both in list form (pytestmark = [pytest.mark.asyncio, ...]) where the pattern did not match. A partial job that reports success is the thing a gate is for. A must-not-fire case failed on the first run for the second time in two days, and the gate was right both times: a toy module whose outermost function has no caller HAS a dead surface. The real package always has an outer caller; a fixture does not unless it is given one. Suite 664 green from 654, with no warnings. Tests only, nothing under src, so no version: the gate says in its own words that tests alter nothing anybody installs. | 3 天前 | |
The floor carries the click fix, or the fix reaches nobody who already had a wheel (#1367) `invisible-playwright` is declared with `>=`, so somebody who already has an older wheel installed updates this package and keeps whatever that wheel does. Up to 0.22.0 that means a click is not delivered once: the driver read the hit target again AFTER the event and turned what it found into a retry, so any control that stops being hittable by its own effect got pressed again. Measured on 0.22.0: a button that hides itself took 1068 clicks across 30 calls, and every call then reported failure; a toggle was flipped twice by one call and reported success. That is a modal's close, a cookie banner's accept, a menu item, a submit that becomes a spinner - most of what an agent clicks. The floor moves from 0.16.2 to 0.22.1, and the reason joins the three already written beside it, in the same form: a version here is the first release in which the named behaviour is true. And the reasons stop being a comment nobody reads. `WHAT_THE_TOOLS_NEED` lists each floor with what breaks below it, and two tests hold it from both sides: the declared floor may not be below any version in the list, and every version in the list must be one the floor actually reached - so the list cannot rot into a description of a past the declaration has moved on from. It reads the DECLARATION, not what is installed. A test that asked the interpreter which version is importable would pass on a machine where the wrapper comes from a checkout and say nothing about what `pip install aihawk` gives a user, which is the only thing a floor decides. Three known-bad mutations killed: the floor back to 0.16.2, the floor below the screencast one, and a reason naming a version the floor never reached. Suite: 732 passed, 9 skipped. Lint, `invisible_core.english` and `check_content.py` clean. | 12 小时前 | |
The language gate comes from the core, and this repository exempts nothing * The language gate comes from the core, and this repository exempts nothing invisible-core 30.23.0 carries the check that a public repository is English, as `invisible_core.english`. Until now it was a script copied here from the wrapper, and the copy carried the wrapper's data with it: four of the five paths in its exclusion list - `src/invisible_playwright/_pw/`, `_driver/`, `_juggler/injected.js`, `tests/test_fork.py` - do not exist in this repository and never did. A dead exclusion never makes anything red, which is exactly why nobody found it; the shared gate refuses one. So `scripts/check_english_only.py` is deleted, the `english` CI job installs the core and runs the module, and the tool-description test imports the word list from `invisible_core.english` instead of loading the script by path. This repository declares no `[tool.invisible.english]` at all: measured, the shared gate reads 245 tracked files here and is silent with nothing exempt. The dependency floor moves to `invisible-playwright>=0.16.2`, which is the first wrapper pinning a core that has the module the test imports. * The version is declared in three files, and the suite declares what it imports Three things the CI found on the first run, all of them mine and all of them real. The version lives in `pyproject.toml`, `manifest.json` and `server.json`, and bumping one left two behind: the gate compares them and said so. Bumped. `invisible-core` is now a DECLARED test dependency. It was already arriving through `invisible-playwright`, so nothing was missing at runtime - but since 30.23.0 the language gate is `invisible_core.english` and two test files import it directly, and an import that leans on a transitive dependency works until the day the wrapper stops needing the core. `test_the_suite_declares_everything_it _imports` caught it against the published stack, which is what it is for. And `test_the_gate_says_which_tree_it_judged.py` is rewritten against the module instead of the deleted script. Its own docstring had named this consolidation and said it was not done yet - "the single home for a rule that all three must obey is invisible_core ... that consolidation is a core release and is not done here". It is done, so the test that asserted the REFUSAL is gone: the script could only judge where it lived, so pointing it elsewhere was a mistake to catch; the module takes the tree as an argument, so pointing it elsewhere is an ordinary request. What replaces it is the property the refusal was protecting - a cross-repo run answers about the tree it was given and names it, and pointed at a guilty tree it accuses THAT one rather than reporting its own clean state. The file also pins what the copy cost: four of the five exclusions it carried named paths that only ever existed in the wrapper, and this repository now declares none. Suite 693 passed against the published stack - invisible-core 30.23.0 and invisible-playwright 0.16.2 resolved from the index, in a clean venv. ruff clean, language gate clean on 245 files with nothing exempt. | 2 天前 | |
A dependency you import is one you declare (0.68.0) (#1353) pyproject.toml states this rule twice, in its own words, about three packages: python-dotenv is "declared rather than inherited ... a transitive dependency is one somebody else can drop in a minor release without telling us", and starlette and uvicorn are "declared even though mcp already pulls both in ... the day mcp stops needing them, the failure would otherwise land here". It was not applied to pydantic. src/aihawk/mcp/server.py says `from pydantic import Field` and nothing declared it: it arrives because mcp requires pydantic>=2.11,<3. The project knew the rule, wrote it down twice, and missed the third - which is the shape that asks for a gate on the class rather than a fourth careful sentence. WHY NOTHING COULD HAVE FOUND IT. Every environment that has mcp has pydantic, so the suite is green, the six matrix jobs are green, and a clean-environment check installing from the index is green too. The wheel is broken only in a future that has not happened yet, and when it does the traceback names pydantic while the cause is in somebody else's pyproject. httpx is the same defect in the test extra: the real-server test imports it and it was arriving through mcp. THE GATE IS ON THE CLASS. Every third-party module the package imports must be declared, and the same question is asked of the suite against the test extra. It was written before the fix and went red on the real state, naming pydantic and the file that imports it, which is the only way to know a gate can fail. AND AN IMPORT THAT DECLARES ITSELF OPTIONAL IS EXEMPT, which the same scan taught by being wrong. Run over the two sibling packages, it raised exactly one thing in shipped code: `from packaging.markers import Marker` in invisible_core, which is correct - inside a function, in a try that catches ImportError and answers "cannot tell", with a docstring saying packaging is not one of that package's runtime dependencies. The scan was wrong, not the line, and this gate had the same blind spot waiting for the first optional import anybody writes here. The exemption is the SHAPE of the code, not a name on a list, and both sides are held: guarded is left alone, the same module imported at module level is still caught. The first version of the gate also carried a hand-written list of local module names and was already wrong on its first run: it missed test_web_service, which another test imports by bare name under this suite's convention. The list is resolved from the tree now. A list of what a directory already says is a second declaration, which is the thing this release is about. What it does not see is written into it: an import through importlib with a computed name, and whether a floor is high enough, which only a resolver can answer. pytest-asyncio is correctly declared and correctly never imported, because a plugin is loaded by pytest rather than by code. THE 503 BRANCH OF /live/frame IS NOW CHOSEN BY THE SERVER. That route picks between 204 and 503 by asking whether the sentence a tool raised IS the not-open one, and only the yes was proven end to end: a comparison that answered "not open" to EVERYTHING passed the whole suite, which turns every real breakage into a silently idle pane. Reaching the other side without starting a browser costs nothing once you notice that `browser` is declared Literal["main", "support"], so a third value is refused by the tool's own schema and comes back as an error result that is not that sentence. Known-bad applied to the real route: the new test fails and the 204 one stays green, which is what should happen. WHAT WAS SCANNED AND IS CLEAN, recorded as measured negatives rather than assumptions. The six scripts in scripts/ are all invoked by workflows and carry no unreferenced top-level definition and no uncalled method. All eight runtime dependencies are genuinely imported by the package. AND ONE THING THIS AUDIT REPORTED THAT WAS FALSE. It listed mixed line endings as remaining debt. Measured on the index: 255 of 255 text files are LF only, zero CRLF, zero mixed, zero lines a renormalisation would rewrite. The working tree is CRLF because core.autocrlf is true on that machine, which is a property of a checkout and not of this repository. A claim carried over from a sibling repo without being measured, which is the error this audit exists to find, made by the audit. Suite 671 green from 664. | 3 天前 | |
The two doors are gone, and every route is a function with a name (#1329) * The two doors are gone, and every route is a function with a name `web.py` was forty lines re-exporting four modules, kept so that callers would not have to change; `brain.py` was twenty-one lines of code forwarding to `agent.py`, beside a docstring about an implementation removed in 0.4.0. Both are deleted. Every importer names the module the thing lives in: `aihawk.ui` for the page, `aihawk.routes` for the app, `aihawk.sessions`, `aihawk.chat`, and `aihawk.agent` for the brain, which is the loop's one consumer and now sits at the end of the loop's file. The routes were closures inside `build_app`, 297 lines deep, so a reader looking for the handler that serves the frame had to find it inside the function that built the app. They are module-level functions now and find the registry of conversations on `request.app.state.sessions`; `build_app` does the one thing its name says. The route tests build their fake requests through one helper that carries the app. The door had a measured cost, which is why this is not tidying. The gate that checks every event kind the server can send is drawn by the page read `web.py` for those kinds - and had been reading forty lines of re-exports since 2026-09-10, green the whole time. Read from the three modules that emit, it found `note`, the interface's own "Stopped." after the button, drawn by the page's total default as if the model had said it. The page names it now. Started from this tree after the change: the page, the session listing, the browsers and the frame answer as before, and an unknown session is 410. Three mutations, three killed. * The clean-venv import in CI names the modules that exist | 4 天前 | |
The two doors are gone, and every route is a function with a name (#1329) * The two doors are gone, and every route is a function with a name `web.py` was forty lines re-exporting four modules, kept so that callers would not have to change; `brain.py` was twenty-one lines of code forwarding to `agent.py`, beside a docstring about an implementation removed in 0.4.0. Both are deleted. Every importer names the module the thing lives in: `aihawk.ui` for the page, `aihawk.routes` for the app, `aihawk.sessions`, `aihawk.chat`, and `aihawk.agent` for the brain, which is the loop's one consumer and now sits at the end of the loop's file. The routes were closures inside `build_app`, 297 lines deep, so a reader looking for the handler that serves the frame had to find it inside the function that built the app. They are module-level functions now and find the registry of conversations on `request.app.state.sessions`; `build_app` does the one thing its name says. The route tests build their fake requests through one helper that carries the app. The door had a measured cost, which is why this is not tidying. The gate that checks every event kind the server can send is drawn by the page read `web.py` for those kinds - and had been reading forty lines of re-exports since 2026-09-10, green the whole time. Read from the three modules that emit, it found `note`, the interface's own "Stopped." after the button, drawn by the page's total default as if the model had said it. The page names it now. Started from this tree after the change: the page, the session listing, the browsers and the frame answer as before, and an unknown session is 410. Three mutations, three killed. * The clean-venv import in CI names the modules that exist | 4 天前 | |
The two doors are gone, and every route is a function with a name (#1329) * The two doors are gone, and every route is a function with a name `web.py` was forty lines re-exporting four modules, kept so that callers would not have to change; `brain.py` was twenty-one lines of code forwarding to `agent.py`, beside a docstring about an implementation removed in 0.4.0. Both are deleted. Every importer names the module the thing lives in: `aihawk.ui` for the page, `aihawk.routes` for the app, `aihawk.sessions`, `aihawk.chat`, and `aihawk.agent` for the brain, which is the loop's one consumer and now sits at the end of the loop's file. The routes were closures inside `build_app`, 297 lines deep, so a reader looking for the handler that serves the frame had to find it inside the function that built the app. They are module-level functions now and find the registry of conversations on `request.app.state.sessions`; `build_app` does the one thing its name says. The route tests build their fake requests through one helper that carries the app. The door had a measured cost, which is why this is not tidying. The gate that checks every event kind the server can send is drawn by the page read `web.py` for those kinds - and had been reading forty lines of re-exports since 2026-09-10, green the whole time. Read from the three modules that emit, it found `note`, the interface's own "Stopped." after the button, drawn by the page's total default as if the model had said it. The page names it now. Started from this tree after the change: the page, the session listing, the browsers and the frame answer as before, and an unknown session is 410. Three mutations, three killed. * The clean-venv import in CI names the modules that exist | 4 天前 | |
Small truths in the shell (0.62.0) (#1346) Five, each small, each the same family as the rest of this week: a thing that said what was not true, or work nobody could see. THE SEPARATOR SAYS WHEN IT CANNOT MOVE. Its floor is the conversation's narrowest and its ceiling is what is left after the picture's, so under a window about 957px wide the two meet: the ceiling collapses onto the floor. Below that it was announced to a screen reader with a value, a floor and a ceiling all equal - a range of zero presented as a range - and to a hand as a thing you can drag that does nothing. It carries aria-disabled there now, and not `disabled`, for the reason the Clear button already carries: a dead control cannot say why. The three bounds are written in ONE place while I was in there; they were in two, which is two chances to announce a range that is not the one being clamped to. AND THE MARKUP STOPS CARRYING A VALUE FOR IT. The comment beside that attribute argues, correctly, that the FLOOR must not be a literal because it would be a second copy of a token. The same argument covers the value, and it was applied to two of the three. THE SCREEN YOU PINNED GETS THE ROOM. Clicking a screen marked it and left it exactly the size of the other, so with two browsers open you always watched at half width - and reading a form the agent is filling in is most of what watching is. The other screen stays on the stage rather than going away, because the agent may move to it at any moment and losing sight of that is worse than a narrow picture. Only when the PERSON has pinned one: the layout never moves on its own, which is the line this page draws everywhere between the agent's hand and the reader's eye. TWO CLOCKS FOR ONE FACT, AND NOW ONE. The address bar had a pump of its own at two seconds, reading a fleet that a different pump refreshed at three: it could only ever redraw the same answer between two arrivals, and the one moment it has to be quick - a screen being clicked - already called it directly. Whoever changes the fleet tells it. Same shape as the /live/address route, removed for the same reason. AND THE COLUMN IS NOT REBUILT WHERE NOBODY CAN SEE IT. The end of every turn asked the server for the list and rebuilt the DOM inside a panel that is hidden almost always, on a page that stops every other pump the moment the tab is hidden for exactly this reason. The boot line already carried the guard, so the rule was known and applied in one of the two places. Suite 616 green. Nine known-bad inputs, nine killed. | 3 天前 | |
The handshake carried the SDK's version, five tools promised they only read, and nothing installed the plugin (0.46.0) Found by comparing the server with the MCP documentation instead of with our own beliefs. serverInfo announced 1.28.0, the mcp SDK's version, for every build: FastMCP takes no `version=` and the low-level server falls back to the library's own. Set from the package; the bundle on CI now reports `stealth 0.46.0`. browser_read_text, browser_snapshot, browser_read_html, browser_take_screenshot and browser_evaluate declared readOnlyHint true and go through `ready()`, which starts a real Firefox. They now declare read-only no and destructive no, the honest third group the hints already have: additive. Behaviour is unchanged, because the server's own instructions promise the lazy start. The gate that should have caught that read an absent hint and a hint set to false as the same fact. It now requires both to be stated, and a second check parses the source with ast and refuses any tool that calls `ready()` while promising it only reads. The plugin is now installed by the real client in a throwaway CLAUDE_CONFIG_DIR and asked what it delivers. Reproducing the 1.0.0 shape turns it red with `MCP servers (0)`. The bundle job stops running `--help` and completes a real handshake with the command its own manifest declares. The stdio test asks about all sixteen tools instead of two. Six facts written in three files each and checked in none - licence, author, keywords, repository url, display title - are under a gate, with one mutation per fact. And half of aihawk/mcp/store.py was the interface's conversation store, with no caller inside that package. It moves to aihawk/chats.py, with the three genuinely shared primitives in aihawk/storage.py, including the atomic write that was copied into both files. The HTTP transport's default port was 8765, the number the interface binds. | 4 天前 | |
The half the gate said it could not do, and one declaration of asyncio (#1352) 0.67.0 shipped a gate on dead product surface and declared methods out of scope, with a reason: a method reaches the code as an attribute, and plan.describe and SessionPlan.describe are the same attribute name, so counting names cannot tell the dead one from the live one beside it. That reason was true and it was not a limit, it was a missing step. It is done now. RESOLVE THE OWNER, AND THE AMBIGUITY GOES AWAY. An attribute on a name that the file imported as a MODULE is the module's function and never the method; self.X, Class.X and anything.X could all be the method and count. The second half is deliberately generous - an arbitrary expression counts as a reference to every method of that name - so it errs by letting something live, never by accusing it. 47 methods judged out of the 59 defined: the twelve left out are methods of a class WITH A BASE, which may be satisfying somebody else's contract, and decorated ones, which can be handed somewhere this cannot follow. The real-tree mutation it exists for - SessionPlan.describe put back - is named by file and line. AND THE FIRST VERSION OF IT LET THAT MUTATION SURVIVE. Strings were split into words, so the docstrings in plan.py - which say "describe() reads the KWARGS" - counted as reaching the method. The check was satisfied by the prose beside the code, which is the most repeated defect in this project, met inside the tool built to find it. Whole-string identifiers only now, which keeps the genuine getattr case and drops the sentences: the universe of attribute names went from 4,798 to 500, so nine tenths of what was keeping methods alive was prose. Floors AND a ceiling are asserted, because a collapse makes every method look dead, an explosion makes every one look alive, and a hand-written perimeter goes stale the day somebody changes what is excluded - which it already had, at 59. THE PAGE WAS SCANNED THE SAME WAY AND IS CLEAN, which is worth writing down as a measured negative rather than an assumption: src/aihawk/ui is product surface too and 0.67.0 never looked at it. 109 top-level JS bindings, every one named by another file or by the markup; 49 CSS classes, every one applied, confirmed by a second measurement taken a different way. The ten scripts become one concatenated script, so the question is the same one the Python gate asks. No gate here - the page has its own in test_the_browser_workspace.py - and the numbers are in the module docstring. ASYNCIO WAS DECLARED 46 TIMES FOR ONE FACT. pyproject.toml has asyncio_mode = "auto", which marks every async test already; 45 more copies sat in the files as @pytest.mark.asyncio or a module-level pytestmark. Copies of one fact can disagree with it and these did: nine sat on SYNCHRONOUS functions, so every run printed nine warnings saying the marker did not belong. Noise is where a real warning hides. They are gone and the suite prints none. What makes that safe is a gate rather than a hope. Under strict mode pytest-asyncio SKIPS an unmarked async test instead of failing it, so deleting the surviving declaration would leave the suite green while 45 tests quietly stopped running. test_the_suite_declares_asyncio_once.py holds the declaration, proves its own known-bad against a mutated copy of the file, and refuses a file that starts declaring it again - because one re-added marker is harmless on its own, which is exactly how the other 44 would follow. AND THAT LAST CHECK EARNED ITSELF IMMEDIATELY: it found two the removal script had missed, both in list form (pytestmark = [pytest.mark.asyncio, ...]) where the pattern did not match. A partial job that reports success is the thing a gate is for. A must-not-fire case failed on the first run for the second time in two days, and the gate was right both times: a toy module whose outermost function has no caller HAS a dead surface. The real package always has an outer caller; a fixture does not unless it is given one. Suite 664 green from 654, with no warnings. Tests only, nothing under src, so no version: the gate says in its own words that tests alter nothing anybody installs. | 3 天前 | |
One declaration of what a caller can act on, and one of what `browser` means (0.64.0) (#1348) Three things the prompt side said more than once, and the first of them was not only repetition: the two copies disagreed. WHAT IS INTERACTIVE WAS DECLARED TWICE, IN TWO LANGUAGES. clean.py held nineteen roles for the sieve; the snapshot's selector held seven, typed by hand inside a JavaScript string in another module, where nothing could compare them. Measured on a page of ARIA controls, driven through the real server: a role=combobox and a role=slider with no tabindex came back from browser_read_html and were absent from browser_snapshot, which is the tool the instructions name as the way to find something to click. Seven controls in all, invisible to the rung the ladder starts on, while the sieve saw every one. The snapshot now asks the page for the selector clean.py builds, so there is one list. The one difference the two need is NAMED instead of being left to a second list to imply: roles whose members come in hundreds - option, gridcell - are kept by the sieve and left out of the inventory, which is the measurement the snapshot's own docstring already carried about a country select with two hundred options. Before and after on the same engine: 8 elements to 15, seven gained, none lost, and a thirty-item listbox still contributes one row. The selector is joined by concatenation and not substituted into the script. That block holds 12 literal percent signs, and a placeholder searched for inside code is found inside the caller's code too - which is the defect fixed one layer down yesterday. THE SAME SENTENCE ABOUT `browser` CLOSED THIRTEEN DESCRIPTIONS, word for word. It is on the parameter now, said once. It also spent the wrong budget: a description is cut at 1024 characters before the model reads it and browser_open sat at 1021, three characters from losing the sentence that says who closes support, which is the defect its own gate exists for. It is 1004 now, and the room went to two rules that had been cut for space and left in a comment no model would ever read. AND THE FIRST VERSION OF THAT MOVE WAS A LOSS, which only a measurement said. A schema travels with its tool every turn exactly as a description does, so thirteen copies in the schemas is the same duplication moved, and the four-sentence version made it dearer: descriptions -256 tokens, schemas +900, complete definitions +617, sixteen percent MORE per turn for a change whose point was to spend less. The parameter carries one line now and the whole definition is -13 tokens, which is to say the same cost. The saving was never the point; not saying it thirteen times is. THE RULE ABOUT OPENING FIRST WAS IN BOTH TEXTS THAT MAKE ONE MESSAGE. The loop's prompt opened with it and the server's instructions say it in their first paragraph, glued on a few lines below: the model read it twice. It belongs to the server, because it is a fact about the tools and a standalone client that never sees the loop's prompt still has to be told it. A gate holds that the home is not empty, which is the half that matters when a sentence moves. A draft also made an empty instructions string append a warning for the model. It was dropped: the link launches this very server, so arriving with nothing means the handshake failed, and a sentence in the prompt helps nobody in that state. Four tests said so before I did. AND THE FIGURE MOVED IN FOURTEEN PUBLISHED PAGES. 9,097 characters became 8,105, and the token counts with it, across 21 occurrences that a content gate checks in only one of its four numbers. Of the fourteen pages, TWO published the token figure with no character figure beside it, so on those two the neighbour the gate relies on protected nothing. Both were given the figure they were missing, and the gate now holds the arrangement rather than hoping for it, which needs no tokenizer: it checks that the number it CAN verify is present. Suite 632 green. Eleven known-bad inputs, eleven killed, plus the gate's own selftest at 18 mutations and 13 clean cases. One of the eleven survived the first run and the gate was genuinely blind: it asked whether anything in the excluded set was inventoried, which a mutation satisfies by moving the role into the other set. The two names are written out now. Another was killed by the parser rather than by the gate, which is not a kill; rewritten as valid Python, the gate kills it. | 3 天前 | |
The half the gate said it could not do, and one declaration of asyncio (#1352) 0.67.0 shipped a gate on dead product surface and declared methods out of scope, with a reason: a method reaches the code as an attribute, and plan.describe and SessionPlan.describe are the same attribute name, so counting names cannot tell the dead one from the live one beside it. That reason was true and it was not a limit, it was a missing step. It is done now. RESOLVE THE OWNER, AND THE AMBIGUITY GOES AWAY. An attribute on a name that the file imported as a MODULE is the module's function and never the method; self.X, Class.X and anything.X could all be the method and count. The second half is deliberately generous - an arbitrary expression counts as a reference to every method of that name - so it errs by letting something live, never by accusing it. 47 methods judged out of the 59 defined: the twelve left out are methods of a class WITH A BASE, which may be satisfying somebody else's contract, and decorated ones, which can be handed somewhere this cannot follow. The real-tree mutation it exists for - SessionPlan.describe put back - is named by file and line. AND THE FIRST VERSION OF IT LET THAT MUTATION SURVIVE. Strings were split into words, so the docstrings in plan.py - which say "describe() reads the KWARGS" - counted as reaching the method. The check was satisfied by the prose beside the code, which is the most repeated defect in this project, met inside the tool built to find it. Whole-string identifiers only now, which keeps the genuine getattr case and drops the sentences: the universe of attribute names went from 4,798 to 500, so nine tenths of what was keeping methods alive was prose. Floors AND a ceiling are asserted, because a collapse makes every method look dead, an explosion makes every one look alive, and a hand-written perimeter goes stale the day somebody changes what is excluded - which it already had, at 59. THE PAGE WAS SCANNED THE SAME WAY AND IS CLEAN, which is worth writing down as a measured negative rather than an assumption: src/aihawk/ui is product surface too and 0.67.0 never looked at it. 109 top-level JS bindings, every one named by another file or by the markup; 49 CSS classes, every one applied, confirmed by a second measurement taken a different way. The ten scripts become one concatenated script, so the question is the same one the Python gate asks. No gate here - the page has its own in test_the_browser_workspace.py - and the numbers are in the module docstring. ASYNCIO WAS DECLARED 46 TIMES FOR ONE FACT. pyproject.toml has asyncio_mode = "auto", which marks every async test already; 45 more copies sat in the files as @pytest.mark.asyncio or a module-level pytestmark. Copies of one fact can disagree with it and these did: nine sat on SYNCHRONOUS functions, so every run printed nine warnings saying the marker did not belong. Noise is where a real warning hides. They are gone and the suite prints none. What makes that safe is a gate rather than a hope. Under strict mode pytest-asyncio SKIPS an unmarked async test instead of failing it, so deleting the surviving declaration would leave the suite green while 45 tests quietly stopped running. test_the_suite_declares_asyncio_once.py holds the declaration, proves its own known-bad against a mutated copy of the file, and refuses a file that starts declaring it again - because one re-added marker is harmless on its own, which is exactly how the other 44 would follow. AND THAT LAST CHECK EARNED ITSELF IMMEDIATELY: it found two the removal script had missed, both in list form (pytestmark = [pytest.mark.asyncio, ...]) where the pattern did not match. A partial job that reports success is the thing a gate is for. A must-not-fire case failed on the first run for the second time in two days, and the gate was right both times: a toy module whose outermost function has no caller HAS a dead surface. The real package always has an outer caller; a fixture does not unless it is given one. Suite 664 green from 654, with no warnings. Tests only, nothing under src, so no version: the gate says in its own words that tests alter nothing anybody installs. | 3 天前 | |
Small truths in the shell (0.62.0) (#1346) Five, each small, each the same family as the rest of this week: a thing that said what was not true, or work nobody could see. THE SEPARATOR SAYS WHEN IT CANNOT MOVE. Its floor is the conversation's narrowest and its ceiling is what is left after the picture's, so under a window about 957px wide the two meet: the ceiling collapses onto the floor. Below that it was announced to a screen reader with a value, a floor and a ceiling all equal - a range of zero presented as a range - and to a hand as a thing you can drag that does nothing. It carries aria-disabled there now, and not `disabled`, for the reason the Clear button already carries: a dead control cannot say why. The three bounds are written in ONE place while I was in there; they were in two, which is two chances to announce a range that is not the one being clamped to. AND THE MARKUP STOPS CARRYING A VALUE FOR IT. The comment beside that attribute argues, correctly, that the FLOOR must not be a literal because it would be a second copy of a token. The same argument covers the value, and it was applied to two of the three. THE SCREEN YOU PINNED GETS THE ROOM. Clicking a screen marked it and left it exactly the size of the other, so with two browsers open you always watched at half width - and reading a form the agent is filling in is most of what watching is. The other screen stays on the stage rather than going away, because the agent may move to it at any moment and losing sight of that is worse than a narrow picture. Only when the PERSON has pinned one: the layout never moves on its own, which is the line this page draws everywhere between the agent's hand and the reader's eye. TWO CLOCKS FOR ONE FACT, AND NOW ONE. The address bar had a pump of its own at two seconds, reading a fleet that a different pump refreshed at three: it could only ever redraw the same answer between two arrivals, and the one moment it has to be quick - a screen being clicked - already called it directly. Whoever changes the fleet tells it. Same shape as the /live/address route, removed for the same reason. AND THE COLUMN IS NOT REBUILT WHERE NOBODY CAN SEE IT. The end of every turn asked the server for the list and rebuilt the DOM inside a panel that is hidden almost always, on a page that stops every other pump the moment the tab is hidden for exactly this reason. The boot line already carried the guard, so the rule was known and applied in one of the two places. Suite 616 green. Nine known-bad inputs, nine killed. | 3 天前 | |
Small truths in the shell (0.62.0) (#1346) Five, each small, each the same family as the rest of this week: a thing that said what was not true, or work nobody could see. THE SEPARATOR SAYS WHEN IT CANNOT MOVE. Its floor is the conversation's narrowest and its ceiling is what is left after the picture's, so under a window about 957px wide the two meet: the ceiling collapses onto the floor. Below that it was announced to a screen reader with a value, a floor and a ceiling all equal - a range of zero presented as a range - and to a hand as a thing you can drag that does nothing. It carries aria-disabled there now, and not `disabled`, for the reason the Clear button already carries: a dead control cannot say why. The three bounds are written in ONE place while I was in there; they were in two, which is two chances to announce a range that is not the one being clamped to. AND THE MARKUP STOPS CARRYING A VALUE FOR IT. The comment beside that attribute argues, correctly, that the FLOOR must not be a literal because it would be a second copy of a token. The same argument covers the value, and it was applied to two of the three. THE SCREEN YOU PINNED GETS THE ROOM. Clicking a screen marked it and left it exactly the size of the other, so with two browsers open you always watched at half width - and reading a form the agent is filling in is most of what watching is. The other screen stays on the stage rather than going away, because the agent may move to it at any moment and losing sight of that is worse than a narrow picture. Only when the PERSON has pinned one: the layout never moves on its own, which is the line this page draws everywhere between the agent's hand and the reader's eye. TWO CLOCKS FOR ONE FACT, AND NOW ONE. The address bar had a pump of its own at two seconds, reading a fleet that a different pump refreshed at three: it could only ever redraw the same answer between two arrivals, and the one moment it has to be quick - a screen being clicked - already called it directly. Whoever changes the fleet tells it. Same shape as the /live/address route, removed for the same reason. AND THE COLUMN IS NOT REBUILT WHERE NOBODY CAN SEE IT. The end of every turn asked the server for the list and rebuilt the DOM inside a panel that is hidden almost always, on a page that stops every other pump the moment the tab is hidden for exactly this reason. The boot line already carried the guard, so the rule was known and applied in one of the two places. Suite 616 green. Nine known-bad inputs, nine killed. | 3 天前 | |
The half the gate said it could not do, and one declaration of asyncio (#1352) 0.67.0 shipped a gate on dead product surface and declared methods out of scope, with a reason: a method reaches the code as an attribute, and plan.describe and SessionPlan.describe are the same attribute name, so counting names cannot tell the dead one from the live one beside it. That reason was true and it was not a limit, it was a missing step. It is done now. RESOLVE THE OWNER, AND THE AMBIGUITY GOES AWAY. An attribute on a name that the file imported as a MODULE is the module's function and never the method; self.X, Class.X and anything.X could all be the method and count. The second half is deliberately generous - an arbitrary expression counts as a reference to every method of that name - so it errs by letting something live, never by accusing it. 47 methods judged out of the 59 defined: the twelve left out are methods of a class WITH A BASE, which may be satisfying somebody else's contract, and decorated ones, which can be handed somewhere this cannot follow. The real-tree mutation it exists for - SessionPlan.describe put back - is named by file and line. AND THE FIRST VERSION OF IT LET THAT MUTATION SURVIVE. Strings were split into words, so the docstrings in plan.py - which say "describe() reads the KWARGS" - counted as reaching the method. The check was satisfied by the prose beside the code, which is the most repeated defect in this project, met inside the tool built to find it. Whole-string identifiers only now, which keeps the genuine getattr case and drops the sentences: the universe of attribute names went from 4,798 to 500, so nine tenths of what was keeping methods alive was prose. Floors AND a ceiling are asserted, because a collapse makes every method look dead, an explosion makes every one look alive, and a hand-written perimeter goes stale the day somebody changes what is excluded - which it already had, at 59. THE PAGE WAS SCANNED THE SAME WAY AND IS CLEAN, which is worth writing down as a measured negative rather than an assumption: src/aihawk/ui is product surface too and 0.67.0 never looked at it. 109 top-level JS bindings, every one named by another file or by the markup; 49 CSS classes, every one applied, confirmed by a second measurement taken a different way. The ten scripts become one concatenated script, so the question is the same one the Python gate asks. No gate here - the page has its own in test_the_browser_workspace.py - and the numbers are in the module docstring. ASYNCIO WAS DECLARED 46 TIMES FOR ONE FACT. pyproject.toml has asyncio_mode = "auto", which marks every async test already; 45 more copies sat in the files as @pytest.mark.asyncio or a module-level pytestmark. Copies of one fact can disagree with it and these did: nine sat on SYNCHRONOUS functions, so every run printed nine warnings saying the marker did not belong. Noise is where a real warning hides. They are gone and the suite prints none. What makes that safe is a gate rather than a hope. Under strict mode pytest-asyncio SKIPS an unmarked async test instead of failing it, so deleting the surviving declaration would leave the suite green while 45 tests quietly stopped running. test_the_suite_declares_asyncio_once.py holds the declaration, proves its own known-bad against a mutated copy of the file, and refuses a file that starts declaring it again - because one re-added marker is harmless on its own, which is exactly how the other 44 would follow. AND THAT LAST CHECK EARNED ITSELF IMMEDIATELY: it found two the removal script had missed, both in list form (pytestmark = [pytest.mark.asyncio, ...]) where the pattern did not match. A partial job that reports success is the thing a gate is for. A must-not-fire case failed on the first run for the second time in two days, and the gate was right both times: a toy module whose outermost function has no caller HAS a dead surface. The real package always has an outer caller; a fixture does not unless it is given one. Suite 664 green from 654, with no warnings. Tests only, nothing under src, so no version: the gate says in its own words that tests alter nothing anybody installs. | 3 天前 | |
The product surface is what the product uses (0.67.0) (#1351) An audit for code that exists only because the tests grew up around it. Seven surfaces came out of src/aihawk and every one had the same shape: zero callers in the product, and enough callers in the suite to look load-bearing. AGENT.RUN_TASK WAS A SECOND WAY TO RUN THE AGENT LOOP. Its own docstring said so: "not called by the product - kept because the suite drives the loop through it, about twenty-five tests". Worse than unused, it took an object with list_tools and call_tool, which is the shape from before Link existed, while the product passes link.call and link.tools to Conversation.run. A reader of agent.py met two entry points with two different ideas of how a tool is reached, and one of them was imaginary. The convenience was real, so it moved to tests/_loop.py rather than being deleted. The e2e test that drove a real server through it now goes through Conversation.run, which is the path the product takes. SESSIONS.AROUND OFFERED ITSELF TO "ANYTHING EMBEDDING THIS", a user that does not exist: this is an application, and the only importer of sessions.py is cli.py. Eleven callers, all in two test modules. The argument it was written for is good and survives in tests/_sessions.py, because it is a rule about the suite: a test driving ONE conversation should still go through build_app and the routes, so the single case is exercised by the same code the many case uses. SESSIONS._OPEN_LINK WAS A SEAM NOTHING DECLARED. It was assigned in __init__ and reassigned from outside by two test modules - a convention a reader of the signature could not see and a reader of the assignment could not tell from an accident. It is a keyword argument now, so how a connection is made is part of the interface and the default is the product's one way. SESSIONPLAN.DESCRIBE WAS A SECOND MAPPING OF A PLAN ONTO THE SENTENCE a caller is told, beside the one in work.open, and the two were free to disagree the day a fifth field joined the sentence. It also described something that does not happen: between planning and launching, work.open can rewrite the settings and the exit note - that is how the helper browser comes to share main's exit - so the sentence is read from what was LAUNCHED and never from what was planned. A method on the plan quietly offered the other thing. Its six callers were all in one test file and now go through the function the product goes through. CLEAN.RELEVANCE AND CLEAN.CLEAN_STATS ARRIVED DEAD and stayed that way. Fifty seven lines scoring how much a model needs an element, and seven figures about what the cleaning saved, both imported with the server on 2026-09-06 and named by no commit since. clean_stats was in __all__ and had a test; relevance had neither, and the module docstring described it as part of how the file works - "the relevance score below orders and annotates" - so the file's own account of itself named a pass that does not run. What those two were really carrying is kept: that nothing here may drop an element, and that a reduction figure says nothing about whether the result is still usable. Both are the invariant at the top of the module, which is where a rule can act. CLEAN.BLOCK_TAGS was a tuple nothing read. TWO PLACES ALSO STOPPED HOLDING THE SAME FACT TWICE. storage.file_for owns the rule that turns a session id into a path, so chats.py and mcp/store.py each name only their own directory and neither spells the join. And a test migrated off the store.home re-export onto aihawk.storage.home, which is where home lives. THE GATE IS ON THE CLASS, NOT ON THE SEVEN NAMES. A list would stop those seven; the defect is that a surface can be added, be used only by its own tests, and look exactly like one the product depends on. So it asks the general question: every top-level function, class and constant in src/aihawk must be named somewhere in src/aihawk. Being registered excuses it, and that is structural - a decorator spelled .tool, .command or .group hands the object to FastMCP or to click, where the call happens over a wire or from a shell. Wrapping does not: dataclass and contextmanager hand the object straight back, so it is still called by name. Exporting it in __all__ does not count either, because __all__ is the claim under audit rather than evidence for it, and that is precisely where clean_stats was hiding. WHAT THE GATE DOES NOT SEE IS WRITTEN INTO IT. 178 definitions across 25 modules, with floors asserted so a scan that goes blind fails instead of printing the same clean line. Methods are out of scope: a method reaches the code as an attribute, and plan.describe and SessionPlan.describe are the same attribute name, so no scan can tell the dead one from the live one beside it. That one was found by reading, and this gate would not have found it. Suite 654 green, from a 642 baseline: one test went with clean_stats and thirteen arrived with the gate. Six known-bad inputs and five cases that must not fire, plus a mutation against the real tree - relevance put back into clean.py, which the gate names by file and line. The first three must-not-fire cases failed on the first run and the gate was right: a toy module whose own outermost function has no caller has a dead surface, so each fixture ends at a registered entry point, exactly as the package does. | 3 天前 | |
The transcript follows the agent, and the scroll waits for the layout (#1361) It reads as "the transcript stops scrolling after a while". It never scrolled. For the first 24 rows the transcript is shorter than the window, so there is nothing to scroll and every distance to the bottom is zero by construction. From there the view falls behind by exactly the height that arrives, 623 px per 20 rows, for as long as the agent keeps working. Two separate things were wrong, and the second one only became visible once the first was fixed. **The page had two answers to "is the reader following".** `settleOnce()` scrolled once, 150 ms after the first event, and set a latch so nothing would scroll again. At 150 ms the transcript is empty, so that one scroll had nothing to do. Meanwhile an IntersectionObserver knew the answer continuously and was used only to show a button. The latch drove the scrolling. The rest was left to `#anchor` and `overflow-anchor:auto`, which can only hold a bottom something else has already reached, because the browser will not choose an anchor that is off screen. The CSS carried a comment claiming "bottom-pinning with no scroll handler"; that was false and is corrected here. The alternative reading was that anchoring is suppressed at scroll offset zero, so it was measured: with the scroller one pixel from the top, 374 px of rows arrived and it moved by zero. One pixel is not zero, so the offset is not the reason. **A scroll scheduled while appending runs before the layout of what was appended.** With the latch gone and `put` scrolling on a frame, a live run followed perfectly and a reopened conversation still opened at the top, three times out of three, 2084 px from the bottom. Instrumented from inside that callback: the DOM already held all 65 rows and the scroller still reported a height of 808, its own window. It scrolled to 808, which clamps to zero. No choice of target fixes that, because `scrollIntoView` on the sentinel reads the same layout, and checking whether the scroll arrived does not either, because the check reads that layout too and concludes it did. So there is one fact and one trigger. The fact is the observer, which knows continuously whether the reader is following. The trigger is a ResizeObserver on the transcript, delivered after layout, which is the only moment the bottom is knowable and which also covers every other way the transcript can grow: a picture that loads, a font that swaps, a row expanded, the window resized. `put` no longer scrolls. The latch, the timer and the two variables behind them are gone rather than repaired, and a tombstone says why. Five tests, which execute the real page code in node under a shim rather than scanning it. The shim is half the gate, because two earlier versions passed while lying. It counts the `scrollTop` setter instead of its own ticks, since a page that scrolled sixty times inside one tick reported one and passed; what is promised is one scroll per arrival, so scrolls are what is counted. It clamps like a real scroller, or `toBottom` would leave the offset past the end and every distance would read negative. And it delivers the two observations in the order a browser delivers them, the resize after layout and the intersection after that, so a page that cannot work in a browser cannot pass here. The other half is the regression this file exists to prevent. The old design bought "never yank a reader who scrolled up" by never scrolling at all, and a fix that follows the bottom and drags the reader back every time a row lands would be worse than the defect it replaces. So the "left alone" half is asserted twice in a row: a latch passes it once and never again, which is exactly the shape of what is being removed. Six known-bad mutations, all killed: `grew` scrolling never, `grew` ignoring the reader, `grew` with the condition inverted, the scroll moved back into `put`, `toBottom` going to the top, and nothing counting what arrived. Verified in a real browser, not only under the shim: a 65-row conversation reopens at the bottom three times out of three, the view follows for a whole agent run, and a reader who scrolls up is left alone twice in a row. The scan terms took three tries to get narrow enough, which is this repository's most repeated gate defect taken from the side where it accuses rather than passes. `pinned` on its own accuses `stage.pinned`, the pane the person chose to watch. `requestAnimationFrame` over the whole page accuses `fitOrOpen`, which uses one to batch layout reads and has nothing to do with scrolling. And `settleOnce` was satisfied by its own tombstone. So comments come out before the scan, and the frame scheduling is asserted absent from the mechanism, and only there. One mutation survived and was not a hole: reading the observer after the append instead of before changes nothing, because an IntersectionObserver is asynchronous and the two orderings are equivalent. The fix was to correct the comment that claimed too much, not to weaken the gate. The third commit is a rename. The gate was written with Italian identifiers and `invisible_core.english` refuses them; the six mutations were re-run afterwards and all six are still killed. Verified locally: 714 passed, ruff clean, `invisible_core.english` clean, `check_content.py` clean, and the version gate green against the real index. | 18 小时前 | |
The transcript is what was said, on the way out as well as in (0.58.0) (#1341) Every saved conversation carried a copy of this build's system prompt at the head of its messages. Measured on a real file: 1205 characters that nothing would ever read, because remember() has dropped the saved system message on the way back in since 2026-09-08 - restoring it wholesale put an OLD prompt back, and every change to the instructions reached new conversations only. So the reading half of the rule existed and the writing half did not, and that is exactly why it survived: nothing behaves differently, the restore already ignores it, and no test had an opinion. What it is, is a copy of CODE inside a file of DATA - and the remedy for a stale duplicate is to stop writing it, rather than to keep remembering to ignore it. said_only() is now the whole rule, in one place, with both callers going through it: remember() filtering what it reads, save() filtering what it writes. Written as one function rather than one filter at each end, because two ends that agree today are two ends that can stop agreeing. Nothing has to be migrated. A file written by an older build still carries one, and is read exactly as it always was - dropped, and replaced by what this build asks for - and it stops carrying one at the end of the next turn, which is when a conversation is written down. Suite 606 green. Four known-bad inputs, four killed: save writing the messages unfiltered again, remember taking the file's messages wholesale, the filter keeping the system message, and the filter being too EAGER and dropping tool results - which would silently shorten somebody's conversation, the same defect one step further along. | 3 天前 | |
The half the gate said it could not do, and one declaration of asyncio (#1352) 0.67.0 shipped a gate on dead product surface and declared methods out of scope, with a reason: a method reaches the code as an attribute, and plan.describe and SessionPlan.describe are the same attribute name, so counting names cannot tell the dead one from the live one beside it. That reason was true and it was not a limit, it was a missing step. It is done now. RESOLVE THE OWNER, AND THE AMBIGUITY GOES AWAY. An attribute on a name that the file imported as a MODULE is the module's function and never the method; self.X, Class.X and anything.X could all be the method and count. The second half is deliberately generous - an arbitrary expression counts as a reference to every method of that name - so it errs by letting something live, never by accusing it. 47 methods judged out of the 59 defined: the twelve left out are methods of a class WITH A BASE, which may be satisfying somebody else's contract, and decorated ones, which can be handed somewhere this cannot follow. The real-tree mutation it exists for - SessionPlan.describe put back - is named by file and line. AND THE FIRST VERSION OF IT LET THAT MUTATION SURVIVE. Strings were split into words, so the docstrings in plan.py - which say "describe() reads the KWARGS" - counted as reaching the method. The check was satisfied by the prose beside the code, which is the most repeated defect in this project, met inside the tool built to find it. Whole-string identifiers only now, which keeps the genuine getattr case and drops the sentences: the universe of attribute names went from 4,798 to 500, so nine tenths of what was keeping methods alive was prose. Floors AND a ceiling are asserted, because a collapse makes every method look dead, an explosion makes every one look alive, and a hand-written perimeter goes stale the day somebody changes what is excluded - which it already had, at 59. THE PAGE WAS SCANNED THE SAME WAY AND IS CLEAN, which is worth writing down as a measured negative rather than an assumption: src/aihawk/ui is product surface too and 0.67.0 never looked at it. 109 top-level JS bindings, every one named by another file or by the markup; 49 CSS classes, every one applied, confirmed by a second measurement taken a different way. The ten scripts become one concatenated script, so the question is the same one the Python gate asks. No gate here - the page has its own in test_the_browser_workspace.py - and the numbers are in the module docstring. ASYNCIO WAS DECLARED 46 TIMES FOR ONE FACT. pyproject.toml has asyncio_mode = "auto", which marks every async test already; 45 more copies sat in the files as @pytest.mark.asyncio or a module-level pytestmark. Copies of one fact can disagree with it and these did: nine sat on SYNCHRONOUS functions, so every run printed nine warnings saying the marker did not belong. Noise is where a real warning hides. They are gone and the suite prints none. What makes that safe is a gate rather than a hope. Under strict mode pytest-asyncio SKIPS an unmarked async test instead of failing it, so deleting the surviving declaration would leave the suite green while 45 tests quietly stopped running. test_the_suite_declares_asyncio_once.py holds the declaration, proves its own known-bad against a mutated copy of the file, and refuses a file that starts declaring it again - because one re-added marker is harmless on its own, which is exactly how the other 44 would follow. AND THAT LAST CHECK EARNED ITSELF IMMEDIATELY: it found two the removal script had missed, both in list form (pytestmark = [pytest.mark.asyncio, ...]) where the pattern did not match. A partial job that reports success is the thing a gate is for. A must-not-fire case failed on the first run for the second time in two days, and the gate was right both times: a toy module whose outermost function has no caller HAS a dead surface. The real package always has an outer caller; a fixture does not unless it is given one. Suite 664 green from 654, with no warnings. Tests only, nothing under src, so no version: the gate says in its own words that tests alter nothing anybody installs. | 3 天前 | |
A tool result is read in one place, which its docstring already claimed (#1358) `link.text_of` carried a docstring saying it was "shared with the agent loop rather than written twice: a tool result is read in two places now, and two readers of one wire format drift". It was not shared. `agent._result_text` held the same five lines, the `[non-text result]` literal included, and imported nothing from `link`, and `/live/frame` was a third reader spelling out `getattr(result, "isError", False)` for itself beside a call to `text_of`. Three readers, two of them with their own tests, so either copy could have moved alone and stayed green. `link.answer_of` answers both halves now, and the loop and the route read it. The name is not `said`, which is already a function in `agent.py`, the name of an event the page draws, and a local in two routes. The tests that held `_result_text` moved with the function. The gate that keeps it that way is structural rather than nominal, because the surface gate added in 0.67.0 cannot see this class: a duplicated reader is named in `src` and answers yes. It walks the AST and asserts that only `link.py` reads `isError` or the no-text literal, so a comment can neither trip it nor satisfy it. Four known-bad inputs, all killed, including `link` no longer reading the flag at all, which would otherwise have left the first assertion vacuous. Two smaller things in the same branch. `Conversation.known` was the only attribute of that class not declared in `__init__`: it was assigned inside `if self.tool_defs is None`, so a conversation whose definitions arrived any other way raised AttributeError on the line that tells a model it asked for a tool nobody has. It is read off `tool_defs` now; an empty list in `__init__` would have been worse, because an empty list is a legal answer that refuses every tool quietly. And two comments named code that has not existed for weeks in the present tense, `registry.py` in `mcp/server.py` and `registry.peek` reached from a `looking` helper in `link.py`, the second inside a paragraph that opens by warning about stale reasoning. The past-tense mentions stay. Separately, `07-fleet.css` went in 0.53.0 and left the stylesheet folder reading 01 to 06 and then 08, in a module whose docstring says the order is load-bearing. `08-stage.css` is `07-stage.css` and the assembled page is byte for byte what it was. 0.68.2 because six files that go into the wheel changed and 0.68.1 is already on the index, which `test_version_is_not_taken` refused until it was bumped. Verified locally: 703 passed, ruff clean, `invisible_core.english` clean, `check_content.py` clean, the version gate green against the real index, and the wheel builds at 0.68.2 with the renamed stylesheet inside. Both opt-in suites still collect. | 2 天前 | |
Two gates that execute where they used to read (#1338) Tests only. Nothing that ships moves, so there is no version to bump. THE HANDLE LOGIC. SNAPSHOT_JS is 220 lines of JavaScript inside a Python string, and the part of it that builds a selector decides where every click this product makes lands. Its behaviour was proven in exactly one place, test_snapshot_handles.py, against a real Firefox and behind an e2e marker that the default selection deselects. So on an ordinary run, and on every pull request that does not pay for an engine, "prefer an id, then a name, then an href" was held up by regexes over the source text - which can see that a branch exists and never what it returns. node is on this machine and on every runner, and three of this suite's page gates already use it. The handle half is pure: attributes in, a selector string out, with one question asked of the document. That fake is small enough to print. The visibility half is not and stays e2e, because it reads layout through getBoundingClientRect and getComputedStyle and a fake DOM for that would be fiction rather than a test. The docstring says so rather than leaving the perimeter to be guessed from a green. What now runs in the fast suite: the order of preference including the two arms appended last, the nth-match wrapping and its one-based index, the null for an element the document does not hold, the quoting of a value containing a quote or a backslash, and the refusal of a placeholder href. Seven known-bad inputs, seven killed. THE WIKI SAMPLES. writing-an-mcp-client-in-python.md publishes a transcript of driving this server, and its browser_list line carried running: false - removed in 0.54.0 - inside a fleet holding a browser that a freshly started server has not had since open-first landed in 0.53.0. Three versions stale, on the page somebody follows to write their first client. Nothing was watching. The content gate anchors on the total character count of the tool descriptions, which moves whenever a tool is added, removed or reworded, and it did its job the day browser_list was rewritten. A JSON sample is prose to it. So the new gate asks the server what the answer looks like rather than holding a list of field names that would go stale the same way the samples did, and compares the VOCABULARY at both levels: a sample may show a different session than yours, and may not show a field that does not exist. A second assertion covers the half the keys cannot see - the old sample's fields were all real in their day, and what made it wrong was that it showed a browser beside words saying nothing was open. Two known-bad inputs, two killed. And one comment that had not been true for fifty versions: the constant the version gate uses as its live known-bad called itself "the release BEFORE the current line of work" while pointing at v0.5.0. The assertion was never wrong - the package has certainly moved since - but the sentence described something somebody would have to update every release, and nobody did. It now says what that value actually has to be, which is old and present in the clone. | 3 天前 | |
The half the gate said it could not do, and one declaration of asyncio (#1352) 0.67.0 shipped a gate on dead product surface and declared methods out of scope, with a reason: a method reaches the code as an attribute, and plan.describe and SessionPlan.describe are the same attribute name, so counting names cannot tell the dead one from the live one beside it. That reason was true and it was not a limit, it was a missing step. It is done now. RESOLVE THE OWNER, AND THE AMBIGUITY GOES AWAY. An attribute on a name that the file imported as a MODULE is the module's function and never the method; self.X, Class.X and anything.X could all be the method and count. The second half is deliberately generous - an arbitrary expression counts as a reference to every method of that name - so it errs by letting something live, never by accusing it. 47 methods judged out of the 59 defined: the twelve left out are methods of a class WITH A BASE, which may be satisfying somebody else's contract, and decorated ones, which can be handed somewhere this cannot follow. The real-tree mutation it exists for - SessionPlan.describe put back - is named by file and line. AND THE FIRST VERSION OF IT LET THAT MUTATION SURVIVE. Strings were split into words, so the docstrings in plan.py - which say "describe() reads the KWARGS" - counted as reaching the method. The check was satisfied by the prose beside the code, which is the most repeated defect in this project, met inside the tool built to find it. Whole-string identifiers only now, which keeps the genuine getattr case and drops the sentences: the universe of attribute names went from 4,798 to 500, so nine tenths of what was keeping methods alive was prose. Floors AND a ceiling are asserted, because a collapse makes every method look dead, an explosion makes every one look alive, and a hand-written perimeter goes stale the day somebody changes what is excluded - which it already had, at 59. THE PAGE WAS SCANNED THE SAME WAY AND IS CLEAN, which is worth writing down as a measured negative rather than an assumption: src/aihawk/ui is product surface too and 0.67.0 never looked at it. 109 top-level JS bindings, every one named by another file or by the markup; 49 CSS classes, every one applied, confirmed by a second measurement taken a different way. The ten scripts become one concatenated script, so the question is the same one the Python gate asks. No gate here - the page has its own in test_the_browser_workspace.py - and the numbers are in the module docstring. ASYNCIO WAS DECLARED 46 TIMES FOR ONE FACT. pyproject.toml has asyncio_mode = "auto", which marks every async test already; 45 more copies sat in the files as @pytest.mark.asyncio or a module-level pytestmark. Copies of one fact can disagree with it and these did: nine sat on SYNCHRONOUS functions, so every run printed nine warnings saying the marker did not belong. Noise is where a real warning hides. They are gone and the suite prints none. What makes that safe is a gate rather than a hope. Under strict mode pytest-asyncio SKIPS an unmarked async test instead of failing it, so deleting the surviving declaration would leave the suite green while 45 tests quietly stopped running. test_the_suite_declares_asyncio_once.py holds the declaration, proves its own known-bad against a mutated copy of the file, and refuses a file that starts declaring it again - because one re-added marker is harmless on its own, which is exactly how the other 44 would follow. AND THAT LAST CHECK EARNED ITSELF IMMEDIATELY: it found two the removal script had missed, both in list form (pytestmark = [pytest.mark.asyncio, ...]) where the pattern did not match. A partial job that reports success is the thing a gate is for. A must-not-fire case failed on the first run for the second time in two days, and the gate was right both times: a toy module whose outermost function has no caller HAS a dead surface. The real package always has an outer caller; a fixture does not unless it is given one. Suite 664 green from 654, with no warnings. Tests only, nothing under src, so no version: the gate says in its own words that tests alter nothing anybody installs. | 3 天前 | |
A dependency you import is one you declare (0.68.0) (#1353) pyproject.toml states this rule twice, in its own words, about three packages: python-dotenv is "declared rather than inherited ... a transitive dependency is one somebody else can drop in a minor release without telling us", and starlette and uvicorn are "declared even though mcp already pulls both in ... the day mcp stops needing them, the failure would otherwise land here". It was not applied to pydantic. src/aihawk/mcp/server.py says `from pydantic import Field` and nothing declared it: it arrives because mcp requires pydantic>=2.11,<3. The project knew the rule, wrote it down twice, and missed the third - which is the shape that asks for a gate on the class rather than a fourth careful sentence. WHY NOTHING COULD HAVE FOUND IT. Every environment that has mcp has pydantic, so the suite is green, the six matrix jobs are green, and a clean-environment check installing from the index is green too. The wheel is broken only in a future that has not happened yet, and when it does the traceback names pydantic while the cause is in somebody else's pyproject. httpx is the same defect in the test extra: the real-server test imports it and it was arriving through mcp. THE GATE IS ON THE CLASS. Every third-party module the package imports must be declared, and the same question is asked of the suite against the test extra. It was written before the fix and went red on the real state, naming pydantic and the file that imports it, which is the only way to know a gate can fail. AND AN IMPORT THAT DECLARES ITSELF OPTIONAL IS EXEMPT, which the same scan taught by being wrong. Run over the two sibling packages, it raised exactly one thing in shipped code: `from packaging.markers import Marker` in invisible_core, which is correct - inside a function, in a try that catches ImportError and answers "cannot tell", with a docstring saying packaging is not one of that package's runtime dependencies. The scan was wrong, not the line, and this gate had the same blind spot waiting for the first optional import anybody writes here. The exemption is the SHAPE of the code, not a name on a list, and both sides are held: guarded is left alone, the same module imported at module level is still caught. The first version of the gate also carried a hand-written list of local module names and was already wrong on its first run: it missed test_web_service, which another test imports by bare name under this suite's convention. The list is resolved from the tree now. A list of what a directory already says is a second declaration, which is the thing this release is about. What it does not see is written into it: an import through importlib with a computed name, and whether a floor is high enough, which only a resolver can answer. pytest-asyncio is correctly declared and correctly never imported, because a plugin is loaded by pytest rather than by code. THE 503 BRANCH OF /live/frame IS NOW CHOSEN BY THE SERVER. That route picks between 204 and 503 by asking whether the sentence a tool raised IS the not-open one, and only the yes was proven end to end: a comparison that answered "not open" to EVERYTHING passed the whole suite, which turns every real breakage into a silently idle pane. Reaching the other side without starting a browser costs nothing once you notice that `browser` is declared Literal["main", "support"], so a third value is refused by the tool's own schema and comes back as an error result that is not that sentence. Known-bad applied to the real route: the new test fails and the 204 one stays green, which is what should happen. WHAT WAS SCANNED AND IS CLEAN, recorded as measured negatives rather than assumptions. The six scripts in scripts/ are all invoked by workflows and carry no unreferenced top-level definition and no uncalled method. All eight runtime dependencies are genuinely imported by the package. AND ONE THING THIS AUDIT REPORTED THAT WAS FALSE. It listed mixed line endings as remaining debt. Measured on the index: 255 of 255 text files are LF only, zero CRLF, zero mixed, zero lines a renormalisation would rewrite. The working tree is CRLF because core.autocrlf is true on that machine, which is a property of a checkout and not of this repository. A claim carried over from a sibling repo without being measured, which is the error this audit exists to find, made by the audit. Suite 671 green from 664. | 3 天前 |
| 文件 | 最后提交记录 | 最后更新时间 |
|---|---|---|
| 3 小时前 | ||
| 6 天前 | ||
| 3 天前 | ||
| 3 天前 | ||
| 4 天前 | ||
| 12 小时前 | ||
| 3 天前 | ||
| 3 天前 | ||
| 3 天前 | ||
| 4 天前 | ||
| 3 天前 | ||
| 2 天前 | ||
| 2 天前 | ||
| 6 天前 | ||
| 14 天前 | ||
| 18 小时前 | ||
| 4 天前 | ||
| 15 天前 | ||
| 2 天前 | ||
| 10 天前 | ||
| 5 天前 | ||
| 12 小时前 | ||
| 3 天前 | ||
| 2 天前 | ||
| 10 天前 | ||
| 4 天前 | ||
| 6 天前 | ||
| 3 天前 | ||
| 3 天前 | ||
| 6 天前 | ||
| 4 天前 | ||
| 3 天前 | ||
| 5 天前 | ||
| 3 天前 | ||
| 3 天前 | ||
| 12 小时前 | ||
| 2 天前 | ||
| 3 天前 | ||
| 4 天前 | ||
| 4 天前 | ||
| 4 天前 | ||
| 3 天前 | ||
| 4 天前 | ||
| 3 天前 | ||
| 3 天前 | ||
| 3 天前 | ||
| 3 天前 | ||
| 3 天前 | ||
| 3 天前 | ||
| 3 天前 | ||
| 18 小时前 | ||
| 3 天前 | ||
| 3 天前 | ||
| 2 天前 | ||
| 3 天前 | ||
| 3 天前 | ||
| 3 天前 |