Repository navigation
fix(annotate): resolve natural-language arguments or hand off to the agent - #1183
Conversation
…agent Claude Code skills run the CLI through a bash-substitution prefix that executes before the model sees anything, so any trailing natural language in /plannotator-annotate died with 'File not found: the'. Worse, a non-zero exit from that prefix aborts the whole prompt before the model runs (verified empirically), so the error was never even visible to the agent. Three-tier resolution in the binary's annotate argument handling, shared by every host via packages/shared/annotate-target.ts: 1. Fast path: probe each whitespace-delimited token; exactly one naming an existing file, URL, or folder proceeds with it directly. 2. Ambiguity: two or more tokens resolve; error naming every candidate, never guess. 3. Handoff: nothing resolves; emit an agent-addressed message echoing the words tried and asking the reading agent to interpret the request and re-run with a concrete target, preserving flags. In plain mode it lands on stdout with exit 0, the only combination that reaches the model through the bang prefix; in --json/--hook mode it goes to stderr with exit 1 so machine stdout stays clean. Single-token invocations run the unchanged pipeline first, so bare correct invocations are byte-identical. Strict gates (--require-approval or --result-file) bypass the tolerance entirely: a typo'd path stays a startup failure with exit 2 and no agent-facing prose. The CLI resolution pipeline moves to apps/hook/server/annotate-resolution.ts (returns typed outcomes instead of exiting) so the token fallback can run it once with a selected candidate; OpenCode and Pi wire the same shared selection into their own not-found paths. Skill bodies gain one line telling the agent to re-run with a concrete target when the command reports unresolvable arguments. Closes #1182 Reported-by: @technicalpickles
Adversarial review (at
|
Review fixes for the three-tier annotate argument handling:
- A single unresolvable token now falls through to the legacy pipeline
verbatim: 'annotate nope.md' is exit 1 with 'File not found: nope.md'
again in every non-strict mode, instead of an exit-0 handoff that
fail-opened scripts gating on the exit code. The handoff fires only
when two or more words resolve to nothing.
- Unrecognized dash-prefixed tokens disable tolerance instead of being
skipped, so a typo'd flag ('--no-jna') errors the way it did on base
rather than silently fetching via Jina. Known flags are stripped
before selection as before.
- Token selection now receives the original argv tokens, so a quoted
missing path ('my notes.md') is probed as one token and can never be
re-split into a silently resolving 'notes.md'.
- Bare directory names only count as fast-path candidates when they are
the sole argument; a stray word matching a directory (or '.') hands
off instead of opening folder mode. Explicit paths like 'src/' keep
resolving, and the bare-existence probe fallback is file-only.
- The handoff re-run suggestion echoes content flags only (--markdown,
--no-jina, --render-html), never transport flags (--gate, --json,
--hook).
- New subprocess suite (annotate-cli.test.ts) spawns the real CLI entry
and pins the contract: single-token typo exit 1, strict invocations
(--require-approval and --result-file) exit 2 with empty stdout and
no handoff prose, unknown-flag error, quoted-token preservation, and
the directory-hijack case. Placeholder dist files are created when a
build is absent so the suite runs in CI.
- The copilot and gemini annotate command bodies gain the same handoff
instruction as the Claude, core, and kiro skills.
- AGENTS.md documents the three tiers under Annotate Flow and corrects
the strict-section sentences that claimed non-strict behavior was
fully unchanged; the marketing annotate doc mentions the tolerant
arguments.
Refs #1182
Delta re-review (at
|
…overage (#1187) Ports five small items from the closed parallel PR #1185 into the tolerant annotate argument resolution that landed in #1183 (#1182): - Bug fix: the token probe tested the raw token against the URL regex, but the pipeline strips the @ reference marker and wrapping quotes first, so a multi-token 'annotate @https://example.com/page and summarize it' probed to nothing and emitted the handoff instead of opening the URL. The probe now unwraps with stripAtPrefix before the regex and returns the unwrapped form (the pipeline re-strips harmlessly). Tests cover @-prefixed and quote-wrapped URLs as multi-token candidates. - Test ports: absolute-path candidate, the wider plain-text set (.txt, .yaml) guarding ANNOTATABLE_DOC_REGEX breadth, the scoped-package literal-@ fallback against a real @scope/ directory, and the whole-un-split-string preference over its own tokens ('Meeting Notes.md' wins over a resolving 'Notes.md' token) covering annotateInputNamesExistingTarget. - Defensive scan: the strict-mode source-scan test now asserts the annotate startup block gates tolerance on !strictAnnotate via isStrictAnnotateInvocation, since an inverted gate cannot be spawn-tested without starting a server. - DRY: the strict predicate was defined twice (strict-annotate-result exit-code helper and the index.ts tolerance bypass). Extracted isStrictAnnotateInvocation with a StrictAnnotateFlags type; both sites use it so the exit-code path and the tolerance bypass can never drift. Behavior byte-identical; existing subprocess tests unchanged. - Docs: the tolerant-resolution section now cites #872 (commit aac5aac) for why the bang prefix is deliberate and states that argument-shape issues belong in the CLI's resolution, not the skill templates. Refs #1185, #1182 Co-authored-by: Josh Nichols <josh.nichols+agent@gusto.com>
Problem
The Claude Code skill runs the CLI through a bash-substitution prefix (
!`plannotator annotate $ARGUMENTS`) that executes before the model sees anything.$ARGUMENTSlands in argv unquoted, so/plannotator-annotate the aim docdied withFile not found: the.It is worse than an unhelpful error. Verified empirically with a scratch skill: when a
!-prefixed command exits non-zero, Claude Code aborts the prompt before the model runs at all (num_turns: 0); neither the command output nor the skill body reaches the agent. When the command exits 0, both stdout and stderr are injected. So the old failure was invisible to the agent by construction, which is why it could never self-correct (the docs are silent on this; tested on the current CLI).Design: three-tier resolution in the binary
The tolerant logic lives in the CLI's argument handling (plus the OpenCode/Pi plugin parsers), so every host benefits at once and the #872 guarantee (slash command always executes the binary) stays intact.
/plannotator-annotate look at notes.md pleasejust opensnotes.md, no model round-trip. Guardrails: single-token invocations run the classic pipeline unchanged (a lone typo'd path staysFile not foundwith exit 1 in every non-strict mode); bare directory names only count when they are the sole argument, so a stray word matching a directory (or.) cannot hijack the fast path; unrecognized dash-prefixed tokens disable tolerance entirely, so a typo'd flag like--no-jnaerrors the way it did on base instead of being silently skipped; and selection receives the original argv tokens, so a quoted missing path ("my notes.md") can never be re-split into a silently resolvingnotes.md.annotate a.md b.md); error naming every candidate and what it resolved to. Never guess.File not found: and, emit an agent-addressed message that echoes the words tried, states the command needs a concrete path, URL, or folder, and asks the agent reading it to interpret the request and re-runplannotator annotate <target>. Content flags (--markdown,--no-jina,--render-html) are echoed for the re-run; transport flags (--gate,--json,--hook) are not, so the suggestion never tells an agent to start a blocking interactive gate. In plain mode the message goes to stdout with exit 0, the only combination that reaches the model through the bang prefix (see above). In--json/--hookmode it goes to stderr with exit 1 so machine-readable stdout stays reserved for decision records; the droid wrapper forwards stderr into its command output on failure, so the droid agent still gets the handoff. On Amp the failure path is a human-facing notification (ctx.ui.notify;ctx.thread.appendruns only on success), so there the handoff reaches the human rather than the model.Mechanics:
packages/shared/annotate-target.ts(new, vendored to Pi): the token probe (URL, folder, HTML, document resolution with the literal-@fallback, then file-only bare existence so existing-but-unsupported files keep their specific pipeline errors), the tier selection (including the unknown-flagflaggedoutcome), and the message builders. Word tokens without an annotatable extension never trigger a project walk, so probing "the aim doc" costs three stat-level checks.apps/hook/server/annotate-resolution.ts(extracted from the annotate branch ofindex.ts): the unchanged resolution pipeline, returning typed outcomes instead of exiting, so the fallback can run it once with a selected candidate and so it is unit-testable. The strict-exit-code guard test enforces the invariant across both files.apps/opencode-plugin/commands.ts) and Pi (apps/pi-extension/index.ts) wire the same shared selection in front of their existing pipelines (their first pass, the whole joined string, is unchanged). Their tier-3 message is the same text without the agent paragraph, since their errors surface as host notifications rather than model context.apps/skills/claude,apps/skills/core,apps/kiro-cli,apps/copilot,apps/gemini): one line each telling the agent that an "arguments could not be resolved" report means interpret the user's words and re-run with a concrete target.Invariants held
plannotator annotate file.md) is byte-identical, and so is every single-token invocation, including failures:annotate nope.mdstays exit 1 withFile not found: nope.md(no handoff prose), so scripts gating on the exit code of a plain invocation are unaffected.--require-approval/--result-filethe tolerance never runs; a typo'd path stays stderrFile not found: ...with exit 2 and byte-empty stdout. This is now pinned by subprocess tests that spawn the real CLI entry.File type not supportedrather than the handoff.Verified
bun run typecheckgreen; fullbun testgreen (2701 pass, 0 fail).packages/shared/annotate-target.test.ts(probe incl. bare-directory gating and file-only existence, selection fast path with trailing and leading words, multi-resolve candidates, dedupe, unknown-flagflaggedoutcome incl.--no-jna, argv token preservation, zero-resolve words, message content incl. content-flag preservation) andapps/hook/server/annotate-resolution.test.ts(pipeline outcomes and terminal classification).apps/hook/server/annotate-cli.test.tsspawns the real CLI: single-token typo exit 1 (plain and--gate), strict--require-approvaland--result-fileinvocations with natural-language args exit 2 with empty stdout,--no-jnaexit 1, quoted-token preservation, directory-hijack handoff. Creates placeholder dist files when a build is absent so it runs in CI.bash apps/pi-extension/vendor.shregenerates cleanly;bun pm pack --dry-runincludesgenerated/annotate-target.ts.annotate notes.mdandannotate notes.md pleaseopen the file;annotate a.md b.mdexits 1 naming both candidates;annotate the aim docexits 0 with the stdout handoff;annotate the aim doc --jsonexits 1 with the stderr handoff;annotate the aim doc --markdown --no-jinaechoes the flags in the re-run suggestion;annotate nope.mdandannotate nope.md --gatestay exit 1File not found;annotate --no-jna https://...exits 1File not found: --no-jna;annotate "my notes.md" runmehands off instead of openingnotes.md;annotate please annotate .hands off instead of opening folder mode;annotate check script.py nowkeeps theFile type not supportederror; strictannotate nope-not-here.md --gate --json --require-approvalstays exit 2 with empty stdout, as does the strict multi-token case.Deliberate behavior change
plannotator annotate a.md b.mdused to silently opena.mdand dropb.md; it is now the tier-2 ambiguity error, per the never-guess guardrail.Out of scope
plannotator-reviewandplannotator-lastpositional args:parseReviewArgsalready ignores non-URL positionals andannotate-lastreads none, so the bang shape does not bite there./plannotator-annotate(no args) still prints usage and exits non-zero, which under the bang prefix aborts the prompt with the usage line visible in the UI. Turning that into a handoff changesplannotator annotateexit semantics for human terminals and deserves its own call.Closes #1182
Thanks @technicalpickles for the report, the history archaeology on #872, and the fix direction (option 1 plus 3), which this implements with the maintainer's exactly-one-resolves guardrail.