feat(intent): resolve a natural-language goal to a task_key before requiring one - #158
Conversation
…quiring one (#124) A cache hit today requires already knowing the exact task_key, which means already knowing a compiled program exists — at which point the cache saved nothing a filename would not have. src/intent/ closes that gap with a normalized-exact-match resolver (least-clever-thing-that-works, per #124) behind a swappable IntentMatcher interface, wired into src/recorder/cli.ts via --intent as an alternative to --task-key. A miss or near-miss refuses rather than guesses — there is no nearest-neighbour fallback. Also lands the site_key/address split (#124 item 4): site_key now names a product+version (grafana-oss@9.5.21), never grafana-oss@{host}:{port}. The old form duplicated data already parameterized on every live trajectory (base_url_template, parameters.host/port, bindings) and made the same product+version at two addresses look like two different sites — the exact inconsistency #124 named, and blocking cross-instance reuse by construction. Small enough to land with the resolver: site_key was already an unconstrained string in every schema, nothing parses it structurally, and no test asserts the old literal. ADR-0015 records both decisions — task identity (phrasing/parameters/host don't fork it, product version does) and the site_key split — plus what is deliberately deferred: wiring src/intent/ into gate:matrix --from-cache, left as an explicit open question rather than done partially. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Review — verdict: no blocking issuesReviewed by reading the full diff against a fresh worktree off 1. Is the matcher genuinely swappable, or a naming gesture?Genuine. 2. Is MISS a real typed return, with no nearest-neighbour fallback?Confirmed — this was the constraint I read the code hardest for, and it holds.
There is no score, no distance, no The caller side is equally tight. The 3. Is the
|
Resolves the docs/README.md Decisions-table conflict: #158 landed the ADR-0015 row on main where this branch adds ADR-0016. Both rows are kept, in numeric order. No other file conflicted.
Closes #124. ADR-0015. Builds on #118/ADR-0014 and #120/ADR-0013.
A cache hit requires already knowing the slug.
task_keyflows unchanged through the trajectory, the compiler, every cache row, and every metric row — and nothing produces it except a person typing one on the command line (src/recorder/cli.ts:268, pre-#124). #118 wired a lookup by(site_key, task_key); it did not close the product question, because an agent arrives with a goal in natural language, not a slug. To get a hit you had to already know a compiled program existed — at which point the cache saved nothing a filename would not have.site_keyhad the same problem plus one more: the only real compiled bundle onmaincarriedsite_key: "grafana-oss@127.0.0.1:3000"— host and port baked into cache identity — while the same host and port were already parameterized inside the same rows as{host}/{port}. One fact, two homes, and the two could disagree.What landed
src/intent/— a new package resolving a phrase to atask_key, or a typed MISS. "The least clever thing that works," per #124: normalize (NFKC, lowercase, strip punctuation, collapse whitespace) and require exact string equality against a hand-maintained catalog (src/intent/catalog.ts, two entries — the two tasks the recorder actually records today). No score, no threshold, therefore nothing fordocs/INTEGRITY-AUDIT.mdcategory B to flag.Behind a swappable
IntentMatcherinterface, so an embedding matcher is a second implementation, not a rewrite of the caller.IntentResolutionis three-way —resolved | needs_confirmation | miss— not two.ExactNormalizedMatchernever returns the middle state (an exact match has no partial credit to hedge with), but the type exists now so a future scored matcher has somewhere to put a near-miss instead of resolving it silently — #124 item 3's "prefer a conservative matcher plus an explicit confirmation path over a permissive one," implemented as a type the current matcher doesn't need yet rather than deferred until the next matcher lands.Wired into
src/recorder/cli.ts:--intent "<goal>"as an alternative to--task-key, tried first, refusing the recording (not falling back to a default) on a miss or a near-miss.resolveTaskKeyForRecordinglives in its own module (src/recorder/select-task.ts), not incli.ts—cli.tsrunsmain()unconditionally on import (theparagentbinary router, #155, dispatches by dynamicimport(), by design), so a function that needs unit testing without a browser cannot live there.site_keydrops the address.src/recorder/site-identity.ts::buildLiveSiteKey(product, version)takes no host or port — the guarantee that two addresses can't produce two site_keys is in the function's signature, not in a caller remembering to omit something.contracts/trajectory.schema.json's own field description has read"e.g. grafana-oss@10.2.0"since it was written; the live path is now the thing that matches its own schema's example. Landed in the same PR rather than deferred (#124's scoping allows either): the actual change is string-construction-only —site_keywas already an unconstrained string in every schema that carries it, nothing insrc/parses it structurally (grep-verified), and no test asserted the old literal. The one committed live trajectory + bundle are recompiled to match (grafana-oss@9.5.21), which also picked up theprogramref ADR-0013 added — the previous bundle predated it and was unresolvable byresolveProgram(), exactly as that ADR's Consequences section anticipated ("the only compiled bundle onmainwill carryprogramthe next time it is recompiled").Task identity, decided explicitly (ADR-0015)
task_keyis opaque, not a rendering of any one phrasingparameters/bindings; catalog descriptions are forbidden from encoding one (digit tripwire in the test)site_key, because host/port aren't part of site identity at all nowsite_key. Locators are version-specific by design (ADR-0006); collapsing identity to the bare product name would let a 9.5.21 program resolve for a 13.0.3 request and silently misfire partway through — the exact "near-miss that has already clicked things" #124 warns about, one layer down from intent matchingScope
Not done: wiring
src/intent/intogate:matrix --from-cache, which already takes--task-keyfor a lookup and is the more obviously cache-shaped second call site. Recorded as an explicit follow-up in ADR-0015's Open Questions rather than half-built — the recorder wiring is the one realistic call site the issue asked for, and--from-cacheis a distinct piece of work (a refuse-on-miss CLI path, not just a function call).docs/gate/cache.mdstates both hit-rate denominators this unblocks (cache-consulting runs vs. tasks requested) so whoever wires that call site adds the second series rather than conflating it with the first.Not touched:
src/cache/allowlist.ts,src/cache/taint.ts,docs/pitch/— owned by parallel agents on #126 and other issues.Still
writeCacheRow()'s job: resolution decides whichtask_key; it doesn't touch the cache, doesn't callresolveProgram(), and doesn't write a row. A resolved task still has to passresolveProgram()'s completeness check and its own assertions, same as a hand-typed slug.Before-you-open-the-PR checklist
site_keydecision — ADR-0015docs/gate/cache.md— extended the existing hit-rate section rather than a new fileIntentMatcher); no threshold — exact match has none to labeldocs/architecture.mdupdated — newINTENTnode + edge in the mermaid diagram, new package-table row, prose noting the not-yet-wired cache-read hop is deliberately not drawn as an edge🤖 Generated with Claude Code