Skip to content

Latest commit

 

History

History
481 lines (406 loc) · 31.8 KB

File metadata and controls

481 lines (406 loc) · 31.8 KB

AnnotKit foundation decisions (F0)

Resolves the open decisions from the plan (planning/annotkit in the cli repo). Provisional items are cheap to change before the public release.

Decision Choice Notes
Name AnnotKit (provisional) Trademark check required before public release. Distinct from Agentation (PolyForm, brand).
License MIT Maximises adoption. See LICENSE.
Repo location Standalone repo at ~/Development/annotkit VirgilHUD consumes it during dev via a local SwiftPM path dependency; public remote created by the maintainer.
Versioning SemVer, 0.x pre-1.0 Breaking changes allowed while 0.x; 1.0 marks a stable public API.
Default element source Accessibility hierarchy The only strategy that surfaces SwiftUI accessibilityIdentifier values.
Annotation target rule Deepest actionable, else deepest meaningful; anchor the selector to the nearest identifier One rule on both platforms. Supersedes the earlier macOS "deepest meaningful" and iOS "nearest identified" split. See below.
Marquee target rule Largest meaningful element ≥85% surrounded; else the tightest element enclosing the drawn frame Rect selection, the deliberate inverse of the point rule's deepest-wins. See below.
Selection navigation Bidirectional Parent/Child over one path; descent replays history and only queries the source at the deepest rung Replaces the one-way "Widen". Prepending the frontier child shifts every rung, so a note's component is the first SEEDED rung above the BOUND one. Whether the parent chain stays seeded-only is OPEN. See below.
Frame mode anchoring The frame the user DREW anchors the overlay until they navigate; the resolved element is NAMED in the composer, not drawn on the canvas Hover is point-mode-only, gated in the session rather than the view. See below.
Opt-in element source View tree (NSView/UIView) Surfaces concrete view class names; richer for AppKit/UIKit hosts. Collapses to hosting views in pure SwiftUI.
pathname mapping Host-supplied route, inferred fallback A native app has no URL routes; the host sets a route, else infer from the key window title or identifier.
Overlay coverage Primary screen (MVP) The overlay covers the primary display; SwiftUI-local points map to AX screen coordinates there. Full multi-display placement is deferred (cli-a99qm.4.2).
Dev-only gating #if DEBUG default + env override On in DEBUG unless ANNOTKIT_DISABLE; off in release unless ANNOTKIT_ENABLE. Mirrors VirgilHUD InspectMode.
MCP bridge in v1 Deferred to F6 (optional) The file and clipboard sinks cover the agent loop; the MCP/HTTP bridge is an optional later target, not part of the 1.0 critical path.
Concurrency Swift 6 language mode, strict Public Element/CapturedImage/AnnotationNote are Sendable; ElementSource/Annotation are @MainActor.

Annotation target rule (cli-got28.2)

A click must isolate the component the user meant and produce a selector that locates its code. One rule, both platforms:

  1. Hit-test to the deepest node at the point, then walk its ancestor chain.
  2. Target = the deepest ACTIONABLE control in the chain (button, link, checkbox, popup, slider, menu item, or anything exposing AXPress). A click anywhere inside a button binds to the button, not the static-text glyph that happens to be its deepest descendant.
  3. Else target = the deepest MEANINGFUL element — one carrying an identifier, a label, or a displayed value. A standalone Text inside a card is annotated in its own right (the card is not actionable, so it does not swallow the text); the selector engine then anchors it to the card's identifier.
  4. Never a target: the window, the application, window chrome (traffic lights), or a structural, unidentified, content-less group that spans (nearly) the whole window (an NSHostingView root AXGroup — the window in disguise).
  5. When the point hits nothing annotatable (decoration, dividers, padding beyond any frame), a RegionAnchorSource anchors the click to the nearest meaningful element as a REGION note rather than dropping it.

Why not "prefer the deepest identified ancestor"? Because seeding is partial: a button may be seeded but a standalone text inside a seeded card is usually not. Preferring the identified container would collapse every click inside a card onto the card and lose the specific element. Preferring the actionable/meaningful leaf and letting the SELECTOR anchor to the nearest identifier (#Card >> text="…") keeps both the specificity and the code-locating anchor. Selection widening (cli-got28.2.3) exists for the times the user does want the enclosing component.

This replaces the earlier asymmetry (macOS "deepest meaningful", iOS "nearest identified") documented in docs/spike-ax-pointquery.md, now corrected. The pure decision lives in AnnotationTargetRule and is unit-tested independent of AX.

Component containment is GEOMETRIC, not tree-ancestry (cli-got28.2)

"Which component encloses this element" is resolved by frame containment, not by walking the AX parent chain. A SwiftUI card seeded with .axCardSurface (the dominant VirgilHUD pattern) is a clear Color.clear background leaf carrying the identifier — and .background makes it a sibling of the card's content, not an ancestor. So the card's identifier never appears in the content's ancestor chain, and pure-ancestry anchoring/widening cannot reach it (it only worked for .accessibilityElement(children: .contain) containers, which are ancestors).

The macOS componentLadder therefore collects every identified element whose frame encloses the point and is larger than the target, smallest-first — scanning the ancestor chain plus each ancestor's direct children (where those background surfaces live), so it reaches sibling card surfaces without a full snapshot. This ladder drives selection widening and the note's component field. Selector anchoring (#Card >> …) still requires a true ancestor because the >> operator is descendant-based; when the component is a sibling surface the selector may be positional or text-based while the component field still names the card, so the note locates the right code either way.

Marquee target rule (VRT-cne0.5)

The user press-drags a rectangle around what they mean. Given every element the adapter can see (a flat array, not an ancestor chain — a marquee sweeps across siblings and unrelated subtrees), the note binds by two passes over the standardized rect, using the same eligibility as the point rule (never the window, the application, chrome, or a window-spanning ghost group, and never a zero-area frame):

  1. Surrounded — every eligible element the frame covers to ≥85% of that element's OWN area; the largest wins.
  2. Enclosing — nothing was surrounded, so the frame was drawn inside something: every eligible element whose frame contains the whole rect; the smallest wins.
  3. Neither — nil, and the session falls back to a region note anchored near the frame, exactly as a point that hit-tests to nothing does.

Ties inside a pass: seeded beats unseeded, then shallower (surrounded) / deeper (enclosing), then lowest index. Areas compare with exact ==, no epsilon.

Why largest-wins, when the point rule is deepest-wins? Because the gestures mean opposite things. A click means "this exact spot", so it descends. Drawing a box around a card means "I mean this whole thing", so it must ascend past the labels and buttons the box also swallowed. Same tree, opposite intent — hence a separate rule rather than a mode flag on AnnotationTargetRule.

Why 0.85 and not strict containment. A hand-drawn rect clips edges. Users drag roughly around a card and routinely shave a corner or slice through a trailing chevron; at 1.0 that silently demotes to the enclosing fallback and binds the note to the panel instead of the card — the exact failure marquee exists to remove. 0.85 absorbs that sloppiness and still sits far above the coverage a neighbouring card picks up when a drag merely overlaps its edge.

Why seeded-beats-unseeded exists at all. It is not a general preference for identified elements (that is the mistake the point rule documents above). It is narrowly for the coextensive case: .axCardSurface(id) hangs the card's identifier on a clear Color.clear background leaf that is exactly the same frame as the card's content group. Both are surrounded identically, and only the seeded one carries the identifier that locates code. AXIntrospection.deepestChild already resolves this same pattern by exact equal-area comparison, which is why no epsilon is used here — the two frames come from one layout computation, so the arithmetic is bit-identical, and an epsilon would instead start collapsing genuinely different elements into a seeding decision.

Why the enclosing fallback. It is the rect generalization of the point-region note (rule 5 above): a scribble over a card's padding surrounds nothing, and dropping it would be the same lost-click bug RegionAnchorSource was added to fix. Smallest-wins there because the tightest enclosure is the most specific — a scribble inside a card must not resolve to the window-spanning panel that also contains it.

Depth and index are determinism-only tie-breaks; they exist so the same drag always resolves to the same element. The pure decision lives in MarqueeTargetRule and is unit-tested independent of AX; adapters expose it via the optional MarqueeTargetSource capability, which returns a component-widening ladder identical in contract to ComponentLadderSource, so widening and the note's component field work unchanged.

Selection navigation (VRT-mijf.1)

The composer's one-way "Widen" button is replaced by Parent and Child over a single path: index 0 is the deepest rung known so far, ascending indices are progressively broader, and one index marks the rung the note is bound to.

The rename is not cosmetic. "Widen" named the MECHANISM — the highlighted area gets bigger — when the act is choosing which component the note is filed against. A button that makes things bigger implies no inverse, so a user who overshot, or whom the target rule bound coarser than they meant, had nothing to press.

Descent prefers HISTORY over re-querying. Above the deepest rung, Child just steps the index back down what the user climbed. Only AT the deepest rung does it ask the source for children, and it then PREPENDS the one it takes, so index 0 still means "deepest known rung". Re-querying on every press would be less code and wrong: the source's answer is a heuristic over a LIVE tree, so a hover state resolving or a list reflowing between two presses makes the same key produce a different result. Prepending is what makes the round trip hold in BOTH directions — after descending to child C, Parent returns to the original target and Child returns to C ITSELF rather than re-running the heuristic against a tree that has moved on.

The consequence that bit us. Prepending shifts every existing rung up one, so "the note's component is the rung above the target" stopped being true: index 1 is now the ORIGINAL target, which is frequently unseeded. component is therefore the first SEEDED rung strictly above the BOUND rung, and it is read from that rung's IDENTIFIER, never from its Element.id. An unseeded element's id is a slash-joined path (AXWindow[0]/AXGroup[0]/AXStaticText[1]); exported as a component it hands the consuming agent a grep target that matches nothing while looking entirely plausible in the note — a silent miss, not a visible one. The same path is rooted differently depending on which entry point produced the element (snapshot() roots at the window, the hit-test and marquee paths at the application), so the id is not even stable for one node, which is a second reason it can never be a code locator.

Open, pending dogfood: should the parent chain stay seeded-only? It is today — every rung above the target is an identified component, so every rung locates code and no press can bind a note to something that names nothing. The cost is that it skips structural levels the user can SEE: a row inside an unseeded stack offers no rung for the stack, so Parent jumps from the row straight to the card and the level the user was aiming at is unreachable. Admitting unseeded rungs would fix the navigation and degrade the notes. Which failure is worse is not decidable from the design; it needs real use, so this is recorded as unresolved rather than settled.

Frame mode anchoring (VRT-mijf.2)

When a drawn frame resolves to a real element, the overlay anchors its highlight, composer and pin to the FRAME the user drew — not to the element — until the user presses Parent or Child, at which point the bound element becomes the anchor and the frame stays on screen, dimmed.

Why the frame outranks the resolved element. The user drew a box, so the box is the truth of the selection until they say otherwise. Anchoring to the resolution instead makes the rectangle vanish the instant the mouse comes up and the highlight snap to a card that was never swept, which reads as the tool having ignored the gesture.

Why the element is NAMED rather than DRAWN. A note must never be captured against a target the user could not see, so the binding has to appear somewhere. But a second rectangle on the canvas is exactly what "show me only the frame I drew" rules out, and two boxes of different shapes leave it ambiguous which one the note records. The composer header carries the name behind a Frame → prefix, so it reads as what the frame RESOLVED to rather than as a label for the rectangle, and the prefix disappears the moment navigation puts a named element back on the canvas — the name is never qualified in two places at once.

Why navigating reveals the element. Pressing Parent or Child IS the question "which element is this filed against?", so the answer has to become visible; moving the binding while the highlight stays on the drawn rect would give no feedback at all. The frame survives, weaker, because it is still what the note records (regionRect) even once it no longer decides the binding.

Why hover is gated in the SESSION, not the view. Frame mode selects from a swept rectangle, so a hover highlight there advertises a click-selection no press in that mode can produce — the dogfooding report was a whole card lit up with its name tag while nothing had been drawn. The view keeps its own guard for the narrower during-the-drag case; the MODE gate belongs one level down because there it is unit-testable without a window, no future UI path can reintroduce it, and it removes a cross-process AX hit-test per pointer-motion event. It is a cost decision as much as a visual one.

Recallable selection marks (VRT-pm3k)

A captured frame is recalled, not painted. Filing a note leaves the surface exactly as it looked before marks existed; the geometry comes back only while the user is attending that note — resting the pointer on its numbered pin, or having its edit card open. "Maintain the highlighted area" is therefore read as keep it recoverable, not keep it drawn.

Why not an always-on marks layer. It was the first design and hover-recall beats it on three counts that all show up after the third or fourth note: no stack of overlapping rectangles, no competition with the live highlight over what the next press binds to, and no permanent field of stale geometry after a scroll. It also makes the layer affordable — because exactly one mark is ever on screen, it can wear the full committed-frame treatment (solid stroke plus the wash) instead of the thin dimmed strokes stacking would have forced, so a recalled note looks like the selection it was made from.

Why the note stores two RECTS and derives nothing. anchor was a POINT, so an element note had no size at all and could not be redrawn. The only derivation available — anchor + regionRect.size — is wrong exactly where it matters: pressing Parent/Child clears the frame anchor, so the anchor becomes the ELEMENT's origin while the size is still the swept one, producing a right-sized box in the wrong place. anchorRect (what was highlighted at capture) and drawnRect (what was swept) are stored side by side, which also turns "area or element?" into a field lookup instead of an equality between rects measured in different spaces. Both are UI-only and stay out of CodingKeys: the JSON, the MCP payload and the markdown are byte-for-byte unchanged.

Why recall is GEOMETRIC rather than the pin's own hover. Pins go inert in frame mode (below), and a view with allowsHitTesting(false) receives no hover — so the trigger cannot live on the pin. PinAttentionRule answers "which note's pin contains this point?" from the catcher's own hover, in both modes: one mechanism instead of two that can drift, and "hover reveals mark N" becomes a unit test rather than something a human checks with a mouse. Its radius is deliberately LARGER than the pin, and that is load-bearing rather than generous: in point mode the pin is a live button above the catcher, so the only points near a pin the catcher ever sees are the ones outside it. Attention is established on the way in and stands until another point answers differently, which is also why hover-exit needs no rule of its own.

Why an open edit card outranks the pointer. Attended means hovered or open in the card, and when the two disagree the card wins — the opposite of the intuitive order. A card NAMES its note in words, so a mark belonging to a different note puts two answers on screen at once; and the hover it overrules is not always current, because in point mode the pin is a live button above the catcher, so a pointer that reaches a pin without crossing the surface first (a fast flick, re-entering the window) leaves the catcher's last answer pointing at the pin it saw before. Observed exactly that way during the visual check — the card read note 1 while the canvas drew note 3.

Why the pin VIEW is inert in frame mode. AnnotationPin is a Button mounted above the catcher, so a press starting on one never reaches the drag gesture — and every capture plants one exactly where the next frame is most likely to be drawn. That, plus hover-to-edit dropping a ~284x172 card under the pointer, is the reported "if I select an element I cannot also use the frame tool". In frame mode the user is drawing, so one condition removes both with no gesture negotiation.

Why a pin is still CLICKABLE there (VRT-u209). Going hit-test-inert was meant to stop a pin swallowing a DRAG; it also removed the only route to the editor, so a comment written with the frame tool could be read and never edited — reported as "I need to click the comment again to edit it… right now when I hover on one of the numbers, nothing happens", and settled by probe 11c printing editingNoteID=nil for a real press on a real pin in frame mode. The press is now routed by GEOMETRY at the catcher's release (PinAttentionRule.pressedNote via SelectionGesture), so the view stays inert and the pin stays live. Travel, not tool, is the gate: a click on a pin edits it in either tool, a drag from a pin still draws its frame — one sentence true of both, and the VRT-dp47 report cannot come back. Probe 11c now measures all three legs (click in each tool, drag from a pin).

Why the press radius is the pin's own, not attentionRadius. The two circles are paid for differently. Attending is free — it draws a picture and takes nothing away — so it may reach outside the pin (and must, since in point mode the catcher only ever sees points outside a live button). A click is scarce: the same press could have selected the element underneath, so its target is exactly the circle the user can SEE. Wired to attentionRadius, every pin would be a 40pt hole in the canvas that looks 20pt wide.

The composer covering its own element is ACCEPTED. A card the user may be typing into must consume its own clicks; dismissing it or starting the drag from outside it are the ordinary mitigations. Unlike a pin, it has state to lose.

The overlay accepts first mouse (dogfooding, VRT-pm3k)

"I have to click twice to get the functionality to work and the little comment portal to appear." AppKit discards a mouse-down that lands in a non-key window which can become key, unless the view under the pointer accepts first mouse — and NSHostingView does not. KeyablePanel can become key because the composer's text field needs keyboard input, so the first press on the overlay only ever bought it focus. Both panels' content views are now a FirstMouseHostingView.

It was not a once-per-launch tax, which is why it read as a permanent one: the catcher panel is rebuilt every time the menu opens, and any interaction with the host app hands key back, so the next press was swallowed again. It cost both tools equally, since they share one gesture on one catcher — in frame mode the first drag drew no band and resolved nothing.

Accepting first mouse is the right answer rather than a workaround: annotate mode is a mode the user has deliberately entered, so a press on the catcher is always meant for the catcher. The alternative — making the panel unable to become key — would take the composer's typing with it.

Shift+Enter inserts the line break by hand (dogfooding)

The card promises ⏎ save · ⇧⏎ newline in its own header, and the second half was not true. It used to return .ignored for Shift+Return and trust the field. A vertical TextField on macOS has no newline gesture: the key arrives at AppKit as an ordinary insertNewline:, which ENDS EDITING. Measured — typing "a", Shift+Return, "b" left the binding holding "b": no break, and the text already typed was gone. A field with no key handler at all behaved identically, so the interception was never the cause.

insertNewlineIgnoringFieldEditor(_:) sent to the FIELD EDITOR — the NSTextView that actually holds the text while a SwiftUI TextField is focused — is AppKit's action for "a line break, do NOT end editing" (what Option+Return does in any NSTextField). Going through the editor rather than appending to the binding is what puts the break at the CARET, so someone fixing the middle of a sentence gets it where they are typing instead of stapled to the end.

Rejected: swapping the field for a TextEditor, which does honour Shift+Return. It would have meant re-creating the rounded border, the 2–5 line growth and the placeholder by hand — restyling the one control on both cards to fix a keystroke.

macOS-only by necessity; on iOS .ignored is correct, since UIKit's multiline field inserts the break itself and a touch keyboard has no Shift+Return.

The live selection follows the content on scroll, not just the notes

"The manually drawn frames disappear on scroll." They do not disappear, they detach, which is worse: reproduced, a frame drawn around row 3 kept its exact window position across a 360pt scroll and ended up drawn around row 8 while the composer still named row 3. Scrolled far enough, the framed thing leaves the viewport while its rectangle stays behind on whatever took its place.

F4 corrected captured notes and left the live selection behind — the wrong half to skip, since a recalled mark is asked for deliberately and one at a time whereas the live frame is on screen continuously with a name on it. translateSelection applies the same measured translation to the drawn rect, the bound element, the navigation path and the child cache. The path and the cache are not housekeeping: they are what Parent/Child binds to later, and a rung left behind would re-anchor the note to where its element used to be.

The delta needs no conversion between AX-screen and window-local space — they differ by a fixed origin, so a pure translation is identical in both — and only the viewport rect is converted. The persisted, element-relative regionRect is invariant under all of this by construction (both ends of the measurement move together), and that is pinned by test: scrolling must never rewrite the record an agent reads.

When the framed content scrolls fully out of view the frame goes off-surface with it, which is the honest outcome; ComposerPlacement already clamps the card, so it stays on screen and sendable.

The toolbar panel is sized to the pill (VRT-jiuo)

The panel carrying the pill used to be a fixed 240x104 rect pinned to the host's bottom-right, permanently mounted in BOTH modes. It consumes presses across its whole frame, so that corner of the host app could not be clicked, and no frame drag could be started in it. The idle pill used 8% of it; the other 92% was dead space no user could see and every user could hit.

The mechanism, measured rather than assumed. macOS does not route mouse events through a window's transparent parts. This was listed as a candidate on the assumption that per-pixel alpha pass-through would save it; it does not. A panel whose content view is a bare NSView drawing NOTHING at all, with isOpaque = false and a clear background, swallows the click just the same. ignoresMouseEvents = true is the only setting that lets events through, and it would take the pill's own clicks with it. So the only lever is the panel's SIZE.

Sizing and placement are split, and the split is the design. SwiftUI knows exactly how wide the pill is — including while it animates between the lone pencil and the six-control row — so the size is measured from it. What AppKit gets wrong is the ANCHOR: a window resize preserves the TOP-LEFT, and this panel is anchored bottom-RIGHT, so letting AppKit apply the size slid the control left by the chrome margin. sizingOptions = [] takes that away, and OverlayPlacement/toolbarFrame(hostFrame:visibleFrame:panelSize:) derives the frame BACKWARDS from where the pill has to land. The panel grows leftwards and shrinks back with the control fixed at host.maxX - 20, exactly where it always was — verified from AppKit's own view frames, not from the formula under test.

Measured on a FRESH hosting view, not the installed one. The installed view has already been stretched to the panel's current frame, so asking it for a fitting size returns the answer being replaced — a measurement of the status quo, which never shrinks. That circularity is why the first attempt silently did nothing.

Grow now, shrink later. A panel that grows LATE clips the control it carries; a panel that shrinks EARLY clips it just the same, because the pill animates its width over 0.15s on a mode change. So growth is immediate and speculative and shrinking waits for the animation. In between the panel is briefly larger than it needs to be.

What remains dead is PillStyle/panelChrome — the band the drop shadow (radius 12, offset 8 down) and the count badge (hung 5pt past the pill's top-left corner) draw into. Clipping either is visible, so this is the irreducible cost, and it hugs the control and reads as part of it. Pinned by probe 11a with three real clicks: a control click that must land, a click where the old fixed panel used to sit that must now reach the catcher, and a click in the chrome band that must still be swallowed.

Recall survives a scroll, best-effort and no further (macOS)

While annotating, the overlay owns every wheel event — KeyablePanel.scrollWheel drives the host scroller's clip itself — so the exact translation applied to the content is known at the moment it is applied, and the notes over that scroller are translated with it. The translation is measured as the DIFFERENCE in the document view's own position rather than computed from the deltas, so it is right for a flipped or unflipped document and for a scroll clamped at either end.

The limits are stated rather than implied: scrolls the overlay does not originate — keyboard paging, programmatic scrollToVisible, anything at all while the menu is closed — are not observed, and no AX re-resolution is attempted. Notes are selected by their rect's CENTRE falling inside the scroller's viewport, so a frame drawn slightly proud of a card still travels with it while chrome outside the scroller stays put. macOS-only: UIScrollView is not intercepted on iOS.

The agent-grade embedding loop (annotkit-op6)

An embedding host that launches many isolated instances of one binary — an HMR session per branch, a gallery host, an inspect window per persona — cannot express "this instance is Ada's dark-mode world and its notes go here" in the app's source. There is one binary and N launches. Three decisions follow from that.

The environment is the configuration surface. Annotation.install() reads ANNOTKIT_NOTES_MD / ANNOTKIT_NOTES / ANNOTKIT_EVENTS / ANNOTKIT_ROUTE / ANNOTKIT_CONTEXT* and builds its own sinks, so a launcher configures each instance without touching a call site (docs/embedding.md). ANNOTKIT_NOTES names the JSON store rather than the markdown one because annotkit-mcp already reads that variable for exactly that file: one variable, one file, both halves of the product pointed at it by the same env dict. Parsing lives in a pure AnnotationEnvironment taking the environment as an argument — the process env is neither thread-safe to mutate nor reversible between tests.

World context is opaque and snapshotted per note. [String: String], host vocabulary, no schema: the moment AnnotKit knows what a persona is it stops being embeddable in the next host. The provider is registered once at install and called once per capture, not once per session — appearance, window size and persona all change while a session is open, and a note has to record the world it was made in rather than the one the app booted into. Launcher context and provider context are merged with the provider winning, because it is the newer measurement.

The snapshot is unwatchable on purpose, so the stream is separate. NotesFileSink writes atomically (rename), which is what stops a reader ever seeing half a document — and is exactly what makes tail -f go silent, since it follows the inode it opened. Rather than give that up, ANNOTKIT_EVENTS adds an append-only JSONL log beside it. Two rules keep the pair honest:

  • Events are DERIVED by diffing each flush, not pushed from each capture. Both files are written by the same flush, so a watcher woken by a line always finds the note already in the snapshot — the stream cannot promise something the snapshot has not been given. A note typed but not sent produces no line.
  • The diff compares the ENCODED record, not the struct. The overlay's window-local rects move on every scroll, and a note that merely moved on screen must not read as edited. What the agent can see changed, or nothing changed.

Lines are appended with a single O_APPEND write(2), so a fleet of instances can share one log without tearing each other's lines and each line names its own world's snapshot — one tail -F covers the fleet. The stream is never a substitute for the snapshot: replayed from the beginning it would double-count edits and resurrect deletes, and the process-agentation-notes skill still reads the markdown.

AnnotKitEnvProbe is one such instance with the human taken out — same Annotation.install path, capture driven in code — and AgentLoopE2ETests runs two of them at once to assert reproduce/locate/react/isolate from outside the processes.

IP hygiene (carried into the F7 legal gate)

  • Do not copy original Agentation source (PolyForm Shield 1.0.0, non-compete). Only the shape of Agentation's AGENTATION_NOTES.md format is reused, reimplemented clean-room. Our output carries our own name — ANNOTKIT_NOTES.md, headed # AnnotKit Notes — so no Agentation mark appears in anything the package writes (2026-08-20; see the F7 legal gate).
  • The iOS adapter is a clean-room UIKit view-tree walker (IOSElementSource), not a vendored dependency. It reuses the shared selector engine, consistent with the one-engine / no-fork design, and carries no third-party code or license obligations.